using System.Reflection; using System.Text; using System.Text.Json; using System.Threading.RateLimiting; using EntitlementBLL.Auth; using EntitlementBLL.Common; using EntitlementBLL.Handlers; using EntitlementBLL.Options; using EntitlementSL.Common; using GB5Shared.Auth.Jwt; using GB5Shared.Authorization; using GB5Shared.DaprCache; using GB5Shared.GB5CommonFunction; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.EventLogPublish; using GB5Shared.Hosting; using GB5Shared.Vault; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Quartz; namespace EntitlementSL; // Host-aggregation entry point (GB5Shared.Hosting.IModule) — folds Entitlement into PlatformHost // (see Hosts/Platform/PlatformHost/Program.cs). Signing-key resolution for both JWT bearer schemes // is lazy (IssuerSigningKeyResolver, resolved on first token validation via the shared // IJwtSigningKeyResolver/IVaultService) rather than a synchronous Vault fetch at Register() time — // this used to block ALL of PlatformHost's other modules from starting if Vault was unreachable at // boot, not just Entitlement. Kept identical to EntitlementSL/Program.cs's standalone path. public class EntitlementModule : IModule { public void Register(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); // Forwarded-headers trust + per-IP rate limiting for Login/RefreshToken/ForgotPassword/ // ResetPassword/CreateClientAdmin — same DI registrations as Program.cs's standalone path, // duplicated here for an identical DI graph. NOTE: the actual app.UseForwardedHeaders()/ // app.UseRateLimiter() MIDDLEWARE calls are the composing host's responsibility (there is // one shared pipeline for all modules) — PlatformHost/Program.cs does not currently call // either with these options configured; that is a separate, pre-existing, cross-module gap // affecting every module PlatformHost composes, not something this module can fix // unilaterally from Register()/MapEndpoints(). services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); var clientAuthRateLimitOptions = new ClientAuthRateLimitOptions(); configuration.GetSection(ClientAuthRateLimitOptions.SectionName).Bind(clientAuthRateLimitOptions); services.AddRateLimiter(opts => { opts.RejectionStatusCode = 429; opts.OnRejected = async (ctx, ct) => { ctx.HttpContext.Response.ContentType = "application/json"; var body = new ResponseStandardDTO { Status = FrameworkEnumDTO.ResponseStatus.Failed, ErrorBody = "Rate limit exceeded. Please retry after a moment.", ErrorInnerException = string.Empty }; await ctx.HttpContext.Response.WriteAsync(JsonSerializer.Serialize(body), ct); }; opts.AddPolicy(ClientAuthRateLimitPolicies.LoginAndRefresh, httpContext => RateLimitPartition.GetSlidingWindowLimiter( ClientIpPartitionKey(httpContext), _ => new SlidingWindowRateLimiterOptions { PermitLimit = clientAuthRateLimitOptions.LoginRefreshPermitLimit, Window = TimeSpan.FromSeconds(clientAuthRateLimitOptions.LoginRefreshWindowSeconds), SegmentsPerWindow = 4, QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true })); opts.AddPolicy(ClientAuthRateLimitPolicies.CreateClientAdmin, httpContext => RateLimitPartition.GetSlidingWindowLimiter( ClientIpPartitionKey(httpContext), _ => new SlidingWindowRateLimiterOptions { PermitLimit = clientAuthRateLimitOptions.CreateClientAdminPermitLimit, Window = TimeSpan.FromSeconds(clientAuthRateLimitOptions.CreateClientAdminWindowSeconds), SegmentsPerWindow = 4, QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true })); opts.AddPolicy(ClientAuthRateLimitPolicies.SelfProvisionTrial, httpContext => RateLimitPartition.GetSlidingWindowLimiter( ClientIpPartitionKey(httpContext), _ => new SlidingWindowRateLimiterOptions { PermitLimit = clientAuthRateLimitOptions.SelfProvisionTrialPermitLimit, Window = TimeSpan.FromSeconds(clientAuthRateLimitOptions.SelfProvisionTrialWindowSeconds), SegmentsPerWindow = 4, QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true })); }); services.Configure(configuration.GetSection(EntitlementDbOptions.SectionName)); services.Configure(configuration.GetSection(EcdsaKeyOptions.SectionName)); services.AddScoped(); // ClientOnboardingOrchestratorBLL (Thread 6/§31 Phase 3) — same registrations as // Program.cs (standalone path), duplicated here for DI-graph parity. NOTE: this module // has no Dapr registration at all in host-aggregated mode (pre-existing gap — the // already-shipped GoLiveDeclaredSubscriber has the identical limitation), so // ClientProvisioningExecutedSubscriber will not actually receive events unless the // composing host (e.g. PlatformHost) adds AddDaprClient()/.AddControllers().AddDapr()/ // MapSubscribeHandler() itself — not something this module can fix unilaterally. services.Configure(configuration.GetSection(ClientProvisioningOptions.SectionName)); services.Configure( configuration.GetSection(EntitlementBLL.Options.OnboardingOptions.SectionName)); services.AddHttpClient("SqlWorkbench", c => { var baseUrl = configuration["Integration:SqlWorkbenchBaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) c.BaseAddress = new Uri(baseUrl); }); services.AddHttpClient("FrameworkDataSync", c => { var baseUrl = configuration["Integration:FrameworkDataSyncBaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) c.BaseAddress = new Uri(baseUrl); }); // GOODBOOKS_ADMIN-gated endpoints authorize via [MenuRights] (Login-header + MROLEVSMENU, // same convention as every other GB5 module), not a JWT role — see Thread 0 §20. Same // registration as Program.cs (standalone path), for DI-graph parity in host-aggregated mode. services.AddMenuRightsAuthorization(); // IGB5CommonFunction — every IQueryExecutor consumer (which is nearly everything in this // module) needs this resolvable. PlatformHost's own Program.cs already registers it // globally, so this is technically redundant there, but keeps this module's own DI graph // self-sufficient if it's ever composed into a host that doesn't register it itself — // same DI-graph-parity reasoning as AddMenuRightsAuthorization() above. services.AddScoped(); // Client-facing auth config + Vault + ClientJwtBearer scheme — same block as Program.cs // (standalone path), duplicated here so host-aggregated mode gets an identical DI graph. services.Configure(configuration.GetSection(ClientJwtOptions.SectionName)); services.Configure(configuration.GetSection(ClientAccountLockoutOptions.SectionName)); services.Configure(configuration.GetSection(ClientPasswordResetOptions.SectionName)); services.AddScoped(); // Vault + shared JWT issuer — same consolidation as Program.cs (standalone path): swaps // the old module-local IVaultClient + IClientSecretResolver registration for // GB5Shared.Vault/GB5Shared.Auth.Jwt's shared services. services.AddGB5Vault(configuration); services.AddGB5JwtIssuer(); // Issuer/audience are plain config reads — no Vault involved, safe at Register() time. var clientJwtIssuer = configuration["Entitlement:ClientJwt:Issuer"]!; var clientJwtAudience = configuration["Entitlement:ClientJwt:Audience"]!; var partnerM2MIssuer = configuration["Partner:M2MJwt:Issuer"]!; var partnerM2MAudience = configuration["Partner:M2MJwt:Audience"]!; // Both schemes' signing keys are resolved lazily, on first token validation, via the // shared IJwtSigningKeyResolver (backed by IVaultService's own 5-minute cache) — NOT a // synchronous Vault fetch here at Register()/host-boot time. IssuerSigningKeyResolver's // delegate signature is inherently synchronous (no async extensibility point exists in // Microsoft.IdentityModel.Tokens for this), so the sync-over-async bridge below is a // narrow, unavoidable exception — but it only ever blocks a single request thread on an // IVaultService cache miss, never the host's startup path. services.AddAuthentication() .AddJwtBearer("ClientJwtBearer", _ => { }) .AddJwtBearer("PartnerM2MJwtBearer", _ => { }); services.AddOptions("ClientJwtBearer") .Configure((options, keyResolver) => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = clientJwtIssuer, ValidateAudience = true, ValidAudience = clientJwtAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => new[] { new SymmetricSecurityKey(Encoding.UTF8.GetBytes( keyResolver.GetSigningKeyAsync("entitlement/jwt-signing-key", CancellationToken.None) .GetAwaiter().GetResult())) }, ClockSkew = TimeSpan.FromSeconds(30), RoleClaimType = ClientJwtClaimTypes.Role }; }); services.AddOptions("PartnerM2MJwtBearer") .Configure((options, keyResolver) => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = partnerM2MIssuer, ValidateAudience = true, ValidAudience = partnerM2MAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => new[] { new SymmetricSecurityKey(Encoding.UTF8.GetBytes( keyResolver.GetSigningKeyAsync("partner/m2m-jwt-signing-key", CancellationToken.None) .GetAwaiter().GetResult())) }, ClockSkew = TimeSpan.FromSeconds(30) }; }); services.AddAuthorization(); var bll = Assembly.Load("EntitlementBLL"); var dal = Assembly.Load("EntitlementDAL"); // GB5Shared — needed for ClientAuthBLL's forgot/reset-password flow and // ClientProvisioningBLL's first-admin-user creation. Same registration set as // Program.cs (standalone path), duplicated here so host-aggregated mode gets an // identical DI graph. GB5Shared is a raw Reference (not a ProjectReference), so // Scrutor's assembly scan below never sees it. services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // EXCLUDE IHostedService — see Program.cs's identical comment (DXPSL incident, 2026-07-15). // EXCLUDE Exception — found via a real dotnet-run DI validation pass while wiring this // module into PlatformHost: System.Exception implements ISerializable, so // AsImplementedInterfaces() was registering EntitlementBLL.Exceptions.* as ISerializable // services, then failing to construct them (their constructors take a string message, not // DI-resolvable). services.Scan(scan => scan .FromAssemblies(bll, dal) // Exclude plain DTOs/records (a `record` implicitly implements IEquatable) and // Exception types (every Exception implements ISerializable) — without this filter // AsImplementedInterfaces() registers them as services and the container later fails // trying to activate them via their data/message constructor. Also exclude // IHostedService (DXPSL incident, 2026-07-15). .AddClasses(c => c.Where(t => !t.IsAbstract && !t.IsInterface && (t.Namespace == null || !t.Namespace.Contains(".DTO")) && !typeof(Exception).IsAssignableFrom(t) && !typeof(Microsoft.Extensions.Hosting.IHostedService).IsAssignableFrom(t))) .AsImplementedInterfaces() .WithScopedLifetime()); // Same 5-job Quartz registration as Program.cs (standalone path), duplicated here so // host-aggregated mode gets an identical DI graph (matches PERMModule.cs's dual- // registration pattern referenced by DXPModule.cs's own comment). services.AddQuartz(q => { q.UseSimpleTypeLoader(); q.UseInMemoryStore(); // FeatureFlagSchedulerJob/KillSwitchMonitorJob/PayOrderReconciliationJob were here — // cut over to Track A (GB5Framework's real MJOBDEFINE/TSCHEDULER scheduler, tracker // §45), matching the standalone Program.cs path. Logic moved verbatim into // EntitlementBLL.Handlers.SchedulerTasksBLL, dispatched via // GET /Entitlement/Scheduler/Run{KillSwitchMonitor,FeatureFlagScheduler,PayOrderReconciliation}. var bundleRefreshKey = new JobKey("EntitlementBundleRefreshJob"); q.AddJob(opts => opts.WithIdentity(bundleRefreshKey)); q.AddTrigger(opts => opts.ForJob(bundleRefreshKey) .WithIdentity("EntitlementBundleRefreshJob-trigger") .WithCronSchedule("0 0 2 * * ?")); var licenseExpiryKey = new JobKey("LicenseExpiryWarningJob"); q.AddJob(opts => opts.WithIdentity(licenseExpiryKey)); q.AddTrigger(opts => opts.ForJob(licenseExpiryKey) .WithIdentity("LicenseExpiryWarningJob-trigger") .WithCronSchedule("0 0 8 * * ?")); var betaExpiryKey = new JobKey("BetaClientExpiryJob"); q.AddJob(opts => opts.WithIdentity(betaExpiryKey)); q.AddTrigger(opts => opts.ForJob(betaExpiryKey) .WithIdentity("BetaClientExpiryJob-trigger") .WithCronSchedule("0 0 3 * * ?")); }); services.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true); // ProvisioningHub — real-time client-onboarding status push (tracker §37 Decision 2). // Same registration shape as Program.cs (standalone path) / DXPModule.cs's own AddSignalR // block. NOTE (mirrors DXPModule.cs's own documented caveat): host-aggregated mode still // needs the composing host's own JWT bearer OnMessageReceived hook if this hub is ever // gated by auth — it isn't today (no [Authorize], same posture as DataSyncHub), so this // is not currently a blocker, just worth knowing if that changes. services.AddSignalR(options => { options.EnableDetailedErrors = false; options.MaximumReceiveMessageSize = 32 * 1024; options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); options.KeepAliveInterval = TimeSpan.FromSeconds(15); }); services.AddScoped(); } public void MapEndpoints(WebApplication app) { // FastEndpoints + Dapr subscribers are auto-discovered via assembly scanning in the // host's own AddFastEndpoints/MapControllers — only the hub itself needs an explicit map. app.MapHub("/hubs/provisioning"); } // IP is the only durable partition key available pre-authentication. Depends on // ForwardedHeadersOptions above actually being applied via app.UseForwardedHeaders() by // whichever host composes this module — see the Register() comment on that gap. private static string ClientIpPartitionKey(HttpContext httpContext) => httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; }