From 7992a5ba144f90a133c0321a0b9746fef63e4639 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 12 May 2026 02:22:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal-bff):=20entra=20config=20foundation?= =?UTF-8?q?=20=E2=80=94=20boot=20validator=20+=20auth=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of ADR-0009 wiring. Captures the Entra app-registration env vars in the boot pipeline so subsequent PRs can plug `@azure/msal-node` straight onto a typed, already-validated config without re-reading process.env. No MSAL client, no OIDC routes, no session integration yet — those land in follow-up PRs. What lands: - `.env.example` promotes the Entra block from its previous "Future env vars" comment stub to an active configuration section. Six keys: `ENTRA_INSTANCE_URL`, `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`, `ENTRA_REDIRECT_URI`, `ENTRA_POST_LOGOUT_REDIRECT_URI`. UUID + URL placeholders so the spec test for the "still-the-placeholder" guard has a real target. Multi-tenant `ENTRA_ACCEPTED_TENANT_IDS` stays in the future-vars comment until External ID activation lands. - `apps/portal-bff/src/config/check-entra-config.ts` — boot validator mirroring `check-database-url.ts`. Asserts every required key is present, validates the instance URL is `https://` and ends with `/`, the tenant + client IDs are UUIDs, none of them are the literal .env.example placeholder, and the two redirect URIs are parseable URLs. Returns a typed `EntraConfig` object with a pre-computed `authority` field (`${instanceUrl}${tenantId}`) so the MSAL factory in the next PR does not re-derive it. - `check-entra-config.spec.ts` — covers the happy path plus eight failure modes (missing keys, non-https instance, missing trailing slash, non-UUID tenant, placeholder UUID, placeholder secret, invalid redirect URI, http redirect for local dev). - `apps/portal-bff/src/auth/auth.module.ts` — `AuthModule` whose v1 surface is a single provider: the parsed `EntraConfig` keyed by the `ENTRA_CONFIG` injection token. Factory delegates to `assertEntraConfig()`. Module is non-global so consumers state intent by importing it. - `apps/portal-bff/src/auth/entra-config.token.ts` — `ENTRA_CONFIG` string token + `EntraConfig` type re-export. The pattern subsequent modules will use: `@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig`. - `auth.module.spec.ts` — verifies the provider resolves the typed config, and that compilation fails when an env var is missing (boot-failure behaviour is preserved across the DI boundary). - `main.ts` calls `assertEntraConfig()` alongside `assertDatabaseUrl()` so misconfiguration fails fast at boot rather than mid-request (per ADR-0018 §"BFF env-var loading"). - `AppModule` imports `AuthModule`. Verification: 29 / 29 specs (was 20; +9 from the new entra-config spec + auth.module spec), lint clean, webpack build green. Naming: chose `ENTRA_*` rather than `AZURE_AD_*` to align with ADR text (Microsoft Entra ID, post-2023 rebrand). The values you copy from the Entra app-registration UI go into `apps/portal-bff/.env` (git-ignored). --- apps/portal-bff/.env.example | 42 ++++-- apps/portal-bff/src/app/app.module.ts | 2 + apps/portal-bff/src/auth/auth.module.spec.ts | 48 +++++++ apps/portal-bff/src/auth/auth.module.ts | 28 ++++ .../portal-bff/src/auth/entra-config.token.ts | 15 ++ .../src/config/check-entra-config.spec.ts | 84 +++++++++++ .../src/config/check-entra-config.ts | 131 ++++++++++++++++++ apps/portal-bff/src/main.ts | 6 + 8 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 apps/portal-bff/src/auth/auth.module.spec.ts create mode 100644 apps/portal-bff/src/auth/auth.module.ts create mode 100644 apps/portal-bff/src/auth/entra-config.token.ts create mode 100644 apps/portal-bff/src/config/check-entra-config.spec.ts create mode 100644 apps/portal-bff/src/config/check-entra-config.ts diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index 956377b..844d586 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -34,16 +34,42 @@ OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # Collector (per ADR-0012). Override only for spike investigations. OTEL_TRACES_SAMPLER=always_on +# Identity / Entra ID app registration (per ADR-0008 / ADR-0009) +# Values come from the project's Entra application registration in the +# Azure Admin Center → App registrations → APF Portal. The four +# *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys +# are mandatory; the BFF refuses to boot without them (see +# apps/portal-bff/src/config/check-entra-config.ts). +# +# ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually +# https://login.microsoftonline.com/. The authority used by MSAL is +# `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows, +# or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant +# (per ADR-0008's dual-audience design). v1 uses the tenant-scoped +# authority; the multi-tenant switch lands when External ID activation +# is needed. +# +# ENTRA_CLIENT_SECRET is the high-value secret of this set. Never +# commit a real value. Production manages it via the deploy platform's +# secret manager (future infrastructure ADR). +ENTRA_INSTANCE_URL=https://login.microsoftonline.com/ +ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000 +ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000 +ENTRA_CLIENT_SECRET=replace_with_real_value +# Redirect URIs registered in Entra alongside the same client id. Both +# `/auth/callback` and `/auth/logout` paths are mounted by the BFF +# once the OIDC routes land in a subsequent PR. +ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback +ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/ + # Future env vars introduced by upcoming phases / ADRs: # -# Auth flow (ADR-0009): -# ENTRA_TENANT_ID -# ENTRA_CLIENT_ID -# ENTRA_CLIENT_SECRET (or ENTRA_CLIENT_CERT_PATH) -# ENTRA_ACCEPTED_TENANT_IDS -# ENTRA_REDIRECT_URI -# ENTRA_POST_LOGOUT_REDIRECT_URI -# SESSION_SECRET +# Auth flow (ADR-0009) — additional keys wired as the routes land: +# ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET) +# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in +# in the multi-tenant phase — empty means +# "only ENTRA_TENANT_ID is accepted") +# SESSION_SECRET (cookie signing key, see Sessions below) # # Sessions (ADR-0010): # REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME) diff --git a/apps/portal-bff/src/app/app.module.ts b/apps/portal-bff/src/app/app.module.ts index c5e86fe..3d5a440 100644 --- a/apps/portal-bff/src/app/app.module.ts +++ b/apps/portal-bff/src/app/app.module.ts @@ -5,12 +5,14 @@ import { AppService } from './app.service'; import { ObservabilityModule } from '../observability/observability.module'; import { HealthModule } from '../health/health.module'; import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ ObservabilityModule, PrismaModule.forRoot({ isGlobal: true }), AuditModule, + AuthModule, HealthModule, ], controllers: [AppController], diff --git a/apps/portal-bff/src/auth/auth.module.spec.ts b/apps/portal-bff/src/auth/auth.module.spec.ts new file mode 100644 index 0000000..ae96ca3 --- /dev/null +++ b/apps/portal-bff/src/auth/auth.module.spec.ts @@ -0,0 +1,48 @@ +import { Test } from '@nestjs/testing'; +import { AuthModule } from './auth.module'; +import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; + +const VALID = { + ENTRA_INSTANCE_URL: 'https://login.microsoftonline.com/', + ENTRA_TENANT_ID: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + ENTRA_CLIENT_ID: '11111111-2222-3333-4444-555555555555', + ENTRA_CLIENT_SECRET: 's3cret-value-from-entra', + ENTRA_REDIRECT_URI: 'http://localhost:3000/api/auth/callback', + ENTRA_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4200/', +}; + +describe('AuthModule', () => { + const originalEnv: Record = {}; + + beforeEach(() => { + for (const key of Object.keys(VALID) as Array) { + originalEnv[key] = process.env[key]; + process.env[key] = VALID[key]; + } + }); + + afterEach(() => { + for (const key of Object.keys(VALID) as Array) { + const saved = originalEnv[key]; + if (saved === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved; + } + } + }); + + it('provides EntraConfig via the ENTRA_CONFIG token', async () => { + const ref = await Test.createTestingModule({ imports: [AuthModule] }).compile(); + const config = ref.get(ENTRA_CONFIG); + expect(config.clientId).toBe(VALID.ENTRA_CLIENT_ID); + expect(config.authority).toBe(`${VALID.ENTRA_INSTANCE_URL}${VALID.ENTRA_TENANT_ID}`); + }); + + it('fails to compile when an env var is missing', async () => { + delete process.env['ENTRA_CLIENT_SECRET']; + await expect(Test.createTestingModule({ imports: [AuthModule] }).compile()).rejects.toThrow( + /ENTRA_CLIENT_SECRET/, + ); + }); +}); diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts new file mode 100644 index 0000000..c88e7d9 --- /dev/null +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { assertEntraConfig } from '../config/check-entra-config'; +import { ENTRA_CONFIG } from './entra-config.token'; + +/** + * Auth module — owns the Entra ID configuration and (in subsequent + * PRs) the MSAL Node confidential client, the OIDC routes, the + * session integration, and the route guards. Per ADR-0009. + * + * v1 of this module exposes a single provider: the parsed Entra + * config, available via the `ENTRA_CONFIG` injection token. The + * factory delegates to `assertEntraConfig()` — the same validator + * `main.ts` calls at boot — so the typed config object that + * consumers receive is guaranteed to be complete and well-formed. + * The duplicate call is intentional: boot-time fails fast, the + * factory parses again for the DI result. Both reads are + * idempotent and trivially cheap. + */ +@Module({ + providers: [ + { + provide: ENTRA_CONFIG, + useFactory: () => assertEntraConfig(), + }, + ], + exports: [ENTRA_CONFIG], +}) +export class AuthModule {} diff --git a/apps/portal-bff/src/auth/entra-config.token.ts b/apps/portal-bff/src/auth/entra-config.token.ts new file mode 100644 index 0000000..ce46b2a --- /dev/null +++ b/apps/portal-bff/src/auth/entra-config.token.ts @@ -0,0 +1,15 @@ +import type { EntraConfig } from '../config/check-entra-config'; + +/** + * DI token used to inject the parsed Entra configuration. + * + * Usage: + * @Inject(ENTRA_CONFIG) private readonly entra: EntraConfig + * + * The provider lives in `AuthModule` — modules that need the config + * import `AuthModule` (not a global module on purpose; "I depend on + * auth" is worth stating in code). + */ +export const ENTRA_CONFIG = 'ENTRA_CONFIG'; + +export type { EntraConfig }; diff --git a/apps/portal-bff/src/config/check-entra-config.spec.ts b/apps/portal-bff/src/config/check-entra-config.spec.ts new file mode 100644 index 0000000..01047e2 --- /dev/null +++ b/apps/portal-bff/src/config/check-entra-config.spec.ts @@ -0,0 +1,84 @@ +import { assertEntraConfig } from './check-entra-config'; + +const VALID = { + ENTRA_INSTANCE_URL: 'https://login.microsoftonline.com/', + ENTRA_TENANT_ID: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + ENTRA_CLIENT_ID: '11111111-2222-3333-4444-555555555555', + ENTRA_CLIENT_SECRET: 's3cret-value-from-entra', + ENTRA_REDIRECT_URI: 'http://localhost:3000/api/auth/callback', + ENTRA_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4200/', +}; + +describe('assertEntraConfig', () => { + const originalEnv: Record = {}; + + beforeEach(() => { + for (const key of Object.keys(VALID) as Array) { + originalEnv[key] = process.env[key]; + process.env[key] = VALID[key]; + } + }); + + afterEach(() => { + for (const key of Object.keys(VALID) as Array) { + const saved = originalEnv[key]; + if (saved === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved; + } + } + }); + + it('returns the parsed config when every key is present and well-formed', () => { + const config = assertEntraConfig(); + expect(config.instanceUrl).toBe(VALID.ENTRA_INSTANCE_URL); + expect(config.tenantId).toBe(VALID.ENTRA_TENANT_ID); + expect(config.clientId).toBe(VALID.ENTRA_CLIENT_ID); + expect(config.clientSecret).toBe(VALID.ENTRA_CLIENT_SECRET); + expect(config.redirectUri).toBe(VALID.ENTRA_REDIRECT_URI); + expect(config.postLogoutRedirectUri).toBe(VALID.ENTRA_POST_LOGOUT_REDIRECT_URI); + expect(config.authority).toBe(`${VALID.ENTRA_INSTANCE_URL}${VALID.ENTRA_TENANT_ID}`); + }); + + it('throws listing every missing required key', () => { + delete process.env['ENTRA_CLIENT_ID']; + delete process.env['ENTRA_CLIENT_SECRET']; + expect(() => assertEntraConfig()).toThrow(/ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET/); + }); + + it('throws when ENTRA_INSTANCE_URL is not https', () => { + process.env['ENTRA_INSTANCE_URL'] = 'http://login.microsoftonline.com/'; + expect(() => assertEntraConfig()).toThrow(/ENTRA_INSTANCE_URL must use one of \[https:\]/); + }); + + it('throws when ENTRA_INSTANCE_URL is missing the trailing slash', () => { + process.env['ENTRA_INSTANCE_URL'] = 'https://login.microsoftonline.com'; + expect(() => assertEntraConfig()).toThrow(/must end with a trailing "\/"/); + }); + + it('throws when ENTRA_TENANT_ID is not a UUID', () => { + process.env['ENTRA_TENANT_ID'] = 'not-a-uuid'; + expect(() => assertEntraConfig()).toThrow(/ENTRA_TENANT_ID must be a UUID/); + }); + + it('throws when ENTRA_CLIENT_ID is still the .env.example placeholder', () => { + process.env['ENTRA_CLIENT_ID'] = '00000000-0000-0000-0000-000000000000'; + expect(() => assertEntraConfig()).toThrow(/placeholder/); + }); + + it('throws when ENTRA_CLIENT_SECRET is still the .env.example placeholder', () => { + process.env['ENTRA_CLIENT_SECRET'] = 'replace_with_real_value'; + expect(() => assertEntraConfig()).toThrow(/placeholder/); + }); + + it('accepts http for the redirect URIs (local dev case)', () => { + process.env['ENTRA_REDIRECT_URI'] = 'http://localhost:3000/api/auth/callback'; + expect(() => assertEntraConfig()).not.toThrow(); + }); + + it('rejects a redirect URI that is not a valid URL', () => { + process.env['ENTRA_REDIRECT_URI'] = 'not-a-url'; + expect(() => assertEntraConfig()).toThrow(/ENTRA_REDIRECT_URI is not a valid URL/); + }); +}); diff --git a/apps/portal-bff/src/config/check-entra-config.ts b/apps/portal-bff/src/config/check-entra-config.ts new file mode 100644 index 0000000..7992374 --- /dev/null +++ b/apps/portal-bff/src/config/check-entra-config.ts @@ -0,0 +1,131 @@ +/** + * 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 — `/auth/callback`. */ + readonly redirectUri: string; + /** Where Entra sends the user after RP-initiated logout. */ + readonly postLogoutRedirectUri: 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', +] 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; + + 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:']); + + return { + instanceUrl, + tenantId, + clientId, + clientSecret, + redirectUri, + postLogoutRedirectUri, + 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.`, + ); + } +} diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index d79396c..bf505f7 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -8,12 +8,18 @@ import { NestFactory } from '@nestjs/core'; import { Logger } from 'nestjs-pino'; import { AppModule } from './app/app.module'; import { assertDatabaseUrl } from './config/check-database-url'; +import { assertEntraConfig } from './config/check-entra-config'; // Fail fast on a malformed DATABASE_URL (most often a special char in // the password that needs URL-encoding) rather than letting Prisma // surface a cryptic "invalid connection string" error mid-request. assertDatabaseUrl(); +// Same family of pre-flight check for the Entra app-registration env +// vars (per ADR-0009). Missing / placeholder values fail here rather +// than deep inside the first auth request. +assertEntraConfig(); + async function bootstrap() { // `bufferLogs: true` holds early-bootstrap log lines until the // Pino-based Logger is wired in below, so we don't lose anything