/** * Sanity-check the Entra ID app-registration env vars early in * bootstrap so a missing or malformed value fails fast with a clear, * actionable message instead of a deep `@azure/msal-node` error * thrown on the first auth request. * * Wired in `main.ts` alongside `assertDatabaseUrl()` — same family * of "fail before any request lands" guard recommended by ADR-0018 * §"BFF env-var loading". * * The four `*_INSTANCE_URL` / `*_TENANT_ID` / `*_CLIENT_ID` / * `*_CLIENT_SECRET` keys are mandatory; the two redirect URIs are * mandatory once the OIDC routes ship (next PR). Until then the * validator returns the parsed config but does not consume it; the * `AuthModule` exposes it via DI so the future MSAL client factory * picks it up by constructor injection. */ export interface EntraConfig { /** * Microsoft login endpoint, e.g. `https://login.microsoftonline.com/`. * Combined with `tenantId` to build the MSAL `authority`. */ readonly instanceUrl: string; /** Directory (tenant) UUID. */ readonly tenantId: string; /** App registration UUID. */ readonly clientId: string; /** Confidential client secret. */ readonly clientSecret: string; /** Where Entra sends the user after authentication — `/api/auth/callback`. */ readonly redirectUri: string; /** Where Entra sends the user after RP-initiated logout. */ readonly postLogoutRedirectUri: string; /** * Admin-surface callback URL — `/api/admin/auth/callback`. Distinct * from {@link redirectUri} per ADR-0020 §"Sessions — distinct from * `portal-shell`" so Entra knows which callback handler (and * therefore which session) the auth flow is establishing. * Both URIs must be registered on the same Entra app registration. */ readonly adminRedirectUri: string; /** * Where Entra sends the admin user after RP-initiated logout from * the admin surface. Typically the admin SPA's landing page — kept * distinct from {@link postLogoutRedirectUri} so an operator can * route admin sign-outs to a different page than user sign-outs. */ readonly adminPostLogoutRedirectUri: string; /** * Convenience: the full authority URL passed to MSAL Node * (`${instanceUrl}${tenantId}`). Computed once so the rest of the * codebase does not re-derive it on every call. */ readonly authority: string; } const REQUIRED_KEYS = [ 'ENTRA_INSTANCE_URL', 'ENTRA_TENANT_ID', 'ENTRA_CLIENT_ID', 'ENTRA_CLIENT_SECRET', 'ENTRA_REDIRECT_URI', 'ENTRA_POST_LOGOUT_REDIRECT_URI', 'ENTRA_ADMIN_REDIRECT_URI', 'ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI', ] as const; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const PLACEHOLDER_TENANT_OR_CLIENT = '00000000-0000-0000-0000-000000000000'; const PLACEHOLDER_SECRET = 'replace_with_real_value'; export function assertEntraConfig(): EntraConfig { const missing = REQUIRED_KEYS.filter((k) => !process.env[k] || process.env[k] === ''); if (missing.length > 0) { throw new Error( `Missing Entra config env vars: ${missing.join(', ')}. ` + `Copy apps/portal-bff/.env.example to apps/portal-bff/.env ` + `and fill in the values from the Entra app registration.`, ); } // After the `missing` check, every required key is present. const instanceUrl = process.env['ENTRA_INSTANCE_URL'] as string; const tenantId = process.env['ENTRA_TENANT_ID'] as string; const clientId = process.env['ENTRA_CLIENT_ID'] as string; const clientSecret = process.env['ENTRA_CLIENT_SECRET'] as string; const redirectUri = process.env['ENTRA_REDIRECT_URI'] as string; const postLogoutRedirectUri = process.env['ENTRA_POST_LOGOUT_REDIRECT_URI'] as string; const adminRedirectUri = process.env['ENTRA_ADMIN_REDIRECT_URI'] as string; const adminPostLogoutRedirectUri = process.env['ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI'] as string; assertUrl('ENTRA_INSTANCE_URL', instanceUrl, ['https:']); if (!instanceUrl.endsWith('/')) { throw new Error( `ENTRA_INSTANCE_URL must end with a trailing "/" so the authority ` + `(${instanceUrl}) concatenates correctly. Got: ${instanceUrl}`, ); } assertUuid('ENTRA_TENANT_ID', tenantId); assertUuid('ENTRA_CLIENT_ID', clientId); assertNotPlaceholder('ENTRA_TENANT_ID', tenantId, PLACEHOLDER_TENANT_OR_CLIENT); assertNotPlaceholder('ENTRA_CLIENT_ID', clientId, PLACEHOLDER_TENANT_OR_CLIENT); assertNotPlaceholder('ENTRA_CLIENT_SECRET', clientSecret, PLACEHOLDER_SECRET); assertUrl('ENTRA_REDIRECT_URI', redirectUri, ['http:', 'https:']); assertUrl('ENTRA_POST_LOGOUT_REDIRECT_URI', postLogoutRedirectUri, ['http:', 'https:']); assertUrl('ENTRA_ADMIN_REDIRECT_URI', adminRedirectUri, ['http:', 'https:']); assertUrl('ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI', adminPostLogoutRedirectUri, [ 'http:', 'https:', ]); // Admin and user redirect URIs must differ — they're the only // way Entra (and therefore the BFF) tells the two flows apart. // A misconfiguration that points both to the same handler would // silently collapse the two surfaces into one session. if (adminRedirectUri === redirectUri) { throw new Error( `ENTRA_ADMIN_REDIRECT_URI must differ from ENTRA_REDIRECT_URI ` + `so Entra can route the callback to the matching session ` + `(see ADR-0020 §"Sessions — distinct from portal-shell"). ` + `Both are: ${redirectUri}`, ); } return { instanceUrl, tenantId, clientId, clientSecret, redirectUri, postLogoutRedirectUri, adminRedirectUri, adminPostLogoutRedirectUri, authority: `${instanceUrl}${tenantId}`, }; } function assertUrl(key: string, value: string, allowedProtocols: readonly string[]): void { let parsed: URL; try { parsed = new URL(value); } catch { throw new Error(`${key} is not a valid URL. Got: ${value}`); } if (!allowedProtocols.includes(parsed.protocol)) { throw new Error( `${key} must use one of [${allowedProtocols.join(', ')}]; got "${parsed.protocol}".`, ); } } function assertUuid(key: string, value: string): void { if (!UUID_RE.test(value)) { throw new Error( `${key} must be a UUID (e.g. "0123abcd-0123-4567-89ab-cdef01234567"). Got: ${value}`, ); } } function assertNotPlaceholder(key: string, value: string, placeholder: string): void { if (value === placeholder) { throw new Error( `${key} is still set to the .env.example placeholder ("${placeholder}"). ` + `Replace with the real value from the Entra app registration.`, ); } }