using System.Security.Claims; using EntitlementBLL.Auth; namespace EntitlementSL.Common; // Everything a ClientAuth endpoint knows about the calling CLIENT_ADMIN/CLIENT_USER, taken ONLY // from validated JWT claims (HttpContext.User.Claims, populated by JWT bearer middleware after // verifying the signature against Entitlement's own client-jwt signing key) — never from request // body/headers. Mirrors DXPSL.Common.DXPCallerContext's exact FromClaims pattern, which is the // established precedent in this codebase for "internet-facing, non-employee caller identity must // come only from verified claims." // // Note this is deliberately NOT a replacement for GB5Shared.FastEndPoint.BaseEndpoint the way // DXPBaseEndpoint replaces GB5Shared's base for DXP — Entitlement's ClientAuth endpoints still use // the module's standard BaseEndpoint (for LoginDTO/DB-routing, caching, telemetry, report // export, exactly like every other Entitlement endpoint, including existing AllowAnonymous ones // such as Feature/GetFeatureList.cs). LoginDTO in this module is DB-ROUTING context (which // tenant/database a query targets), never an authorization/identity mechanism — every Roles() // gated endpoint here already relies on ASP.NET Core's own authorization pipeline (JWT bearer role // claims), not on anything inside LoginDTO. So this class is purely additive: read it inside // ExecuteAsync wherever a ClientAuth endpoint needs the CALLER's own ClientUserId/ClientId/Role, // alongside (not instead of) the LoginDTO the base class already reconstructs for DB routing. // // REAL as of item 9: EntitlementSL/Program.cs registers a named JWT bearer scheme // ("ClientJwtBearer") that validates tokens signed with Entitlement's own ClientJwtOptions // signing key/issuer/audience (Vault-backed, resolved once at startup) and maps // TokenValidationParameters.RoleClaimType to ClientJwtClaimTypes.Role so FastEndpoints' Roles() // attribute correctly matches CLIENT_ADMIN/CLIENT_USER claims. CreateClientUser.cs/ // ChangePassword.cs both declare AuthSchemes("ClientJwtBearer") + Roles(...) as the real // framework-level gate. It is registered as a NAMED, non-default scheme — not the default — // because this module already has GOODBOOKS_ADMIN-gated endpoints (Roles("GOODBOOKS_ADMIN"), no // explicit scheme) meant for a separate, not-yet-registered internal-employee auth scheme; making // client-jwt the default would have silently routed those endpoints' authorization checks through // the wrong scheme. See Program.cs's registration comment for the full reasoning. // // Each endpoint additionally re-checks caller.Role/caller.ClientId (or caller.ClientUserId) // explicitly in ExecuteAsync as genuine defense-in-depth (same posture as Feature/GetFeatureList.cs // re-enforcing its PIE-only rule inside FeatureBLL rather than trusting Configure()-level // attributes alone) — this also catches a missing/malformed claim beyond just role membership, // which Roles() alone would not. public class ClientCallerContext { public int ClientUserId { get; set; } public int ClientId { get; set; } public string Role { get; set; } = string.Empty; /// EntitlementClientCapabilityCodes values parsed from the Capabilities claim — used /// by capability-gated endpoints (e.g. RequestChangePlan requires CommercialAdmin) to check /// membership with no DB round-trip. public IReadOnlyList Capabilities { get; set; } = Array.Empty(); public bool HasCapability(string capabilityCode) => Capabilities.Contains(capabilityCode); public static ClientCallerContext FromClaims(IEnumerable claims) { var dict = claims.ToDictionary(c => c.Type, c => c.Value); int GetInt(string key) => dict.TryGetValue(key, out var v) && int.TryParse(v, out var i) ? i : 0; string GetStr(string key) => dict.TryGetValue(key, out var v) ? v : string.Empty; return new ClientCallerContext { ClientUserId = GetInt(ClientJwtClaimTypes.ClientUserId), ClientId = GetInt(ClientJwtClaimTypes.ClientId), Role = GetStr(ClientJwtClaimTypes.Role), Capabilities = GetStr(ClientJwtClaimTypes.Capabilities) .Split(',', StringSplitOptions.RemoveEmptyEntries) }; } }