feat(portal-bff): entra config foundation — boot validator + auth module
CI / scan (pull_request) Successful in 2m21s
CI / commits (pull_request) Successful in 2m31s
CI / check (pull_request) Successful in 2m37s
CI / a11y (pull_request) Successful in 2m7s
CI / perf (pull_request) Successful in 4m54s

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).
This commit is contained in:
Julien Gautier
2026-05-12 02:22:52 +02:00
parent cb69a692e7
commit 7992a5ba14
8 changed files with 348 additions and 8 deletions
+34 -8
View File
@@ -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)
+2
View File
@@ -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],
@@ -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<string, string | undefined> = {};
beforeEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
originalEnv[key] = process.env[key];
process.env[key] = VALID[key];
}
});
afterEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
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<EntraConfig>(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/,
);
});
});
+28
View File
@@ -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 {}
@@ -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 };
@@ -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<string, string | undefined> = {};
beforeEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
originalEnv[key] = process.env[key];
process.env[key] = VALID[key];
}
});
afterEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
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/);
});
});
@@ -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}<tenant-id>) 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.`,
);
}
}
+6
View File
@@ -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