feat(portal-bff): entra config foundation — boot validator + auth module (#102)
CI / check (push) Successful in 2m15s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m1s
CI / a11y (push) Successful in 1m15s
CI / perf (push) Successful in 3m47s

## Summary

First step of ADR-0009 wiring on the BFF: capture the Entra app-registration env vars in the boot pipeline so subsequent PRs can plug `@azure/msal-node` 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`](apps/portal-bff/.env.example)** promotes the Entra block from its previous "future-vars" comment stub to an active section. Six keys:
  - `ENTRA_INSTANCE_URL` — the Microsoft login endpoint (e.g. `https://login.microsoftonline.com/`).
  - `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` — the values from the Entra app-registration UI.
  - `ENTRA_REDIRECT_URI`, `ENTRA_POST_LOGOUT_REDIRECT_URI` — consumed by the OIDC routes in a follow-up PR.

  Multi-tenant `ENTRA_ACCEPTED_TENANT_IDS` stays in the future-vars comment until External ID activation (ADR-0008 phase 2).

- **[`apps/portal-bff/src/config/check-entra-config.ts`](apps/portal-bff/src/config/check-entra-config.ts)** — boot-time validator mirroring `check-database-url.ts`. Verifies every required key is present, the instance URL is `https://` and ends with `/`, tenant + client IDs are UUIDs, none of them are the literal placeholder values from `.env.example`, and the two redirect URIs parse as URLs. Returns a typed `EntraConfig` object with a pre-computed `authority` field (`${instanceUrl}${tenantId}`) so the future MSAL factory does not re-derive it.

- **[`auth.module.ts`](apps/portal-bff/src/auth/auth.module.ts)** — `AuthModule` whose v1 surface is one provider: the parsed `EntraConfig` keyed by the `ENTRA_CONFIG` injection token. Factory delegates to `assertEntraConfig()`. Non-global on purpose — consumers state intent by importing the module.

- **Bootstrap wiring** — `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`.

## Naming choice

Chose `ENTRA_*` rather than `AZURE_AD_*` to align with the 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).

## Decisions worth flagging

- **Validator called twice** — once in `main.ts` (boot-time fail-fast) and once in the `AuthModule` factory (to obtain the value for DI). Both reads are idempotent and trivially cheap. The duplication is intentional: boot-time gives a clear, pre-NestFactory error; the factory call surfaces the typed value to consumers.
- **No `@azure/msal-node` dependency added yet** — introducing the dep without a consumer would be a smell. Lands in the next PR alongside the MSAL client factory.
- **Pre-computed `authority`** in the parsed config rather than letting each MSAL consumer concatenate `instanceUrl + tenantId`. One place to change if the multi-tenant authority (`/organizations`, `/common`) replaces the tenant-scoped one when External ID activates.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green.
- **29 / 29 specs** (was 20; +9 from the new entra-config spec + auth.module spec).
- Boot smoke test (manual): with the placeholder values in `.env.example`, `nx serve portal-bff` aborts immediately with `ENTRA_CLIENT_ID is still the .env.example placeholder (…)`. With real values in a local `.env`, the BFF starts normally.

## Test plan

- [x] Lint + test + build green.
- [x] Validator unit-test covers happy path + every documented failure mode.
- [ ] Manual: drop the real Entra values you obtained into `apps/portal-bff/.env`, `nx serve portal-bff` boots clean.
- [ ] Manual: temporarily blank out one of the four `ENTRA_*` keys → BFF aborts at boot with a clear message naming the missing key.

## Next PRs on the auth track

1. Install `@azure/msal-node`, add the `MsalConfidentialClient` factory provider in `AuthModule`, expose it via DI.
2. First OIDC routes: `/api/auth/login` (PKCE-initiated redirect to Entra) + `/api/auth/callback` (token exchange + ID-token validation, audit-logged, no session persistence yet).
3. Session persistence per ADR-0010 (Redis + AES-GCM, `__Host-portal_session` cookie). Closes the auth loop.
4. RP-initiated logout, CSRF protection, route guards.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #102
This commit was merged in pull request #102.
This commit is contained in:
2026-05-12 02:27:55 +02:00
parent cb69a692e7
commit 58e3b65bd9
8 changed files with 348 additions and 8 deletions
@@ -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/);
});
});