import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node'; import { Module } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { assertEntraConfig } from '../config/check-entra-config'; import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map'; import { SessionModule } from '../session/session.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token'; import { MSAL_CLIENT } from './msal-client.token'; import { PrincipalBuilder } from './principal-builder'; import { PrismaScopeResolver } from './prisma-scope-resolver'; import { RequireMfaGuard } from './require-mfa.guard'; import { RequirePrivilegeGuard } from './require-privilege.guard'; import { RequireRoleGuard } from './require-role.guard'; import { RequireScopeGuard } from './require-scope.guard'; import { ScopeResolver } from './scope-resolver'; import { SessionEstablisher } from './session-establisher.service'; /** * Auth module — owns the Entra ID configuration, the MSAL Node * confidential client, and (in subsequent PRs) the OIDC routes, the * session integration, and the route guards. Per ADR-0009. * * v1 providers: * * - `ENTRA_CONFIG` — the parsed, validated Entra app-registration * config (PR #102). * - `MSAL_CLIENT` — a `ConfidentialClientApplication` instance * wired with that config plus a logger callback that forwards * MSAL's internal log lines into the Pino stream so auth flow * diagnostics land alongside the rest of the BFF logs (per * ADR-0012). PII logging is disabled by default — MSAL won't * include tokens or user identifiers in the messages it emits. * - `AuthService` — first-leg-of-the-flow logic: PKCE + state * generation, MSAL auth-code URL building. The controller * stays a thin shell around it. * * v1 routes (per ADR-0009): * * - `GET /api/auth/login` — 302 to Entra's authorize endpoint * with the freshly-generated state + code challenge; sets a * short-lived signed cookie carrying the state + PKCE * verifier so the next-PR callback can verify the round-trip. * * The module stays non-global: modules state "I depend on auth" by * importing it. Re-exports both tokens so a single * `imports: [AuthModule]` is enough to consume either. */ @Module({ imports: [SessionModule], controllers: [AuthController], providers: [ AuthService, RequireMfaGuard, RequirePrivilegeGuard, RequireRoleGuard, RequireScopeGuard, SessionEstablisher, PrincipalBuilder, // PrismaScopeResolver replaces StubScopeResolver per ADR-0026 PR 2. // The stub class stays exported from scope-resolver.ts as a // fallback for spec fixtures / future "force-unrestricted" dev // modes, but it is no longer the default wiring. { provide: ScopeResolver, useClass: PrismaScopeResolver }, { provide: ENTRA_CONFIG, useFactory: () => assertEntraConfig(), }, { provide: ENTRA_GROUP_TO_ROLE_RESOLVER, inject: [Logger], useFactory: (logger: Logger) => { const { resolver, sourcePath } = loadEntraGroupToRoleResolver({ onWarn: (event, payload) => logger.warn({ event, ...payload }, 'AuthModule'), }); // Log the post-load summary unconditionally so an operator // grepping the boot log can confirm how many functional // roles are wired without inspecting the JSON file. The // file path (if any) helps diagnose env-var / cwd mismatches. logger.log( { event: 'auth.entra_group_map_loaded', sourcePath, mappingCount: resolver.size, coveredRoles: resolver.coveredRoles(), }, 'AuthModule', ); return resolver; }, }, { provide: MSAL_CLIENT, inject: [ENTRA_CONFIG, Logger], useFactory: (config: EntraConfig, logger: Logger) => new ConfidentialClientApplication({ auth: { clientId: config.clientId, authority: config.authority, clientSecret: config.clientSecret, }, system: { loggerOptions: { piiLoggingEnabled: false, logLevel: LogLevel.Info, loggerCallback: (level, message) => { // MSAL levels: 0=Error, 1=Warning, 2=Info, 3=Verbose, 4=Trace. // Forward to Pino with the matching level. The whole // payload lives under the `msal` context key so a log // search filtering on `msal` returns every MSAL line // without false positives from app code. const ctx = 'msal'; switch (level) { case LogLevel.Error: logger.error(message, ctx); break; case LogLevel.Warning: logger.warn(message, ctx); break; case LogLevel.Verbose: case LogLevel.Trace: logger.debug(message, ctx); break; default: logger.log(message, ctx); } }, }, }, }), }, ], exports: [ ENTRA_CONFIG, ENTRA_GROUP_TO_ROLE_RESOLVER, MSAL_CLIENT, RequireMfaGuard, RequirePrivilegeGuard, RequireRoleGuard, RequireScopeGuard, AuthService, PrincipalBuilder, ScopeResolver, SessionEstablisher, ], }) export class AuthModule {}