Commit Graph

13 Commits

Author SHA1 Message Date
julien f9f0151717 feat(portal-bff): extract Entra roles claim onto AuthenticatedUser (#126)
CI / check (push) Successful in 2m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m14s
CI / a11y (push) Successful in 1m8s
CI / perf (push) Successful in 3m59s
## Summary

First step in the `portal-admin` audit-log-viewer workstream (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)). The BFF's `AdminRoleGuard` (next PR) needs to read `session.user.roles` to enforce admin-only access to `/api/admin/*`. Today the session carries `{ oid, tid, username, displayName, amr }` — the `roles` claim is dropped on the floor when the ID token comes back from Entra.

This PR closes that gap:

- Adds `roles: readonly string[]` to [AuthenticatedUser](apps/portal-bff/src/auth/auth.service.ts) and threads it through `toAuthenticatedUser()`.
- The field flows onto `req.session.user` automatically via the existing module-augmentation chain in [session.types.ts](apps/portal-bff/src/session/session.types.ts) — no extra wiring.

## Defensive parsing

Mirrors the existing `amr` extraction pattern:

| Input claim shape | Result |
| --- | --- |
| `["admin", "editor"]` | `["admin", "editor"]` |
| Claim absent | `[]` |
| Non-array (e.g. `"admin"`) | `[]` |
| Mixed types (e.g. `["admin", 42, null, "editor"]`) | `["admin", "editor"]` |

Empty array means **"user has no app role assigned"**, not **"claim was unparseable"** — both collapse to the same value because both are equally non-authoritative for the admin guard.

## Why this is its own PR

The `AdminRoleGuard` + `@RequireAdmin()` decorator + first `/api/admin/me` self-test endpoint will follow in the next PR. Splitting the claim extraction out makes both diffs trivial to read and lets the second PR focus on guard semantics + audit emission without the mechanical fixture updates that came with adding a new `AuthenticatedUser` field.

## Surface impact — none yet

- `PublicUser` (the SPA-facing shape returned by `GET /api/auth/me`) is **deliberately unchanged**. Exposing `roles` to the SPA happens in the next PR alongside the conditional admin-link rendering — without a consumer in this PR it would be dead code.
- Audit pipeline unchanged. `SignInActor` carries `{ oid, amr }` only; the audit log doesn't need `roles` and won't get it.
- No new env vars, no new dependencies.

## Test plan

- [x] `pnpm nx test portal-bff` — **203 specs pass** (was 199; +4 new specs covering the four parsing cases above).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated).
- [x] Existing fixtures in [auth.controller.spec.ts](apps/portal-bff/src/auth/auth.controller.spec.ts), [auth.service.spec.ts](apps/portal-bff/src/auth/auth.service.spec.ts), [absolute-timeout.middleware.spec.ts](apps/portal-bff/src/session/absolute-timeout.middleware.spec.ts) updated with `roles: []`.
- [ ] e2e — would require the `admin` app role to be declared on the Entra registration and assigned to a test user. Out of scope for this PR; will be validated when the `AdminRoleGuard` lands and there is a 403 to observe.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #126
2026-05-14 00:30:37 +02:00
julien 0e6c114ba7 feat(portal-bff): rate limiting + structured error filter (#123)
CI / scan (push) Successful in 1m42s
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m59s
CI / a11y (push) Successful in 55s
CI / perf (push) Successful in 2m43s
## Summary

Closes the phase-2 hardening list that `main.ts` has been advertising since the security PR (#122). Two new middlewares + one alignment pass on the response shape so every BFF error follows a single contract.

### Structured error filter

A global `ExceptionFilter` (registered via `app.useGlobalFilters(...)` at the top of `bootstrap()`) normalises every 4xx/5xx response to a single envelope :

```json
{
  "error": {
    "code": "csrf",
    "message": "CSRF token missing or invalid",
    "traceId": "abc123…"
  }
}
```

- `code` — stable token the SPA can `switch` on. Either explicit on the `HttpException`'s response object (`new UnauthorizedException({ code: 'unauthenticated', message: '...' })`) or derived from the status (`STATUS_CODE_MAP` for the common cases, `'http_error'` fallback). 500s always use `'internal'`.
- `message` — safe human-readable text. **500s never leak the underlying exception** (the full message + stack go to the Pino `error` log line as `err: exception` — Pino's stack-serialiser does the rest).
- `traceId` — current OTel trace id (or `null` when no span is active). Makes cross-correlation with the audit log + Pino lines trivial.

An exported `errorResponse(code, message)` helper produces the same envelope for code paths that write the response directly (raw Express middlewares like the CSRF one, the rate-limit handler) — single contract everywhere.

### Rate limiting

`express-rate-limit` mounted after the session middleware:

- **Dynamic max per request**: 10/min on `/api/auth/login` + `/api/auth/callback` (`RATE_LIMIT_AUTH_PER_MINUTE` env), 120/min everywhere else (`RATE_LIMIT_PER_MINUTE`).
- **Bucket key** = session id when the request carries an active session, remote IP otherwise. A single attacker can't dodge the limit by rotating sessions; an authenticated user gets per-account fairness regardless of source IP.
- **`/api/health` is skipped** so orchestrator polls don't burn the user quota.
- 429 response uses the same envelope as everything else (`{ error: { code: 'rate_limited', … } }`) via the shared `errorResponse()` helper.
- In-memory store (single-instance v1 per ADR-0015). Redis-backed store is a one-line config change when we scale out.

### Alignment pass

- **CSRF middleware** previously returned `{ error: 'csrf' }`. Now returns the full envelope via `errorResponse('csrf', 'CSRF token missing or invalid')`.
- **`/auth/me` 401** previously wrote `{ error: 'unauthenticated' }` directly. Now throws `UnauthorizedException({ code: 'unauthenticated', message: 'Unauthenticated' })` so the filter formats it. Identical response shape on the wire as the CSRF path.

Both spec assertions updated to the new shape.

### Type-resolution fix (transitive)

`@types/express@4.17.25` was being pulled in transitively by `http-proxy-middleware` (Nx's webpack-dev-server). `express-rate-limit`'s `.d.ts` files import `'express'` and the type resolver was matching the v4 copy, causing `Request` type mismatches with our v5-based code. Added `"@types/express": "^5.0.6"` to `pnpm.overrides` so the workspace pins a single version everywhere.

## Notable choices

**`StructuredErrorFilter` is the source of truth, but raw middlewares are still allowed to write responses directly** (rate-limit, CSRF). The reason: Nest's filter chain only handles exceptions thrown from controllers/guards/interceptors. Express middleware short-circuits before that. Both paths now use the same envelope shape through the `errorResponse()` helper.

**No `traceId` in non-5xx responses?** It IS included. The filter writes it on every status — useful for any client-server debugging conversation ("send me your traceId from the 403 you got").

**500s strip the exception message.** Even if a developer accidentally surfaces a sensitive detail via `throw new Error('connection to postgres://user:secret@host failed')`, the response body just says "Internal server error". The full message goes to the log — visible to ops, never to clients. This is the standard secure-by-default for unhandled errors.

**Dynamic `max` per request, not two separate `rateLimit()` instances.** Two instances would each maintain a separate store, so the `/auth/login` bucket would be independent of the general one for the same IP. A single instance with a path-conditional max gives consistent bucket accounting.

## Out of scope

- Redis-backed rate-limit store. v1 ships in-memory; the BFF runs as a single instance. The migration is `new RedisStore({ ... })` when we scale out (ADR-0015 mentions this).
- Per-user override of `RATE_LIMIT_PER_MINUTE` (e.g. admins / service accounts with higher quotas). No code path for this in v1.
- CSP fine-tuning for portal-shell + portal-admin once Caddy serves them.

## Test plan

- [x] `pnpm nx test portal-bff` (clean env) → **199/199 pass** (+25 specs: StructuredErrorFilter, rate-limit middleware, CSRF + /me alignments).
- [x] `pnpm nx test feature-auth` (clean env) → **28/28 pass**.
- [x] `pnpm nx test portal-shell` (clean env) → **34/34 pass**.
- [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean.
- [x] Prettier-clean.
- [x] CI clean-env repro: every env var unset (including new `RATE_LIMIT_*`) → 261/261 pass.
- [ ] Manual smoke against running BFF:
  - [ ] Throw any error from a controller → response is `{ error: { code, message, traceId } }`. Pino log has the full exception under `err`.
  - [ ] Curl `/api/auth/me` without a session cookie → 401 + same envelope, `code: 'unauthenticated'`.
  - [ ] Hit `/api/auth/login` 11 times in a minute → 11th returns 429 + `code: 'rate_limited'`. `/api/health` hit 100 times → all 200.
  - [ ] POST without `X-CSRF-Token` → 403 + `code: 'csrf'`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #123
2026-05-13 21:34:33 +02:00
julien 5bbe2304ff feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m10s
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 4m16s
## Summary

Phase-2 security baseline that the `main.ts` placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together.

### Helmet on the BFF

`helmet()` with three overrides matching our specific shape:

- **HSTS only in production** — dev runs on plain HTTP, HSTS is just noise.
- **`crossOriginResourcePolicy: 'cross-origin'`** — the SPA on its own origin reads JSON from the BFF; the default `same-origin` would block it.
- **CSP disabled in non-production** — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy `connect-src` violations in browser devtools that we don't need.

Everything else is Helmet defaults: `X-Frame-Options=SAMEORIGIN`, `X-Content-Type-Options=nosniff`, `Referrer-Policy=no-referrer`, `X-Powered-By` removed, etc.

### CORS allowlist, env-driven

`CORS_ALLOWED_ORIGINS` env (comma-separated) is now **mandatory** at boot. The BFF refuses to start without it via `readCorsAllowlist()` — same boot-time validator family as `assertSessionSecret` etc. The previous hardcoded `http://localhost:4200` fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. `X-CSRF-Token` is now in the allowed headers.

### Double-submit CSRF

- BFF mints a 256-bit `csrfToken` at session creation (`/auth/callback`), stored on `req.session.csrfToken` and mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). The cookie is the SPA's read-only view; the server-side session is the source of truth.
- `createCsrfMiddleware` (mounted after the session middleware in `main.ts`) compares the `X-CSRF-Token` header with `req.session.csrfToken` using `crypto.timingSafeEqual`. Skips:
  - safe methods (`GET / HEAD / OPTIONS`),
  - anonymous requests (no `req.session.user`),
  - `/api/auth/login` and `/api/auth/callback` (those mint the token themselves).
- Mismatch → `403 {"error":"csrf"}` with a structured Pino warn.
- SPA's `csrfInterceptor` reads the cookie via `document.cookie` and copies its value into `X-CSRF-Token` on every mutating BFF request. The header is omitted on `GET / HEAD / OPTIONS` (BFF skips them anyway) and on non-BFF origins.
- Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie.

## Notable choices

**Session-bound double-submit, not pure cookie-vs-header.** A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place.

**No CSRF for anonymous mutating routes (v1).** None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with `saveUninitialized: false` on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship.

**`SameSite=Lax`, not `Strict`, on the CSRF cookie.** Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; `SameSite=Lax` is a belt-and-braces layer.

**`csrfInterceptor` runs after `bffCredentialsInterceptor` and before `bffUnauthorizedInterceptor` in the chain.** Order: credentials first (set `withCredentials`), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises.

**`CORS_ALLOWED_ORIGINS` has no localhost fallback.** I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit.

## Out of scope (next PRs)

- Rate limiting + structured error filter (still in the phase-2 to-do).
- CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving).
- CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations).

## Test plan

- [x] `pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell` clean env → **177 + 28 + 34 = 239/239 pass** (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage).
- [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean.
- [x] **CI clean-env repro** (lesson from prior PRs): every env var unset (including new `CORS_ALLOWED_ORIGINS`) → tests still pass. The BFF refuses to boot without `CORS_ALLOWED_ORIGINS`, which is the intended behaviour.
- [x] Prettier-clean.
- [ ] Manual smoke against running BFF:
  - [ ] Sign in → `__Host-portal_csrf` (prod) / `portal_csrf` (dev) cookie set, value matches `audit.events.payload->>actorIdHash`-style traceability via `req.session.csrfToken` in Redis.
  - [ ] Hit a future POST route from the SPA → request carries `X-CSRF-Token`, BFF accepts.
  - [ ] Forge a POST without the header (curl) → 403 `{"error":"csrf"}`.
  - [ ] Sign out → both cookies cleared.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #122
2026-05-13 20:50:44 +02:00
julien a97be121e6 fix(portal-bff): audit writes use raw INSERT (audit_writer has no SELECT for RETURNING) (#121)
CI / check (push) Successful in 2m16s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m29s
CI / a11y (push) Successful in 1m20s
CI / perf (push) Successful in 3m30s
## Summary

#120 shipped the audit pipeline but the end-to-end path was never smoke-tested against a running Postgres. First click on `/auth/logout` returned 500 with the Pino log:

```
PostgresError code 42501 — permission denied for table events
```

Despite:

- ACL on `audit.events` showing `audit_writer=a/audit_owner` (INSERT granted).
- `has_table_privilege('audit_writer', 'audit.events', 'INSERT')` returning `t`.
- `has_schema_privilege` / `has_type_privilege` all `t`.
- A direct psql `INSERT INTO audit.events ...` after `SET LOCAL ROLE audit_writer` **succeeding**.
- A psql `INSERT ... RETURNING id` after the same `SET LOCAL ROLE` **failing** with the exact same error.

Root cause: Prisma's ORM `tx.auditEvent.create(...)` issues `INSERT ... RETURNING *` to hydrate the returned entity. Postgres requires **SELECT** on every column listed in `RETURNING`. `audit_writer` has INSERT only by ADR-0013 design — RETURNING fails with `code 42501` and the error message reads "permission denied for table events" (no mention of SELECT or RETURNING, which is what made it deeply non-obvious to diagnose).

## Fix

`AuditWriter.recordEvent` now issues a parameterised raw INSERT via `tx.$executeRawUnsafe` instead of the ORM `create()`:

```ts
await tx.$executeRawUnsafe(
  `INSERT INTO "audit"."events"
     (id, event_type, audience, outcome, subject, actor_id_hash, trace_id, payload)
   VALUES (gen_random_uuid(), $1, $2::"audit"."AuditAudience", $3::"audit"."AuditOutcome",
           $4, $5, $6, $7::jsonb)`,
  input.eventType, input.audience, input.outcome,
  input.subject ?? null, actorIdHash, traceId, payloadJson,
);
```

The role contract per ADR-0013 stays strict: `audit_writer` keeps INSERT only, no SELECT/UPDATE/DELETE/TRUNCATE. The other natural fix (`GRANT SELECT` to `audit_writer`) would have weakened the writer/reader role separation, so we deliberately went the other way.

## Notable choices

**`gen_random_uuid()` server-side instead of Prisma's `@default(uuid())` client-side.** The model still declares `@default(uuid())` for any future ORM read or `audit_reader`-side query, but the write path uses the built-in Postgres function. No extension required (Postgres 13+).

**Explicit enum and jsonb casts.** Parameters travel as TEXT over the wire; the SQL casts (`$2::"audit"."AuditAudience"`, `$7::jsonb`) ensure Postgres parses them as the right type. Without the casts, the type system rejects the INSERT before privilege check even fires.

**Parameterised, not interpolated.** `$executeRawUnsafe` accepts a SQL template with `$1, $2, …` placeholders and a vararg of values — same wire-level parameter binding as a prepared statement, so SQL injection isn't possible even on caller-controlled inputs like `eventType`. The spec pins this with a malicious-input test.

**Also fixes an env-sensitivity bug in `auth.controller.spec.ts`.** The test that asserts `session.absoluteExpiresAt == createdAt + 43200000` was reading the default via `readSessionTimeouts()` but didn't override `SESSION_ABSOLUTE_TIMEOUT_SECONDS`. If `apps/portal-bff/.env` has a custom value (as it did during the manual audit debugging), the test failed non-deterministically. Now the test deletes the env var before running and restores it after — same pattern as the other env-touching tests in this file.

## ADR amendment

[ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) §"Writer" now carries an **Implementation trap** callout explaining why Prisma's ORM `create()` cannot be used for audit writes (RETURNING requires SELECT, audit_writer has INSERT only). The corresponding Confirmation entry cross-references the callout. Two-commit shape on this PR (code + docs) — the squash-merge will fold them.

## Test plan

- [x] `pnpm nx test portal-bff` (clean env) → **144/144 pass**.
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean.
- [x] Prettier-clean.
- [ ] Manual smoke against running BFF + Postgres:
  - [ ] Sign in → row in `audit.events` with `event_type = 'auth.sign_in'`.
  - [ ] Sign out → row with `event_type = 'auth.sign_out'`. **The 500 from before is gone.**
  - [ ] Verify the role contract is still strict :
    ```sql
    SET ROLE audit_writer;
    SELECT * FROM audit.events LIMIT 1;  -- should fail "permission denied"
    UPDATE audit.events SET event_type = 'x';  -- should fail
    DELETE FROM audit.events;  -- should fail
    ```

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #121
2026-05-13 19:48:32 +02:00
julien 940267e317 feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle (#120)
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m50s
CI / a11y (push) Successful in 1m56s
CI / perf (push) Successful in 3m29s
## Summary

Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma `AuditEvent` model, Postgres roles + grants, `AuditWriter.recordEvent` with `SET LOCAL ROLE audit_writer`); this PR layers a typed event surface and emits the first four events on real code paths.

### What lands

- **Typed methods on `AuditWriter`**: `signIn`, `signInFailed`, `signOut`, `sessionExpired`. Callers pass the raw Entra `oid`; hashing happens inside the writer so the salt never leaves the audit module. ADR-0013 explicitly defers adding these typed methods "as the matching feature ships" — auth has shipped, so we add the four events tied to code paths that exist today.
- **`HashUserIdService`** — reads `LOG_USER_ID_SALT` once at injection, exposes `hash(userId)` → 16-hex-char digest used by both `audit_events.actor_id_hash` (ADR-0013) and the future Pino `user_id_hash` (ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams.
- **`LOG_USER_ID_SALT` env var** promoted from the "future vars" block in `.env.example` to the active section, with the same boot-time validator pattern as `SESSION_SECRET` / `SESSION_ENCRYPTION_KEY`: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired in `main.ts`.
- **`AuditModule` is now `@Global()`** and also provides `HashUserIdService`. The previous in-line comment said "imported globally by AppModule" but the decorator was missing — without it, AuthController and the absolute-timeout middleware couldn't inject `AuditWriter` without re-importing AuditModule.
- **Emission points**:
  - `/auth/callback` happy path → `auth.sign_in` after `session.save()` (blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in).
  - `/auth/callback` failure paths → `auth.sign_in.failed` with a discriminator `failureKind` (`entra-error`, `missing-code-or-state`, `no-pre-auth-cookie`, or any of the `AuthCodeFlowError` kinds — `state-mismatch`, `flow-expired`, `token-exchange-failed`).
  - `/auth/logout` (authenticated only) → `auth.sign_out` before `session.destroy()` — once destroy runs we lose the actor id.
  - Absolute-timeout middleware → `auth.session.expired` with `reason: 'absolute'` and `ageMs` for forensic granularity.

### Out of scope (next PRs)

- The other four v1 events from ADR-0013's catalogue (`auth.session.revoked`, `auth.token.validation.failed`, `auth.mfa.assertion.failed`, `authz.deny`) — no triggering code path exists today. They land with the admin "logout everywhere" route, downstream API access (ADR-0014), and the eventual `@RequireMfa()` / `@RequireAdmin` guards.
- Idle-timeout expiry is intentionally silent — Redis lets the key disappear with no BFF observation point. Per ADR-0010.
- Separate `AUDIT_DATABASE_URL` connection pool with `audit_writer`-only credentials — ADR-0013 marks it as the production hardening step, deferred behind `SET LOCAL ROLE` in v1.
- Retention purge job + startup self-test probe — deferred to the on-prem infrastructure ADR per ADR-0013.

### Notable choices

- **No CLS-populating middleware.** ADR-0013 anticipates an interceptor that puts `actorIdHash` on the request CLS so `AuditWriter.recordEvent` can pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we pass `actorIdHash` explicitly via the typed methods and skip the middleware. It can land later when more routes need it.
- **Blocking on the happy path = strict ADR posture.** `audit.signIn` is awaited before the 302; a Postgres outage makes the sign-in fail (5xx) rather than silently producing an un-audited session. That's "no audit ⇒ no action" applied to authentication itself. Matches ADR-0013 §"Blocking writes" verbatim.
- **`signInFailed` skips the actor hash by default.** Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optional `actor` for the rare identity-after-rejection case (future MFA assertion failure, etc.).

### Test plan

- [x] `pnpm nx test portal-bff` (clean env) → **142/142 pass** (was 123; +19 new specs across `check-log-user-id-salt`, `hash-user-id.service`, `audit.service` typed-methods, `auth.controller`, `absolute-timeout.middleware`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean.
- [x] **CI clean-env repro** (lesson from #115/#116/#117): every env var unset → tests still 142/142. The two module specs that previously sat on the boundary (`auth.module`, `session.module`) now bootstrap their own `@Global()` stub providers for `PrismaService` + `ClsService` so AuditWriter's transitive resolution works without booting Prisma for real.
- [ ] Manual smoke against running BFF + Postgres:
  - [ ] Sign in → `select * from audit.events where event_type = 'auth.sign_in'` returns one row with `actor_id_hash`, `subject = 'session:…'`, `payload.amr` populated.
  - [ ] Sign out → matching `auth.sign_out` row.
  - [ ] Force `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` + wait → `auth.session.expired` row with `payload.reason = 'absolute'` and `ageMs > 5000`.
  - [ ] Manual `UPDATE audit.events SET event_type = 'x' WHERE id = ...` as the BFF role → fails with "permission denied" (the role contract holds even when the migrator runs as a privileged login).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #120
2026-05-13 14:21:42 +02:00
julien c427e5d4fe fix(portal-bff): set REDIS_URL + SESSION_* in auth.module.spec so ci:check passes on a clean runner (#116)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m23s
CI / check (push) Successful in 1m33s
CI / a11y (push) Successful in 45s
CI / perf (push) Successful in 2m58s
## Summary

CI red on `main` after #115. Failure was masked locally because `nx test` auto-loads `apps/portal-bff/.env` — the CI runner has no such file, so `process.env.REDIS_URL` is genuinely unset there and the test sees the real failure path.

Root cause: #115 made `AuthModule` import `SessionModule` so `AuthController` could inject `UserSessionIndexService`. `SessionModule` pulls in `RedisModule`, whose factory calls `assertRedisConfig()` and refuses to compile without `REDIS_URL`. The existing `auth.module.spec.ts` only set the `ENTRA_*` env vars — so as soon as the spec's `compile()` walks the new import graph, `assertRedisConfig` throws.

Fix is one file: add `REDIS_URL`, `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY` to the spec's `VALID` env block and dispose the `ioredis` client in `afterEach` (the spec now compiles a full SessionModule, which opens a connection at module init). Same pattern as `session.module.spec.ts`.

## Verification

The reason the bug didn't surface locally was Nx's `.env` loading. To repro the CI condition locally:

```
env -u REDIS_URL -u SESSION_SECRET -u SESSION_ENCRYPTION_KEY -u DATABASE_URL \
    -u ENTRA_INSTANCE_URL -u ENTRA_TENANT_ID -u ENTRA_CLIENT_ID \
    -u ENTRA_CLIENT_SECRET -u ENTRA_REDIRECT_URI -u ENTRA_POST_LOGOUT_REDIRECT_URI \
    pnpm exec nx test portal-bff --skip-nx-cache
```

Before this PR (on main): `auth.module.spec.ts` fails with `REDIS_URL is not set` at `assertRedisConfig`. After: 123/123 pass under that same clean env.

## Test plan

- [x] `nx test portal-bff` with all BFF env vars `unset` → **123/123 pass** (the CI condition).
- [x] `nx lint portal-bff` → clean.
- [x] `nx build portal-bff` → clean.
- [x] Prettier-clean.
- [ ] CI re-run after merge → `ci:check` green.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #116
2026-05-12 23:58:55 +02:00
julien c3de2340e7 feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010 (#115)
CI / check (push) Failing after 1m28s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m3s
CI / a11y (push) Successful in 58s
CI / perf (push) Successful in 2m53s
## Summary

Hardens the BFF session per ADR-0010 §"TTL policy" and §"Revocation":

- **Absolute-timeout middleware** — every request that survives `express-session` runs through a new middleware that checks `req.session.absoluteExpiresAt`. Past the 12 h hard ceiling, the middleware destroys the Redis-side session, clears the `portal_session` cookie, drops the entry from the per-user index, and lets the request continue anonymously. Route-level guards (`/me`, future `@RequireAuth`) turn that into a 401 where the user actually needs auth — public routes keep serving.
- **`user_sessions:{userId}` secondary index** — a new `UserSessionIndexService` maintains a Redis set of active session ids per user. Hooked into `/auth/callback` (SADD on sign-in) and `/auth/logout` + the absolute-timeout middleware (SREM on destroy). Best-effort: a failed `SADD`/`SREM` logs a warning and the auth flow continues. No in-product consumer in this PR — the admin "logout everywhere" endpoint lands with the admin module.
- **Session payload extension** — `createdAt` and `absoluteExpiresAt` are now set on the session at the same moment as `req.session.user` (in `/auth/callback`). The `session.types.ts` declaration merging exposes them as optional `SessionData` fields.

## Notable choices

**Non-intrusive enforcement on expiry.** ADR-0010 says "returns 401"; we interpret that as "the user eventually sees a 401 when they touch something that needs auth", not "every route returns 401 the moment we notice the ceiling". The middleware destroys the session and calls `next()` — `/me` returns 401 on its own (no user on the session), public routes stay accessible. Validated with the project lead 2026-05-12.

**Express middleware exposed via DI, not a NestJS `MiddlewareConsumer`.** Same pattern as `SESSION_MIDDLEWARE`: factory inside `SessionModule`, resolved from the application context in `main.ts` with `app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE)`. Keeps the wiring co-located with the session middleware and avoids the `AppModule.configure(consumer)` boilerplate for a one-off enforcement layer.

**Best-effort index maintenance.** `UserSessionIndexService.add` / `remove` catch Redis errors and log a Pino warning instead of throwing. Rationale (per ADR-0010): the index is a convenience for admin operations, not a security invariant — a Redis hiccup must not break sign-in / sign-out. Orphans (entries pointing to keys that have expired idle-TTL on their own) are tolerated and will be filtered by future consumer code.

**Per-user index identifier = Entra `oid`.** Stable per-user inside the tenant, matches `req.session.user.oid`. Admin "logout user X" will work against this same key. Future multi-tenant scenarios may want `${tid}:${oid}` — easy refactor when External ID activation lands (ADR-0008).

## Out of scope (next PRs)

- Admin "logout everywhere" endpoint consuming `UserSessionIndexService.list(userId)`. Waits on the admin module + `@RequireAdmin` / `@RequireMfa` guards.
- Audit-pipeline first-class events for `session.absolute_timeout` and `user_session_index.*` (ADR-0013). For now they're structured Pino logs.
- Token blob persistence (id_token / access_token / refresh_token) in the encrypted session — ADR-0014 dependency.

## Test plan

- [x] `pnpm nx test portal-bff` → **123/123 pass** (was 110 before; +13 specs across new `user-session-index.service.spec.ts`, `absolute-timeout.middleware.spec.ts`, and added cases in `auth.controller.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean webpack build.
- [x] Prettier-clean for all touched files.
- [ ] Manual smoke against running BFF:
  - [ ] Sign in normally → Redis has `session:<id>` + `user_sessions:<oid>` SISMEMBER returns `<id>`.
  - [ ] Logout → both keys gone.
  - [ ] Forge a past `absoluteExpiresAt` in Redis (or shorten `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in `.env`) → next request after expiry returns 401 on `/me`, cookie cleared, index entry SREM-ed.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #115
2026-05-12 23:23:14 +02:00
julien 0464ce3ac8 feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout (#112)
CI / check (push) Successful in 2m39s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m33s
CI / a11y (push) Successful in 1m40s
CI / perf (push) Successful in 4m26s
## Summary

Closes the OIDC loop end-to-end on the BFF side:

- `/auth/callback` now writes the resolved `AuthenticatedUser` into `req.session.user` and waits for `req.session.save()` before redirecting, so the SPA reaches the landing page with a populated session.
- `GET /auth/me` returns the curated public view of the session user (`oid`, `tid`, `username`, `displayName`) or `401 {"error": "unauthenticated"}`. `amr` and other internal claims stay server-side.
- `GET /auth/logout` destroys the BFF session (Redis `DEL`), clears the session cookie, and 302s to Entra's `/oauth2/v2.0/logout` so the IdP-side session is killed too — RP-initiated logout per ADR-0009.

Scope intentionally stops here: the absolute-timeout interceptor (12 h hard ceiling) and the `user_sessions:{userId}` secondary index land in dedicated follow-ups.

## Notable choices

**`req.session.save()` is awaited before the redirect.** Express-session writes to its store on response end; emitting the 302 closes the response before `connect-redis` finishes the write, so without an explicit await the browser can race the SPA into requesting `/me` against a missing key. Awaiting `save()` is the documented fix.

**Logout via `GET`.** Matches `/login` (also `GET`) and keeps the UX a plain anchor / top-level navigation. The CSRF surface is mitigated by `SameSite=Lax` on the session cookie — cross-site subresource requests (`<img src>`, `fetch`) don't carry it. A dedicated CSRF middleware lands with phase-2 security; if we want POST-only logout earlier, easy follow-up.

**`/me` strips `amr`.** The session payload mirrors `AuthenticatedUser` (used internally by the future `@RequireMfa()` guard, ADR-0011), but the SPA only ever needs the curated subset. Mapping happens in the controller — no leak by default.

**Logout URL skips `id_token_hint`.** ADR-0009 mentions it for single-account logout UX, but v1 doesn't persist the `id_token` in the session yet (the encrypted `tokens` blob lands with downstream API support per ADR-0014). Without `id_token_hint`, Entra shows an account picker — the conservative default until token persistence ships.

**Cookie name in logout.** Uses `sessionCookieName()` from `session/session-cookie.ts` so logout clears the same cookie the middleware sets — `__Host-portal_session` in prod, `portal_session` in dev.

## Out of scope (next PRs)

- Absolute-timeout interceptor (12 h hard ceiling, ADR-0010).
- `user_sessions:{userId}` secondary index for admin "logout everywhere".
- Persisting the `id_token` / `access_token` / `refresh_token` blob in the encrypted session (ADR-0014 dependency).
- CSRF middleware (phase-2 security).
- Renaming `ENTRA_POST_LOGOUT_REDIRECT_URI` if we want a distinct post-login redirect target — for now both flows land on the same SPA URL.

## Test plan

- [x] `pnpm nx test portal-bff` → **110/110 pass** (was 99 before this PR; +11 specs across `auth.controller.spec.ts` and `auth.service.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → webpack compiled successfully.
- [x] Prettier-clean on all touched files.
- [ ] Manual end-to-end smoke test:
  - [ ] `/api/auth/login` → Entra → back at `/api/auth/callback` → session cookie set, redirect to SPA.
  - [ ] `/api/auth/me` → 200 JSON when authenticated, 401 when anonymous.
  - [ ] `/api/auth/logout` → Redis key gone, cookie cleared, lands at SPA via Entra logout.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #112
2026-05-12 19:46:38 +02:00
julien bfa35d3283 fix(portal-bff): drop strict amr check — flow blocked when Entra omits the claim (#108)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m39s
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 1m21s
CI / perf (push) Successful in 2m45s
## Bug

After a real sign-in against the Entra tenant, the callback rejected the flow with:

```
{"context":"AuthCallback","event":"auth.flow_error","failure":{"kind":"amr-missing"}}
```

The user landed on the SPA with `?auth_error=amr-missing` instead of authenticated. Every dev sign-in is blocked.

## Root cause

PR #107's `amr-missing` guard misread ADR-0011's intent. `amr` is an **optional** claim in Entra ID tokens: it's populated for fresh interactive sign-ins where Conditional Access asked for an MFA method, and frequently absent for SSO / refresh flows or in tenants where no CA policy is configured on the app registration. Rejecting tokens on empty `amr` blocks every legitimate sign-in against such a tenant.

ADR-0011 actually specifies:
- **Conditional Access** (org-side) is the enforcement layer for "MFA happened".
- The **`@RequireMfa({ freshness: 600 })`** decorator (designed-in, no v1 consumer) is what guards sensitive routes.
- The BFF surfaces `amr` through the audit log and the future guard, not as a callback precondition.

## Fix

- **[`auth.errors.ts`](apps/portal-bff/src/auth/auth.errors.ts)**: drop the `amr-missing` variant from the `AuthCodeFlowError` discriminator. Three failure modes left: `state-mismatch`, `flow-expired`, `token-exchange-failed`. MSAL's ID-token validation (signature, issuer, audience, exp, nbf) is the real gate at this stage.
- **[`auth.service.ts`](apps/portal-bff/src/auth/auth.service.ts)**: `toAuthenticatedUser` keeps extracting `amr` and passing it through (as a possibly-empty string array) so the structured log line and the future `@RequireMfa` guard still see it. The strict `if (amr.length === 0) throw` is replaced by a comment explaining the new shape.
- **[`auth.service.spec.ts`](apps/portal-bff/src/auth/auth.service.spec.ts)**: the `'throws amr-missing'` test becomes `'returns the user even when the ID token has no amr claim'` — asserts the array passes through empty rather than blocking the flow.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green. **52/52 specs**.
- Manual smoke: end-to-end sign-in against the live tenant now lands cleanly on the SPA; Pino's `auth.signed_in` log shows the resolved identity with `amr` (often `[]` until CA is configured on the org side).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #108
2026-05-12 15:31:57 +02:00
julien c50794eceb feat(portal-bff): /auth/callback route — token exchange + amr check (#107)
CI / check (push) Successful in 2m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m19s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 3m51s
## Summary

Fourth step of ADR-0009 wiring. Closes the OIDC round-trip on the BFF side (modulo session persistence — that's the next PR per ADR-0010). Entra now redirects the user back to `GET /api/auth/callback`; the BFF verifies the state, exchanges the code for tokens via MSAL's `acquireTokenByCode`, runs the ADR-0011 `amr` sanity-check, logs the resolved identity to Pino, clears the single-use pre-auth cookie, and 302s the user back to the SPA.

## What lands

- **[`auth.errors.ts`](apps/portal-bff/src/auth/auth.errors.ts)** — discriminated-union `AuthCodeFlowError` (`state-mismatch` / `flow-expired` / `amr-missing` / `token-exchange-failed`) + `AuthCodeFlowException` wrapper. The `kind` field doubles as the `?auth_error=<code>` query param on the SPA-bound redirect so the front-end can render an exact message without duplicating the string set.
- **[`AuthService.completeAuthCodeFlow(code, state, preAuth, now?)`](apps/portal-bff/src/auth/auth.service.ts)** — verifies state binding, refuses cookies older than the 5-minute flow TTL, calls MSAL Node's `acquireTokenByCode` with the stored verifier, validates `amr` is non-empty (the BFF sanity-check per ADR-0011 — Entra Conditional Access on the org side does the real enforcement), extracts `oid` / `tid` / `preferred_username` / `name` / `amr` into an `AuthenticatedUser` shape.
- **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** gains `clearPreAuthCookieOptions()` mirroring the set-options minus `maxAge` so the browser actually drops the cookie. (Cookies match by name + path + secure; getting any of those wrong leaves the old cookie in place.)
- **[`AuthController.callback()`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Get('callback')`. Always clears the cookie first (single-use). Bails on Entra-side errors (`?error=`), missing query params, missing or malformed cookie — each branch logs a structured Pino warning and redirects with the right `auth_error` code. On `AuthCodeFlowException`, logs + redirects with the typed `kind`. On success, logs an `auth.signed_in` event with `oid`, `tid`, `username`, `amr` (PII-sensitive bits only; no tokens), then 302s to `entra.postLogoutRedirectUri`.

## Decisions worth flagging

- **`postLogoutRedirectUri` reused as the SPA root URL.** Semantically a tiny stretch (its OIDC role is the post-logout destination) but the value is the same. Avoids one more env var until / unless the two URLs need to diverge.
- **Cookie cleared FIRST**, before any branching. Single-use is a property we want guaranteed regardless of which path exits the handler — overlap with a parallel /login from the same browser session would otherwise leak a usable cookie.
- **`auth.signed_in` logged via Pino, not via the audit module.** ADR-0013 wants this in the audit table; pairing audit with the session that ships in the next PR keeps the audit row carrying a `session_id` (otherwise it'd reference a "phantom" auth event with no follow-up).
- **`amr` non-empty is the BFF's check; the Conditional Access policy is what enforces "MFA happened".** ADR-0011 explicitly factors it this way — empty `amr` would indicate a policy misconfiguration where MFA never fired.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green.
- **52 / 52 specs** (was 39; +13 across the new completeFlow branches and callback branches).
- Service spec covers happy path + 6 failure modes (state mismatch, flow expired, amr missing, MSAL throws, MSAL returns null, oid claim missing).
- Controller spec covers happy redirect, Entra error, missing cookie, AuthCodeFlowException branch, missing query, malformed cookie.

## Manual smoke test (end-to-end)

1. `apps/portal-bff/.env` carries real `ENTRA_*` + `SESSION_SECRET`.
2. `nx serve portal-bff` and `nx serve portal-shell`.
3. Open `http://localhost:3000/api/auth/login` → redirects to Entra.
4. Authenticate. Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…`.
5. BFF processes; redirects to `http://localhost:4200/`. Pino log shows `auth.signed_in` with the user's `oid`, `tid`, `username`, `amr`.
6. Tamper test: open the link again, hand-edit the `state=` in the callback URL → BFF redirects with `?auth_error=state-mismatch`.

## What this PR explicitly does NOT do

- **Persist a session.** The user is "authenticated" from the BFF's point of view (identity resolved + logged) but the next request lands anonymous. Closes in the Redis sessions PR per ADR-0010.
- **Audit log entry.** Pairs with sessions so the row carries a `session_id`.
- **Logout / `/me`.** Land after sessions.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #107
2026-05-12 12:16:39 +02:00
julien 0eb404d111 feat(portal-bff): /auth/login route — pkce flow start + signed cookie (#105)
CI / scan (push) Successful in 2m27s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m29s
CI / a11y (push) Successful in 1m58s
CI / perf (push) Successful in 4m3s
## Summary

Third step of ADR-0009 wiring. Adds the first OIDC route, `GET /api/auth/login`: it 302s the browser to Entra's authorize endpoint with a freshly-generated state + PKCE challenge, and stashes the matching `{state, codeVerifier}` payload in a short-lived signed cookie so the next-PR callback can verify the round-trip.

## What lands

- **Cookie infra**: `cookie-parser` + `@types/express` deps; `main.ts` mounts the cookie middleware with the `SESSION_SECRET` signing key. Signed cookies are now available via `req.signedCookies` for the upcoming callback.
- **[`.env.example`](apps/portal-bff/.env.example)** promotes `SESSION_SECRET` from a future-vars comment into an active section, with a one-liner showing how to generate 32 random bytes.
- **[`check-session-secret.ts`](apps/portal-bff/src/config/check-session-secret.ts)** — boot-time guard: refuses to start if `SESSION_SECRET` is unset, still the .env.example placeholder, or decodes below 32 bytes of entropy. Same family as `check-database-url` / `check-entra-config`.
- **[`auth.service.ts`](apps/portal-bff/src/auth/auth.service.ts)** — `beginAuthCodeFlow()` uses MSAL's `CryptoProvider` for canonical PKCE verifier / challenge generation and a fresh GUID state per call, calls `msal.getAuthCodeUrl()` with the configured redirect URI + OIDC scopes (`openid profile email` — no `offline_access` in v1), and returns `{ authUrl, preAuthPayload }`.
- **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** — `portal_pre_auth` name, 5-minute TTL, shared `CookieOptions`: `signed`, `httpOnly`, `sameSite: 'lax'` (lets Entra's cross-site top-level redirect back through), `secure` toggled by `NODE_ENV`.
- **[`auth.controller.ts`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Controller('auth') @Get('login')`: writes the cookie then 302s. Thin shell around the service.
- **AuthModule** registers the new controller + service alongside the existing `ENTRA_CONFIG` and `MSAL_CLIENT` providers.

## Decisions worth flagging

- **Scope deliberately stops before the callback.** It's the next PR. Clicking `/auth/login` today round-trips through Entra and lands on a 404 — bounded mid-state, documented in the commit and here.
- **State + verifier in the cookie, not in Redis.** Keeps `/login` stateless (no server-side store), which means the BFF stays horizontally scalable from day one without sticky-session config. The next-PR callback reads `req.signedCookies` to recover the payload.
- **`portal_pre_auth`, not `__Host-portal_pre_auth`.** `__Host-` mandates `Secure`, and local dev is HTTP. The prefix + `Secure: true` lands together with the production TLS hardening ADR.
- **No `offline_access` scope.** Sessions are short-lived (per ADR-0010); the user re-authenticates through Entra rather than the BFF refreshing tokens behind their back. Smaller token footprint, less code to write, easier to reason about.
- **5-minute cookie TTL.** Enough for the Entra round-trip (including a fresh MFA prompt), short enough that a stale cookie can't be replayed long after the user abandoned the flow.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green.
- **39 / 39 specs** (was 30; +9 across `check-session-secret`, `auth.service`, `auth.controller`).
- The service spec mocks `getAuthCodeUrl`, asserts the redirect URI / scopes / S256 method, the state-verifier identity between the cookie payload and what's sent to Entra, and fresh-per-call replay protection.
- The controller spec asserts the cookie name + options + serialized payload and the 302 redirect.

## Manual smoke test (next PR completes the loop)

1. `apps/portal-bff/.env` has real `ENTRA_*` + `SESSION_SECRET`.
2. `nx serve portal-bff`.
3. `curl -i http://localhost:3000/api/auth/login` → 302 with `Set-Cookie: portal_pre_auth=…; HttpOnly; SameSite=Lax; Path=/`, `Location: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?...`.
4. Open the `Location` in a browser, authenticate, Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…` → 404 today, will be the next PR.

## Next PR on the auth track

`GET /api/auth/callback` — reads the signed cookie, verifies `state` matches, calls `acquireTokenByCode` with the stored verifier, validates the ID token (issuer, audience, exp, nonce, `amr` per ADR-0011), clears the pre-auth cookie, logs the resolved user identity, redirects to `/` (SPA). Still no session — that's the PR after.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #105
2026-05-12 11:20:03 +02:00
julien b7093d61de feat(portal-bff): msal confidential client provider in AuthModule (#104)
CI / check (push) Successful in 3m21s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m43s
CI / a11y (push) Successful in 1m56s
CI / perf (push) Successful in 3m34s
Second step of ADR-0009 wiring. AuthModule now exposes the
`@azure/msal-node` confidential client alongside the parsed Entra
config — the building block the upcoming OIDC routes inject to issue
the auth-code URL, exchange the callback code for tokens, and
acquire downstream tokens on behalf of the user.

What lands:

- `@azure/msal-node` added as a direct dependency (^5.2.1).
- `apps/portal-bff/src/auth/msal-client.token.ts` — `MSAL_CLIENT`
  string token + `ConfidentialClientApplication` type re-export.
  Mirrors the `ENTRA_CONFIG` token shape from PR #102.
- AuthModule grows a factory provider for `MSAL_CLIENT`:
  - Injects `ENTRA_CONFIG` + nestjs-pino `Logger`.
  - Builds a `ConfidentialClientApplication` with `clientId`,
    `authority`, `clientSecret` from the parsed config.
  - Wires `system.loggerOptions.loggerCallback` to forward MSAL's
    internal log lines into the Pino stream (per ADR-0012) — Error
    → logger.error, Warning → logger.warn, Verbose / Trace →
    logger.debug, Info → logger.log. PII logging is disabled by
    default so tokens / user identifiers never leak into our
    structured log records.
  - Sets MSAL's `logLevel` to Info — Pino's own threshold
    re-filters from there.
  - All MSAL log lines carry the `msal` Pino context for easy
    isolation in log queries.
- `AuthModule.exports` extended to include `MSAL_CLIENT`.

Verification:

- `nx run-many -t lint test build --projects=portal-bff` — green.
- 30/30 specs (was 29; +1 covering MSAL client construction).
- New spec imports `nestjs-pino`'s `LoggerModule.forRoot({ pinoHttp:
  { level: 'silent' } })` to provide the same Logger the production
  app supplies via `ObservabilityModule`, without flooding test
  stdout. Two tests assert the provider tree resolves correctly
  (ENTRA_CONFIG + MSAL_CLIENT) and one re-checks the missing-env
  failure mode still propagates through the new factory.

Construction is cheap — MSAL Node defers authority discovery to the
first auth call — so the client is built eagerly at module init.
The factory is injection-only; no MSAL methods get invoked yet.
Routes land in the next PR.

<!--
PR title format — becomes the squash-merge subject on main, validated by commitlint.

  <type>(<scope>): <short description>

Examples:
  feat(portal-shell): add user-preferences panel skeleton
  fix(portal-bff): correct env var bracket access
  docs(decisions): add ADR-0018 for security baseline
  chore(deps): bump @nx/* to 22.7.2

Imperative mood, lowercase, no trailing period, target ≤ 70 chars.
See docs/development.md §5 for the full convention (types, scopes).
-->

## Summary

## Motivation

## Implementation notes

## Verification

- [ ] `pnpm ci:check` green locally
- [ ] `pnpm ci:audit` green (or pre-existing drift acknowledged)
- [ ] Tested manually:
- [ ] Architecture diagram updated (if `docs/architecture.md` was affected)
- [ ] ADR amended or added (if a decision changed)

## Related

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #104
2026-05-12 10:34:23 +02:00
julien 58e3b65bd9 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
2026-05-12 02:27:55 +02:00