import { ConfidentialClientApplication, CryptoProvider, type AuthenticationResult, } from '@azure/msal-node'; import { Inject, Injectable } from '@nestjs/common'; import { PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie'; import { AuthCodeFlowException } from './auth.errors'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; import { MSAL_CLIENT } from './msal-client.token'; /** * Payload carried in the pre-auth cookie between `/auth/login` and * `/auth/callback`: the OIDC `state` (anti-CSRF nonce) and the PKCE * `codeVerifier` (secret the BFF sends back to Entra to prove it * was the one that asked for the code). `createdAt` lets the * callback reject cookies older than the flow's expected duration. */ export interface PreAuthPayload { readonly state: string; readonly codeVerifier: string; readonly createdAt: number; } export interface AuthCodeFlowStart { readonly authUrl: string; readonly preAuthPayload: PreAuthPayload; } /** * Identity resolved by `completeAuthCodeFlow`. Carries the claims * the rest of the BFF / SPA care about post-authentication: * - `oid`: stable per-user object id inside the Entra tenant. * - `tid`: tenant id the user authenticated against (for the * dual-audience / multi-tenant logic to come). * - `username` / `displayName`: surfaced in the UI. * - `amr`: authentication methods reference — the array used by * the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa` * freshness checks once the session lands. * - `roles`: Entra app roles assigned to this user on the BFF's * app registration. These are the four `Portal.*` *privileges* * per ADR-0025 (`Portal.Admin`, `Portal.Auditor`, * `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the * existing `@RequireAdmin()` guard and the upcoming * `@RequirePrivilege()` decorator. Always present in the shape * — an empty array means the user has no app role on this app * registration, not that the claim was unparseable. * - `groups`: Entra security-group GUIDs the user belongs to. * Emitted by the `groups` claim once the app registration sets * `groupMembershipClaims: 'SecurityGroup'` (per ADR-0025 * §"Sources of truth — Entra-side configuration"). The * `PrincipalBuilder` resolves these GUIDs to `apf-role-*` slugs * via the `EntraGroupToRoleResolver`. Always present — empty * means either the user has no group membership or the claim * was not configured on the app registration. Unknown GUIDs * are dropped at resolve time, not at extraction time, so an * audit reader still sees the raw claim. */ export interface AuthenticatedUser { readonly oid: string; readonly tid: string; readonly username: string; readonly displayName: string; readonly amr: readonly string[]; readonly roles: readonly string[]; readonly groups: readonly string[]; } /** * The minimum OIDC scopes — `openid` to get an ID token, `profile` * for the user's display name / preferred_username, `email` for the * email claim. Refresh tokens (`offline_access`) are deliberately * omitted in v1: sessions are short-lived (per ADR-0010) and the * user re-authenticates through Entra rather than the BFF refreshing * tokens behind their back. */ const SCOPES: readonly string[] = ['openid', 'profile', 'email']; @Injectable() export class AuthService { private readonly crypto = new CryptoProvider(); constructor( @Inject(MSAL_CLIENT) private readonly msal: ConfidentialClientApplication, @Inject(ENTRA_CONFIG) private readonly config: EntraConfig, ) {} /** * First leg of the OIDC Authorization Code + PKCE flow. Builds * the URL the user gets redirected to (Entra's authorize * endpoint) and the pre-auth payload the controller stores in a * signed cookie so the callback can verify the round-trip later. * * MSAL Node owns the PKCE code generation (`CryptoProvider`) so * the verifier / challenge pair is canonical. The state nonce is * a fresh GUID per flow. * * `redirectUri` is passed by the caller (user-portal or * admin-portal controller) per ADR-0020 §"Sessions — distinct * from `portal-shell`". Same MSAL client, distinct redirect URI → * Entra routes the callback to the matching session. */ async beginAuthCodeFlow(redirectUri: string): Promise { const { verifier, challenge } = await this.crypto.generatePkceCodes(); const state = this.crypto.createNewGuid(); const authUrl = await this.msal.getAuthCodeUrl({ redirectUri, scopes: [...SCOPES], state, codeChallenge: challenge, codeChallengeMethod: 'S256', }); return { authUrl, preAuthPayload: { state, codeVerifier: verifier, createdAt: Date.now(), }, }; } /** * Second leg. Verifies the state round-trip, refuses cookies * older than the flow TTL, asks MSAL to exchange the auth code * for tokens using the stored PKCE verifier, then runs the * BFF-side sanity-checks ADR-0009 mandates (state, expiry, `amr`). * * MSAL Node performs the heavy ID-token validation itself * (signature against Entra's JWKS, issuer + audience, exp, nbf). * What this method adds on top is the application-layer policy: * - anti-CSRF state binding, * - flow-TTL replay protection, * - MFA presence (`amr`) per ADR-0011. */ async completeAuthCodeFlow( code: string, state: string, preAuth: PreAuthPayload, redirectUri: string, now: number = Date.now(), ): Promise { if (state !== preAuth.state) { throw new AuthCodeFlowException({ kind: 'state-mismatch' }); } if (now - preAuth.createdAt > PRE_AUTH_COOKIE_TTL_MS) { throw new AuthCodeFlowException({ kind: 'flow-expired' }); } let result: AuthenticationResult | null; try { result = await this.msal.acquireTokenByCode({ code, codeVerifier: preAuth.codeVerifier, redirectUri, scopes: [...SCOPES], }); } catch (err) { throw new AuthCodeFlowException({ kind: 'token-exchange-failed', cause: errorCause(err), }); } if (!result) { throw new AuthCodeFlowException({ kind: 'token-exchange-failed', cause: 'msal returned null result', }); } return this.toAuthenticatedUser(result); } /** * Build the Entra RP-initiated logout URL. Hits the v2.0 logout * endpoint on the configured authority and passes the * `post_logout_redirect_uri` so the browser lands back on the SPA * after Entra finishes its side of the sign-out. * * We deliberately do *not* pass `id_token_hint` (which would skip * the account-picker on multi-account devices) because v1 doesn't * persist the id_token in the session yet — token persistence * lands with downstream API support per ADR-0014. The account * picker is the safer default in the interim. */ buildLogoutUrl(postLogoutRedirectUri: string): string { const url = new URL(`${this.config.authority}/oauth2/v2.0/logout`); url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); return url.toString(); } private toAuthenticatedUser(result: AuthenticationResult): AuthenticatedUser { const claims = result.idTokenClaims as Record; // `amr` is an optional claim. Entra includes it when it has // explicit auth-method tracking to report (fresh interactive // sign-in with MFA, etc.) and may omit it for SSO / refresh // flows or when no Conditional Access policy requires the // method to be surfaced. The BFF surfaces whatever is there in // the audit log / future `@RequireMfa` guard — but it does NOT // reject the token on empty / missing `amr`. Per ADR-0011, MFA // enforcement is org-side via Conditional Access, and the // BFF's "sanity check" is that MSAL accepted the token at all // (signature + issuer + audience + exp validated). Sensitive // routes that need a fresh MFA event will assert it explicitly // through the `@RequireMfa({ freshness: 600 })` decorator once // it lands. const amr = Array.isArray(claims['amr']) ? (claims['amr'] as unknown[]).filter((v): v is string => typeof v === 'string') : []; // `roles` is an optional claim. Entra includes it when the user // has at least one app role assigned on the BFF's app // registration. The value is an array of the role `value` // strings declared in the registration manifest (e.g. `admin`). // No role assignment → claim is omitted entirely; we normalise // to an empty array so consumers can call `.includes('admin')` // unconditionally. Per ADR-0020, role-based authorisation is the // single source of authority for admin access; the AdminRoleGuard // will read this field on every `/api/admin/*` request. const roles = Array.isArray(claims['roles']) ? (claims['roles'] as unknown[]).filter((v): v is string => typeof v === 'string') : []; // `groups` is an optional claim (per ADR-0025 §"Sources of truth // — Entra-side configuration"). Present only when the app // registration sets `groupMembershipClaims: 'SecurityGroup'`. // Empty array if the user is in zero security groups or the // claim is not configured — both are valid "no functional // roles" states the rest of the BFF must tolerate. Same // string-filter as `roles` to defend against a non-string value // smuggled in via a malformed token. const groups = Array.isArray(claims['groups']) ? (claims['groups'] as unknown[]).filter((v): v is string => typeof v === 'string') : []; return { oid: requireString(claims['oid'], 'oid'), tid: requireString(claims['tid'], 'tid'), username: typeof claims['preferred_username'] === 'string' ? (claims['preferred_username'] as string) : (result.account?.username ?? ''), displayName: typeof claims['name'] === 'string' ? (claims['name'] as string) : (result.account?.name ?? ''), amr, roles, groups, }; } } function requireString(value: unknown, claim: string): string { if (typeof value !== 'string' || value === '') { throw new AuthCodeFlowException({ kind: 'token-exchange-failed', cause: `id token missing required string claim: ${claim}`, }); } return value; } function errorCause(err: unknown): string { if (err instanceof Error) { return err.message; } return String(err); }