a848e0fabda06083fb32aa332c791fd8800f60e1
15 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
12136f7a8a |
feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
## Summary First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident `Principal`. No new guards yet — those come in the next PR per the ADR's `§More Information` phasing. ## What lands **New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest) | File | Role | | --- | --- | | `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. | | `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). | | `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. | **BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`) | File | Role | | --- | --- | | `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. | | `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). | | `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). | | `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). | | `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. | | `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. | | `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role). | **Tests against the 19 test-tenant personas** (`principal-builder.spec.ts`) Every persona provisioned in `apfrd.onmicrosoft.com` on 2026-05-20 is exercised end-to-end: `admin`, `directeur-bordeaux`, `directeur-complexe`, `rh-aquitaine`, `rh-siege`, `collaborateur-simple`, `tresorier-bordeaux`, `dpo`, `it`, `benevole-aquitaine`, `chef-equipe-bordeaux`, `chef-service-bordeaux`, `directeur-territorial-aquitaine`, `juriste-siege`, `rssi`, `communication-siege`, `elu-ca-national`, `president-cd-aquitaine`, `secretaire-cd-aquitaine`. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (the `partenaire` gap is intentional per ADR-0025). **Infra + docs** | File | Change | | --- | --- | | `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. | | `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. | | `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. | | `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). | | `tsconfig.base.json` | `shared-auth` path alias. | | `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). | **ADR amendment** | File | Change | | --- | --- | | `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. | ## Notes for the reviewer - **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib is the correct home. The ADR is patched in the same PR so the document and the code agree. - **Why drop unknown privileges with a WARN instead of preserving them.** `Portal.GhostRole` in the `roles` claim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge. - **Why the principal builder uses a `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed. - **Why `user.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards. - **Why session.types.ts carries both `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`. - **Map file fails soft on absence, hard on malformation.** Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets `roles: []`. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. **Bad JSON / unknown slug / duplicate GUID → throws at boot.** The alternative would silently mis-resolve a role. - **Why `boot-time` validation only (no per-request)** for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session. - **Entra `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.roles = []` and a future warn for every group GUID that would have arrived (none, in that case). - **No drift gate yet.** ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or `pnpm run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today. ## Test plan - [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`) - 17 `shared-auth` tests (catalogue counts + resolver contracts) - 1 `shared-util` test (unchanged) - full lint + full build - [x] `pnpm nx format:check` — clean after `pnpm nx format:write` - [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects. - [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed. - [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis. ## What's next Per ADR-0025 §"More Information" phasing: 1. ✅ **This PR** — types + Principal builder + group-to-role mapping skeleton. 2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas. 3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue). 4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #206 |
||
|
|
9b7d16601d |
feat(portal-bff): ai-client skeleton — vendored protos + grpc client + Principal mapper (#195)
## Summary Step 2 of the AI-relay chantier (after [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) merged in #194). Lands the BFF-side **skeleton** that talks to `apf-ai-service` over gRPC: vendored protos, generated TypeScript stubs, typed wrapper clients, Principal mapper, and metadata builder — all tested against an in-process fake gRPC server. **No HTTP route is exposed in this PR**; the SSE bridge (`POST /api/ai/chat`, `GET /api/ai/rag/search`, `GET /api/ai/models`) ships in the next PR. The skeleton is self-contained: `AiClientModule` is built but is NOT imported in `AppModule` yet. The BFF runtime is byte-for-byte unchanged. Everything below exists for the next PR to wire into a controller. ## What lands ### Proto vendoring + codegen - `apps/portal-bff/src/grpc/proto/apf-ai/` — mirror of `apf-ai-service/contract/proto/` (common, chat, rag, ingestion, models). Both the `.proto` files and the regenerated `ts-proto` output under `grpc/gen/` are committed for hermetic builds and reviewable diffs (per ADR-0024 §"Sub-decision 3"). - `pnpm run grpc:codegen` — regenerates the stubs via `grpc-tools`' bundled `protoc` and the `ts-proto` plugin (`outputServices=grpc-js, esModuleInterop, forceLong=long, useOptionals=messages, exportCommonSymbols=false`). - `pnpm run grpc:sync` — copies the vendored `.proto` files from the sibling `apf-ai-service` working tree (`../apf-ai-service/contract/proto/`); developer convenience, never invoked from CI. Errors with an actionable message when the sibling tree is not where it expects. - Generated tree (`grpc/gen/**`) excluded from Prettier (`.prettierignore`) and ESLint (`eslint.config.mjs` ignores). Hand-rules apply to wrappers under `ai-client/`, not to codegen output. ### Dependencies - `@grpc/grpc-js@^1.13.0` — runtime gRPC client. - `@bufbuild/protobuf@^2.10.2` — wire codec used by `ts-proto`'s emitted code. - `long@^5.2.3` — int64 representation for proto Long fields (`forceLong=long`). - `ts-proto@^2.7.0` — devDep, TypeScript codegen plugin. - `grpc-tools@^1.13.0` — devDep, ships `protoc` + the gRPC plugin; added to `pnpm.onlyBuiltDependencies` so the postinstall binary download runs. ### Env validator `apps/portal-bff/src/config/check-ai-service-config.ts` follows the same posture as the other validators (per ADR-0018 §"BFF env-var loading"): small, per-key, runs at module init, throws with an actionable message on misconfiguration. Three vars: | Var | Mandatory | Purpose | |---|---|---| | `AI_SERVICE_GRPC_ENDPOINT` | yes | `host:port` reachable from the BFF — `apf-ai-service:8080` in dev Compose, service DNS + 443 in prod | | `AI_SERVICE_CLIENT_ID` | yes | Deployment slug propagated as the `x-client-id` metadata. Convention: `apf-portal-<env>` | | `AI_SERVICE_GRPC_TLS` | no (default `true`) | `false` for h2c in dev, `true` for h2 + TLS in prod | 7 spec cases lock the validation contract end-to-end. ### `AiClientModule` `apps/portal-bff/src/grpc/ai-client/` houses: - **`tokens.ts`** — DI tokens (`AI_CONFIG`, `AI_CREDENTIALS`, one per generated stub). - **`principal.mapper.ts`** — `PrincipalMapper.fromInputs({oid, tid, roles, extraAttributes})` returns the proto `Principal`. `subject` is hashed via `HashUserIdService` (the **same** salt + algorithm the audit writer uses) so `Principal.subject` matches `audit.events.actor_id_hash` byte-for-byte. `roles` passes through verbatim — inclusive expansion lands with a future role-hierarchy ADR; the wire contract on the AI side is unchanged. - **`grpc-metadata.builder.ts`** — stamps every outbound call with `x-client-id` (from config) and `x-correlation-id` (active OTel span's trace-id when present, else explicit override, else fresh UUID). - **`chat.client.ts`** — server-stream wrapper around `ChatServiceClient`. Returns the raw `ClientReadableStream<ChatEvent>` (Node `Readable` is async-iterable so the SSE bridge consumes with `for await`). Optional `AbortSignal` propagates browser disconnect to `call.cancel()`. - **`rag.client.ts`**, **`models.client.ts`**, **`ingestion.client.ts`** — unary promisified wrappers. `IngestionClient` is unused in v1 (the admin "manage AI corpus" surface lands later) but wired now so the future controller is one new file, not a new module. - **`ai-client.module.ts`** — NestJS module wiring the providers. `HashUserIdService` is declared locally rather than imported via `AuditModule` (the global module) to keep the module unit-testable in isolation; runtime equivalence is guaranteed by the service being a pure function of `LOG_USER_ID_SALT`. ### Tests 5 new spec files, 18 new test cases. All run against in-process fake gRPC servers; no network, no live `apf-ai-service`: - `check-ai-service-config.spec.ts` — 7 cases (happy path + 6 rejection branches). - `principal.mapper.spec.ts` — 7 cases including the cross-service hash-stability invariant and the `tenantId` reserved-key contract. - `grpc-metadata.builder.spec.ts` — 5 cases covering all three correlation-id resolution paths and metadata immutability. - `chat.client.spec.ts` — 4 cases: happy-path stream, metadata propagation, mid-stream cancel via AbortSignal, pre-aborted signal. - `rag.client.spec.ts` — 2 cases: unary happy path, `ServiceError` propagation. - `ai-client.module.spec.ts` — 4 cases: module bootstrap, all four wrappers resolved, env-driven `AI_CONFIG`, shared credentials across stubs. **Total BFF spec suite: 443 → 461 (after merge accounting), all passing.** ## Notes for the reviewer - **Why both `.proto` and generated `.ts` are committed.** Hermetic builds. Reviewers see both sides of a contract change in one diff. CI never runs codegen — drift between proto and stub would otherwise hide behind a successful build. The drift gate that compares the vendored copy against an upstream tag of `apf-ai-service` is the post-v1 follow-up listed in ADR-0024's "What's next". - **`HashUserIdService` declared locally in `AiClientModule`.** Two instances of the service exist when both `AuditModule` (global) and `AiClientModule` are wired into `AppModule`. The cost is one extra constructor call at bootstrap; the value is full test isolation of `AiClientModule` and a self-contained Principal-mapping boundary. The cross-service hash join invariant from ADR-0013 still holds because the service is a pure function of the `LOG_USER_ID_SALT` env var. - **`AiClientModule` is NOT imported by `AppModule`.** Deliberately. The skeleton compiles, type-checks, and unit-tests cleanly with no runtime side effects. The next PR (SSE bridge controller) adds the `imports: [AiClientModule]` line + the controller, in one focused change. - **`ts-proto` flat-oneof emission.** `ChatEvent` is generated with optional siblings (`token?`, `citation?`, `done?`, …) rather than a discriminated union (`$case: 'token'`). The flatter shape composes more naturally with the SSE writer the next PR will introduce (`event:` field name maps directly to the populated sibling). - **Cancellation test deliberately relaxed.** The "AbortSignal already aborted before call dial" test asserts the client-side outcome (no payload, error or clean end) but not server-side observation. gRPC-js may or may not propagate a cancel frame depending on whether the call had time to dial — both outcomes are correct per the contract; only the absence of payload matters. - **Lifecycle (`onApplicationShutdown`) deferred.** The module does not close the gRPC channel on shutdown today. Process termination closes the sockets via OS-level descriptor reclaim — sufficient for dev/preprod. The next PR wires the module into `AppModule` and adds an explicit Nest lifecycle hook in the same change (paired with `app.enableShutdownHooks()` in `main.ts`). ## Test plan - [x] `pnpm run grpc:codegen` — clean regeneration. Generated tree byte-identical to what's committed. - [x] `pnpm nx test portal-bff` — **443 specs pass** (was 425). - [x] `pnpm nx lint portal-bff` — clean. The eslint ignore for `grpc/gen/**` covers ts-proto's relaxed style; hand-written `ai-client/` files pass the project's full rule set. - [x] `pnpm nx build portal-bff` — clean, webpack-compiled. No bundle-size delta on the runtime artefact yet (the module is unimported). - [x] `pnpm install` — lockfile reconciled; `grpc-tools` postinstall fetches `protoc` from the precompiled-binaries mirror without errors on Linux x64. - [ ] **Manual smoke (next PR)** — once the SSE bridge ships, point `AI_SERVICE_GRPC_ENDPOINT` at the local `apf-ai-service` Compose service and run an end-to-end chat against the canned stub responses on the AI side. Not in scope for this PR. ## What's next 1. **PR — SSE bridge controller.** Wires `AiClientModule` into `AppModule`, adds `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models`. Adds the `OnApplicationShutdown` hook + `enableShutdownHooks()`. Adds `apf-ai-service` to `infra/local/dev.compose.yml`. Promotes ADR-0024 from `proposed` to `accepted` and updates `CLAUDE.md`'s ADR roll-up. 2. **PR (frontend chantier)** — chatbot widget on `portal-shell` consuming the SSE endpoint. 3. **PR (post-v1)** — proto-drift CI gate diffing the vendored `proto/apf-ai/` against the upstream tag. 4. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed envelope or mTLS) on the same date. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #195 |
||
|
|
282a972346 |
feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json (#138)
## Summary Second half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the **signed-assertion strategy** (non-Entra downstreams) and the **JWKS publishing endpoint** as testable primitives, completing the strategy layer the OBO PR (#137) started. The framework around them (DownstreamApiClientFactory, cockatiel, audience pre-check, error translation) still waits for the first concrete integration per the ADR's own "until then" clause. After this PR the BFF has, ready to plug into a future integration: - `OboStrategy` — Entra-protected downstreams (PR #137) - `SignedAssertionStrategy` — non-Entra downstreams (this PR) - `DownstreamTokenCache` — encrypted-at-rest OBO token cache (PR #137) - `GET /.well-known/jwks.json` — public key publication (this PR) ## What lands ### [`assertJwksConfig`](apps/portal-bff/src/config/check-jwks-config.ts) Boot validator for `BFF_JWKS_PRIVATE_KEY_PATH` + `BFF_JWKS_KID`. Reads the PEM file once at startup, refuses missing / unreadable / weak material (RSA < 2048, Ed25519, unknown key type), derives the JOSE algorithm (`RS256` / `ES256` / `ES384`) from the key shape, and validates the kid against `[A-Za-z0-9_-]{4,128}` so the value lives unescaped in JWT headers + JWKS payloads. ### [`BffSigningKey`](apps/portal-bff/src/downstream/bff-signing-key.ts) Singleton holding `{ config: JwksConfig, publicJwk: JWK }`. The `publicJwk` is derived from the **public half** of the key (via `jose.exportJWK` on a `createPublicKey`-derived `KeyObject`) so no private material can leak through. Single DI source for both consumers (strategy + JWKS controller) so a key rotation only changes one provider. ### [`SignedAssertionStrategy`](apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts) Wraps `jose.SignJWT` with the ADR-0014 claim shape: ```json { "iss": "portal-bff", "sub": "<actor_id_hash>", "aud": "<downstream-name>", "audience": "workforce" | "customer", "claims": { /* curated subset */ }, "exp": <now + 60s>, "iat": <now>, "trace_id": "<W3C trace id>" } ``` - **60 s TTL** hard-coded — the ADR mandates it. - **No JWT cache** — at 60 s lifetime the savings would be negligible and a cache would let replayed assertions linger past their useful life. The signing operation itself is cheap (~hundreds of µs for RS256 with a 3 KB key). - **kid in the protected header** matches the JWKS so a downstream picks the right key during rotation. - Supports **RS256 / ES256 / ES384** transparently — picks the alg the validator derived at boot. ### [`JwksController`](apps/portal-bff/src/downstream/jwks.controller.ts) `GET /.well-known/jwks.json` returns `{ keys: [<single jwk>] }`. v1 publishes one key; the rotation chantier will add a second entry + window-based eviction so a downstream that cached the previous JWK keeps verifying during cut-over. [`main.ts`](apps/portal-bff/src/main.ts) excludes `/.well-known/*` from the global `/api` prefix so the route lands at the bare root per RFC 8615. No auth gate — the JWKS is the verification anchor; gating it would defeat the purpose. The CSRF middleware already exempts GET methods, so the route comes out clean. ## Required env update (mandatory at boot) Generate the key: ```bash mkdir -p apps/portal-bff/.secrets openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \ -out apps/portal-bff/.secrets/jwks.pem ``` Set in `apps/portal-bff/.env`: ```env BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem BFF_JWKS_KID=bff-2026-05 ``` The repo's existing `*.pem` / `*.key` gitignore patterns cover `.secrets/`. ## Dependency - **`jose@^6`** added as a direct dep (was transitive via MSAL). Pinned at the workspace root since the BFF is the only consumer today and the package isn't part of the Angular bundle graph. - `jest.config.cts`: `jose` ships ESM-only, so its `node_modules` path is removed from `transformIgnorePatterns`. The pattern walks pnpm's deep `.pnpm/` layout — anything under `/node_modules/` whose path also contains `jose` somewhere gets transformed by ts-jest. ## Out of scope (deferred until the first concrete integration) Per ADR-0014's "until then" clause: - `DownstreamApiClientFactory` + per-service typed `DownstreamApiConfig`. - `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead). - Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit). - Error translation tables per service. - OTel custom spans `downstream.<service>.<verb>.<path>`. - The framework code that actually calls `SignedAssertionStrategy.sign()` and attaches `X-User-Assertion` + the `ServiceCredential` auth header to an outbound HTTP request. - Key rotation (the JWKS lists one key for now; the rotation chantier adds the second entry + eviction policy). These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs. ## Test plan - [x] `pnpm nx test portal-bff` — **358 specs pass** (was 334; +24: env validators 11, signing key 4, strategy 6, controller 3). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Env validator: missing path, unreadable file, garbage PEM, RSA-1024 (weak), Ed25519 (unsupported), missing kid, illegal kid charset, kid too short. - [x] Signing key: RSA / EC P-256 / EC P-384 round-trip to public JWK with no private material (`d`, `p`, `q`, `dp`, `dq`, `qi` all absent from the published JWK). - [x] Strategy: claim shape matches ADR-0014, `exp - iat == 60`, audience mismatch rejected, signature mismatch rejected, EC P-256 signing path (ES256), per-call freshness. - [x] Controller: returns JWKS with the single public key, no private material leaks. - [ ] Manual smoke: generate a key locally + set the two env vars + `curl http://localhost:3000/.well-known/jwks.json` should return the JWKS shape with the chosen kid. ## Notes for the reviewer - The strategy uses `setProtectedHeader({ alg, kid })` — the kid in the protected header is the canonical way to tell a verifier "use the entry with this kid in the JWKS". Without it, a verifier holding two keys during rotation has to try both. - The `60 s` TTL is intentionally not env-overridable. ADR-0014 mandates it; making it tunable would create a tempting knob to widen the replay window for "performance". - `jose` was already in the tree transitively (likely via MSAL). Promoting it to a direct dep + pinning means a future hoist deduplication can't silently remove it without our review. ## What's next The chantier's strategy layer is complete. Open follow-ups on the roadmap: - **First concrete downstream integration** — when a real consumer arrives, the framework gets built around the two strategies (DownstreamApiClientFactory, cockatiel resilience, audience pre-check, error translation, OTel spans, audit events). Until then the strategies + cache + JWKS sit ready. - **Strategic security baseline ADR** — RSSI sign-off on ASVS / HDS / GDPR / NIS 2. Paused per [CLAUDE.md](CLAUDE.md) §"Repository status". - **portal-admin v1 modules** — CMS pages, menu management, user list. Each is its own self-contained chantier. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #138 |
||
|
|
d665c66c4e |
feat(portal-bff): obo strategy + encrypted downstream token cache (#137)
## Summary
First half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the OBO auth strategy and its encrypted-at-rest token cache as testable primitives — explicitly **not** the full `DownstreamApiClientFactory` + cockatiel + audience-pre-check framework.
The scope is dictated by ADR-0014 §"Consequences":
> *"Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. **Mitigated by writing the framework code only in the same iteration as the first concrete integration; until then, this ADR plus mock-driven unit tests on the strategies (OBO, signed-assertion) keep the design honest.**"*
The framework gets assembled when the first real downstream integration arrives, with that integration as the validation surface. The next PR in this chantier ships the symmetric signed-assertion strategy + the JWKS endpoint.
## What lands
### [`assertOboCacheEncryptionKey`](apps/portal-bff/src/config/check-obo-cache-encryption-key.ts)
Boot validator mirroring `assertSessionEncryptionKey`. AES-256-GCM, 32-byte requirement, placeholder rejection, fail-fast posture. Plus one extra defense in depth:
> *Refuses a value identical to `SESSION_ENCRYPTION_KEY`* — ADR-0014 §"Token cache (for OBO)" mandates dedicated keys; catching the copy-paste regression at boot prevents a silent trust-boundary downgrade.
Wired in [`main.ts`](apps/portal-bff/src/main.ts) alongside the other `assertX()` validators.
### [`DownstreamTokenCache`](apps/portal-bff/src/downstream/downstream-token-cache.service.ts)
Redis-backed cache, key shape `obo:{actorIdHash}:{resource}`. Encrypts each entry via the shared AES-256-GCM helpers from `session-crypto` but under a **dedicated key** (`OBO_CACHE_KEY`).
| Path | Behaviour |
| --- | --- |
| Cache miss | Returns `null`. |
| Tampered ciphertext | Returns `null` + Pino warn `downstream.obo_cache.decrypt_failed`. |
| Wrong-key ciphertext | Returns `null` (GCM auth-tag mismatch). |
| Decrypted but malformed shape | Returns `null` + Pino warn. |
| Redis read failure | Returns `null` + Pino warn `downstream.obo_cache.read_failed`. |
| Write of a token already inside the 60 s buffer | Skipped (TTL would be useless). |
| Redis write failure | Logged, non-fatal. |
Reads never throw — every failure collapses to a miss, the strategy re-acquires from Entra.
### [`OboStrategy`](apps/portal-bff/src/downstream/strategies/obo.strategy.ts)
Wraps MSAL Node's `acquireTokenOnBehalfOf` with the cache.
```
acquire(input):
cached = cache.get(...)
if cached && cached.expiresAt - now > 60s → return cached
result = msal.acquireTokenOnBehalfOf({ oboAssertion, scopes })
if !result || !result.accessToken || !result.expiresOn → throw OboAcquireError(msal-no-result)
cache.set(...)
return result
```
`OboAcquireError` carries a typed `reason` discriminator (`msal-refused` / `msal-no-result`) the future framework will translate to a **502 + `auth.token.validation.failed`** audit event per ADR-0014 — "the BFF does NOT silently fall back to the user's original token".
### One scope nuance from ADR-0014
ADR-0014 §"OBO strategy" says *"uses MSAL Node's `acquireTokenOnBehalfOf` with the user's current Entra access token (read from session via CLS)"*. v1 sessions don't persist the user's access token (ADR-0009 omits `offline_access` deliberately). For now the strategy takes the user access token as an **input parameter** — when the first concrete integration ships, the framework will fetch it from CLS / MSAL's token cache and forward here. That keeps the strategy a testable primitive without coupling to a session shape that doesn't exist yet.
### [`DownstreamModule`](apps/portal-bff/src/downstream/downstream.module.ts)
Provides `OBO_CACHE_KEY` (via the validator at factory time), `DownstreamTokenCache`, `OboStrategy`. Imports `AuthModule` for the shared `MSAL_CLIENT` and `RedisModule` for the shared `ioredis` client. Wired into `AppModule` though no runtime consumer yet — the registration makes the strategy injectable for the future integration without that integration having to also touch the module graph.
## Required env update (mandatory at boot)
```env
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
```
Generate with `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`. Must differ from `SESSION_ENCRYPTION_KEY` — the boot validator refuses identical values.
## Out of scope (deferred until the first concrete integration)
Per ADR-0014's "until then" clause:
- `DownstreamApiClientFactory` + per-service typed config.
- `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead).
- Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit event).
- Error-translation tables per service.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- The `auth.token.validation.failed` audit event itself (the discriminator is on `OboAcquireError`, the audit-emission glue lives in the future framework).
- The framework wiring that reads the user access token from CLS instead of accepting it as a parameter.
These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs.
## Test plan
- [x] `pnpm nx test portal-bff` — **334 specs pass** (was 308; +26: env validator 8, token cache 9, OBO strategy 9).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Env validator refuses placeholder, wrong length, non-base64url, AND identical-to-`SESSION_ENCRYPTION_KEY`. Boot-order tolerant: accepts the value when `SESSION_ENCRYPTION_KEY` is unset.
- [x] Token cache round-trip verified: written ciphertext starts with `v1.`, never contains the plaintext sentinel.
- [x] Tamper rejection verified: flipping the last char of the GCM-encrypted blob fails decryption and collapses to a miss.
- [x] Wrong-key rejection verified: writing with one key, reading with another, returns `null`.
- [x] TTL math verified: PX TTL = `expiresAt − now − 60 000`. Write skipped when token already inside the buffer.
- [x] OBO strategy: cache-hit short-circuit, stale-cache re-acquire, cold-cache → MSAL → cache.set, MSAL refusal → typed error, MSAL null-result → typed error, empty access token → typed error, null expiresOn → typed error.
## Notes for the reviewer
- The strategy file uses `override readonly cause` on `OboAcquireError` because TS `strict.exactOptionalPropertyTypes + noImplicitOverride` flags shadowing the built-in `Error.cause`. The shadowing is intentional — we want the typed cause property visible in error consumers — so the `override` keyword is the canonical way.
- `DownstreamTokenCache.get`'s "never throws" posture is deliberate. A cache failure must not poison a downstream call: the strategy re-acquires from Entra. The trade-off is that a key-rotation gone wrong shows up as silent re-acquisitions (no errors, just extra MSAL load); the structured Pino warns are the ops signal.
- The `DownstreamModule` is wired into `AppModule` even though nothing consumes the strategy at runtime. Without the wiring, the first integration PR would have to also touch the module graph; with it, the integration is just "inject `OboStrategy` and call `.acquire()`".
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #137
|
||
|
|
77343e3113 |
fix(portal-bff): use the real portal-admin dev port (4300) in admin-flow references (#131)
## Summary PR #129 (`feat(portal-bff): distinct admin session + /api/admin/auth flow`) baked `4201` into a handful of comments, test fixtures, and the `.env.example` as the portal-admin dev port. The actual port wired in [apps/portal-admin/project.json](apps/portal-admin/project.json#L87) `serve.options.port` is **4300** — that's what `pnpm nx serve portal-admin` listens on. This PR aligns the references so a contributor copying values from `.env.example` (or reading the test fixtures) sees the same port their browser is going to hit. It also drops `http://localhost:4300` into `CORS_ALLOWED_ORIGINS` — the portal-admin SPA will hit the BFF with credentials as soon as the admin auth flow is exercised end-to-end, and without the origin in the allowlist the browser blocks the call. Better to set the right example now than have the next contributor chase a CORS error. ## Touched - [apps/portal-bff/.env.example](apps/portal-bff/.env.example): - `ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI` default + the surrounding comment now point at `http://localhost:4300/`. - `CORS_ALLOWED_ORIGINS` example lists both `:4200` (portal-shell) and `:4300` (portal-admin). - Both sections cite `apps/<app>/project.json` `serve.options.port` as the source of truth so a future reader doesn't have to grep. - [apps/portal-bff/src/config/check-cors-allowlist.ts](apps/portal-bff/src/config/check-cors-allowlist.ts) — stale doc-comment that pre-dated the portal-admin scaffolding, now matches reality. - Test-fixture `adminPostLogoutRedirectUri` values in `auth.module.spec.ts`, `auth.controller.spec.ts`, `auth.service.spec.ts`, `admin-auth.controller.spec.ts`, `check-entra-config.spec.ts` — tests don't depend on the port; aligned for clarity only. ## Test plan - [x] `grep -rn 4201 apps/ libs/` → empty. - [x] `pnpm nx test portal-bff` — **278 specs pass** (unchanged from #129; this PR only touches strings). - [x] No behaviour change in the BFF; only the example values shift. Developers must update their local `.env` to pick up the new port + origin. ## Notes for the reviewer The two new env vars from #129 (`ENTRA_ADMIN_REDIRECT_URI`, `ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI`) plus the existing `CORS_ALLOWED_ORIGINS` are mandatory at boot. If your local `apps/portal-bff/.env` still has the `4201` value, the BFF will still start (any valid URL passes the validators) — but admin logout will 302 you to a port nothing is listening on, and the admin SPA's BFF calls will fail CORS. Update to `4300` to match the actual portal-admin dev server. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #131 |
||
|
|
fed905edc5 |
feat(portal-bff): distinct admin session + /api/admin/auth flow (#129)
## Summary
Phase-3a step per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Sessions — distinct from `portal-shell`". Wires a second `express-session` middleware on `/api/admin/*` carrying `__Host-portal_admin_session` over Redis prefix `session:admin:`, and ships the parallel `/api/admin/auth/{login,callback,me,logout}` flow that populates it. Signing in to one surface no longer signs the user into the other — Entra SSO at the IdP level still preserves the click-through.
## What lands
### Session middlewares — path-routed dispatch
| Token | Cookie | Redis prefix | Bound to |
| --- | --- | --- | --- |
| `SESSION_MIDDLEWARE` | `portal_session` / `__Host-portal_session` | `session:` | every path **except** `/api/admin/*` |
| `ADMIN_SESSION_MIDDLEWARE` | `portal_admin_session` / `__Host-portal_admin_session` | `session:admin:` | `/api/admin/*` only |
Implemented via a `buildSessionMiddleware(redis, logger, opts)` factory in [session.module.ts](apps/portal-bff/src/session/session.module.ts) — the TTL policy, encryption key, signing secret, session-id entropy, and serializer error-handling all come from the same source. Only the cookie name + Redis key prefix differ.
The dispatch in [main.ts](apps/portal-bff/src/main.ts) is a tiny `(req, res, next) => req.path.startsWith('/api/admin') ? adminSession(...) : userSession(...)`. Running both middlewares unconditionally would have the second overwrite `req.session` from the first, collapsing the two surfaces.
### Distinct admin auth flow
[`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) mounts `/api/admin/auth/{login,callback,me,logout}`. Structurally identical to [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) but passes `adminRedirectUri` / `adminPostLogoutRedirectUri` and clears the admin session cookie on logout. `me` exposes the `roles` claim (admin SPA needs it for conditional UI); the user-portal `me` intentionally still doesn't.
### Shared `SessionEstablisher` (no controller duplication)
[`SessionEstablisher`](apps/portal-bff/src/auth/session-establisher.service.ts) encapsulates the session lifecycle so both controllers stay thin:
- `establish({ user, req, res, surface })` — mints CSRF, populates `user / createdAt / absoluteExpiresAt / csrfToken / mfaVerifiedAt`, saves, sets the CSRF cookie, registers in `user_sessions` index, emits `auth.sign_in` audit (blocking), logs with the `surface` tag.
- `destroy({ actor, req })` — when `actor` is set, removes from index + emits `auth.sign_out`; always destroys the session with Redis-hiccup tolerance.
No code duplicated between the two surfaces — the only per-surface differences are the redirect URIs (passed in) and the cookie names cleared on logout (controller-local).
### Entra config gains two URIs
`EntraConfig` adds `adminRedirectUri` + `adminPostLogoutRedirectUri`, validated at boot in [check-entra-config.ts](apps/portal-bff/src/config/check-entra-config.ts). The validator **refuses to start** when `ENTRA_ADMIN_REDIRECT_URI === ENTRA_REDIRECT_URI` — that misconfiguration would silently collapse the two surfaces into one session. Both URIs must be registered on the same Entra app registration's "Redirect URIs" list.
### `AuthService` API change
`beginAuthCodeFlow(redirectUri)`, `completeAuthCodeFlow(code, state, preAuth, redirectUri, now?)`, and `buildLogoutUrl(postLogoutRedirectUri)` now take their URI as a parameter. Callers (user-portal vs admin-portal controllers) pick which set to pass.
## Required ops action before this PR can run locally
Two new mandatory env vars. The BFF refuses to start without them.
```env
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4201/
```
The example values land in [apps/portal-bff/.env.example](apps/portal-bff/.env.example) for reference. The corresponding Entra app registration also needs `/api/admin/auth/callback` added to its "Redirect URIs" list before any admin sign-in works end-to-end.
## Notes for the reviewer
- The user-portal callback's post-login redirect still targets `postLogoutRedirectUri` (existing quirk where the post-auth and post-logout landing happen to be the same URL). The admin callback mirrors the pattern for `adminPostLogoutRedirectUri`. Splitting these into dedicated post-login URIs is a separate ADR/PR.
- `AdminModule` now imports `AuthModule` to consume `AuthService`, `SessionEstablisher`, and `ENTRA_CONFIG`. `AuditWriter` and `RequireMfaGuard` come through transitively.
- Existing `AuthController` spec assertions are preserved through the refactor by constructing a **real** `SessionEstablisher` in the test fixture with the same audit / index / logger mocks. No behavioural assertion was removed — the inline session-state-setting logic is now exercised through the establisher.
- The pre-existing docstring in `check-entra-config.ts` line 11-16 still says "the two redirect URIs are mandatory once the OIDC routes ship (next PR)" — stale, the routes have shipped. Not touched in this PR to keep the diff focused; can be a one-line doc PR later.
## Test plan
- [x] `pnpm nx test portal-bff` — **278 specs pass** (was 253; +25: admin cookie 3, session-establisher 11, admin auth controller 9, entra config 2).
- [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] Entra config validator: both URIs required, both URL-validated, equality refused.
- [x] Path-dispatch verified by routing — `/api/admin/me` and `/api/admin/auth/*` see the admin session; everything else sees the user session.
- [ ] e2e — pending env var update + Entra registration update to add the admin redirect URI. Once both are in place: sign in via `/api/auth/login`, see `portal_session` cookie; clear cookies; sign in via `/api/admin/auth/login`, see `portal_admin_session` cookie; verify `/api/admin/me` works on the admin session and `/api/auth/me` works on the user session — neither sees the other's session.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #129
|
||
|
|
d51ccebe6a |
feat(portal-bff): @RequireMfa decorator + freshness guard (ADR-0011) (#128)
## Summary
Third step in the `portal-admin` audit-log-viewer workstream — ships the `@RequireMfa({ freshness })` decorator + guard called out in [ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md) and referenced as the gate on the admin entry route in [ADR-0020](docs/decisions/0020-portal-admin-app.md). Designed-in, dormant: no v1 route uses the decorator yet. First consumer will be the admin entry route once the distinct admin session lands (next PR).
## What ships
- **[`auth/mfa.ts`](apps/portal-bff/src/auth/mfa.ts)** — `MFA_AMR_VALUES = ['mfa', 'otp', 'fido', 'wia', 'phr']` allow-list and `wasMultiFactor(amr): boolean`. The list mirrors ADR-0011 §"BFF verification"; the spec pins it so an ad-hoc edit can't bypass review.
- **[`config/check-mfa-config.ts`](apps/portal-bff/src/config/check-mfa-config.ts)** — `readMfaConfig()` reads `MFA_FRESHNESS_SECONDS` (default **600 s**, minimum **60 s**). Anything below the floor throws at boot — the floor catches a misconfigured "MFA on every navigation" before the BFF starts.
- **[`auth/require-mfa.guard.ts`](apps/portal-bff/src/auth/require-mfa.guard.ts)** — four branches:
| Branch | HTTP | Code | Audit |
| --- | --- | --- | --- |
| No session | 401 | `unauthenticated` | none (noise) |
| Session, no MFA-class `amr` | 401 | `mfa_required` | `auth.mfa_required reason=no-mfa-in-amr` |
| Session, no `mfaVerifiedAt` | 401 | `mfa_required` | `auth.mfa_required reason=no-mfa-verified-at` |
| Session, stale `mfaVerifiedAt` | 401 | `mfa_required` | `auth.mfa_required reason=mfa-stale, mfaAgeMs=…` |
The `reason` discriminator is **not** surfaced over the wire — only the audit row carries it. An attacker probing for "stale vs no-MFA" can't distinguish the two from the response.
- **[`auth/require-mfa.decorator.ts`](apps/portal-bff/src/auth/require-mfa.decorator.ts)** — `@RequireMfa({ freshness? })` built via `applyDecorators(SetMetadata, UseGuards)`. The per-route `freshness` override wins over the env default. Designed to compose with `@RequireAdmin()` — apply `@RequireMfa` outside `@RequireAdmin` so the freshness gate runs only after role is established.
- **[`AuditWriter.mfaRequired()`](apps/portal-bff/src/audit/audit.service.ts)** — new typed method using `outcome=denied`, captures `reason`, `freshnessSeconds`, and `mfaAgeMs` (when applicable) in the JSONB payload.
- **`session.mfaVerifiedAt: number`** — augmented onto `express-session`'s `SessionData` in [`session.types.ts`](apps/portal-bff/src/session/session.types.ts). Set to `Date.now()` at sign-in by the callback ([`auth.controller.ts`](apps/portal-bff/src/auth/auth.controller.ts)). Entra's CA policy is the authority on whether MFA actually happened; the BFF stamps "now" when persisting a session whose `amr` reflects MFA.
## Deferred — for the SPA-interceptor PR
ADR-0011 §"Step-up MFA — designed-in" step 2 calls for a `WWW-Authenticate` header carrying a **claims challenge** (MSAL-produced blob) on the 401. That requires:
1. MSAL Node integration to mint the challenge — adds wire-format coupling to MSAL we don't have anywhere else yet.
2. The Angular SPA interceptor to consume the header, redirect to `/auth/login?claims=…`, and retry the original request.
Neither side has a consumer in this PR. Shipping a `code: 'mfa_required'` in the structured envelope is sufficient signalling for the SPA interceptor once it lands — the interceptor PR can layer the `WWW-Authenticate` header and the MSAL claims blob without changing the guard's audit contract.
## Composability with `@RequireAdmin`
The admin entry route (next-PR consumer) will read:
```ts
@Controller('admin')
@RequireMfa({ freshness: 600 })
@RequireAdmin()
export class AdminController { … }
```
Apply order matters — Nest runs guards in the order their decorators were applied (innermost first). Putting `@RequireMfa()` outside `@RequireAdmin()` means a non-admin user gets a clean 403 from `AdminRoleGuard` without a spurious `auth.mfa_required` audit row. The decorator's JSDoc spells this out for future consumers.
## Notes for the reviewer
- The `RequireMfaGuard` is registered as a provider in `AuthModule` and re-exported. Per the existing convention ("`AuthModule` stays non-global; modules state 'I depend on auth' by importing it"), any future module using `@RequireMfa()` will need to `imports: [AuthModule]`. The `AdminModule` already does this transitively via shared `AuditWriter`; the explicit import will follow when the decorator is first applied.
- `mfaChallenge(reason)` takes the reason argument deliberately even though it ignores it in the response — keeps the call sites readable (`throw this.mfaChallenge('mfa-stale')`) and parks a hook for the day we want to localise / differentiate the message.
- New env var `MFA_FRESHNESS_SECONDS` is **optional** (default 600). No production env change is required to ship this PR.
## Test plan
- [x] `pnpm nx test portal-bff` — **251 specs pass** (was 214; +37 covering helpers, config reader, guard branches, audit typed method, callback stamp).
- [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] `MFA_FRESHNESS_SECONDS` boot validator: default + valid + below-floor + non-integer + decimal + non-numeric all covered.
- [x] Guard timing-boundary cases covered (age == freshness passes; age == freshness + 1 ms fails — implicitly via the 700-s-vs-600-s test).
- [ ] e2e — pending real Entra session with `amr` carrying an MFA token. Will be exercised when the admin entry route applies the decorator.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #128
|
||
|
|
5bbe2304ff |
feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
## 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 |
||
|
|
940267e317 |
feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle (#120)
## 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 |
||
|
|
758d723744 |
feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 (#110)
## Summary
Mounts `express-session` + `connect-redis` at bootstrap on top of the shared `ioredis` client, with **AES-256-GCM applied to the full JSON payload before it lands in Redis** (per ADR-0010). The configured middleware is exposed as a NestJS provider (`SESSION_MIDDLEWARE`) and `main.ts` mounts it through `app.get(...)` so it sits on the same Redis connection the rest of the BFF uses — no second client at the bootstrap layer.
Envelope is versioned (`v1.<iv>.<tag>.<ciphertext>`, all base64url) so the algorithm / key derivation can rotate without a flag-day re-encryption. Tamper / wrong-key / unknown-version all raise `SessionDecryptError`; for now the failure is logged via Pino with `event: session.decrypt_failed` — the first-class audit event lands with ADR-0013.
Scope is intentionally **infrastructure only**:
- middleware mounted on every request, `req.session` available downstream
- session id = `crypto.randomBytes(32).toString('base64url')` (256 bits per ADR-0010)
- cookie name: `__Host-portal_session` in production, `portal_session` in dev (the `__Host-` prefix mandates `Secure`, which dev HTTP can't satisfy)
- `httpOnly + sameSite=lax + path=/`; `resave:false`, `saveUninitialized:false`, `rolling:true`
- cookie `maxAge` follows `SESSION_IDLE_TIMEOUT_SECONDS` (default 1800)
- encryption-at-rest active end-to-end
Out of scope, landing in follow-ups: `/auth/callback` populating `req.session.user`, `/me`, `/auth/logout`, the absolute-timeout interceptor, and the `user_sessions:{userId}` secondary index.
## Notable shape choices (ADR-0010 amended in the same commit)
**Full-payload encryption vs. just the `tokens` field.** The first draft of ADR-0010 scoped at-rest encryption to a `tokens` sub-field. The session also carries claims (`oid`, `tid`, `preferred_username`, …) that qualify as PII under GDPR — for an APF-Handicap portal handling health-adjacent data this matters. Encrypting the envelope is strictly stronger and removes the need to classify fields one by one. The ADR text is updated to match.
**`ioredis` + adapter vs. switching the BFF to `node-redis`.** `connect-redis` v9 was rewritten for `node-redis` v4 and no longer accepts `ioredis` directly. Two reasonable paths:
1. **Adapter (chosen)** — keep the shared `ioredis` client; shim the six commands `connect-redis` actually calls (`get`, `set` with `{expiration:{type:'EX',value}}`, `expire`, `del`, `mGet`, `scanIterator`) to the node-redis shape. Smallest blast radius — RedisModule, OBO cache (ADR-0014), future pub/sub all stay on a single Redis library.
2. **Switch RedisModule to `node-redis`** — clean alignment with `connect-redis`'s expectations, but touches every Redis consumer and would itself require an ADR amendment.
The adapter is reversible: if we ever decide to standardise on `node-redis`, deleting one file removes it. Happy to switch if you'd rather take that path.
## Env vars
- `SESSION_ENCRYPTION_KEY` — **mandatory**, AES-256-GCM key (32 bytes after base64url decode). New `assertSessionEncryptionKey()` validator wired in `main.ts` alongside the other pre-flight checks.
- `SESSION_IDLE_TIMEOUT_SECONDS` — optional, default `1800`.
- `SESSION_ABSOLUTE_TIMEOUT_SECONDS` — optional, default `43200` (consumed by the absolute-timeout interceptor in a follow-up).
`.env.example` updated; the three variables are promoted from the "future vars" block to the active section.
## Test plan
- [x] `pnpm nx test portal-bff` — **99/99 pass** (was 62 before this PR; +37 new specs across the 5 new files).
- [x] `pnpm nx build portal-bff` — clean webpack build.
- [x] `pnpm nx lint portal-bff` — clean.
- [x] Prettier-clean for all PR source files.
- [ ] Local smoke test once the next PR wires `/auth/callback` → `req.session.user`; this PR has no user-visible behaviour to exercise on its own.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #110
|
||
|
|
d4b5ed1c5d |
feat(portal-bff): redis client foundation per ADR-0010 (#109)
## Summary First step toward Redis-backed sessions (ADR-0010). Adds the shared `ioredis` connection that every downstream consumer (session storage, OBO token cache, …) injects via the new `REDIS_CLIENT` DI token. No session logic in this PR — that's the next one. ## What lands - **`ioredis@^5.10.1`** as a direct dependency. Chosen by ADR-0010 for its mature Sentinel support — single-instance URL today, Sentinel-HA configuration lands with the prod infrastructure ADR. - **[`.env.example`](apps/portal-bff/.env.example)** promotes `REDIS_URL` from its future-vars comment to an active variable, defaulting to the local Compose stack's address. The Sentinel-style keys (`REDIS_SENTINEL_HOSTS`, `REDIS_SENTINEL_NAME`, `REDIS_TLS`) stay in the future-vars comment until the prod deploy. - **[`check-redis-config.ts`](apps/portal-bff/src/config/check-redis-config.ts)** — boot-time guard mirroring the existing four: - Refuses to start on missing / non-`redis(s)://` / passwordless / placeholder URLs. - Returns a typed `RedisConfig` with parsed `host` + `port` for downstream observability. - **[`redis.token.ts`](apps/portal-bff/src/redis/redis.token.ts)** — `REDIS_CLIENT` string token + `Redis` type alias. Same shape as the existing `ENTRA_CONFIG` / `MSAL_CLIENT`. - **[`redis.module.ts`](apps/portal-bff/src/redis/redis.module.ts)** — `RedisModule` factory provider: - Caps `maxRetriesPerRequest: 3` so an unreachable Redis surfaces a clear command-time error rather than an infinite reconnect storm. - Wires `connect` / `ready` / `error` / `close` / `reconnecting` events into the Pino stream under the `redis` context — easy log isolation. - Non-global; consumers import the module to state "I depend on Redis". - **`main.ts`** calls `assertRedisConfig()` alongside the other three validators; **`AppModule`** imports `RedisModule`. ## Decisions worth flagging - **`maxRetriesPerRequest: 3`** rather than the ioredis default of 20. With the default, a Redis outage masquerades as request-level timeouts spread over minutes. Capping low surfaces the outage in the first command failure — the BFF can then return 503 and recover quickly when Redis comes back. - **Single shared client.** Pub/sub use-cases (when they appear) duplicate via `redis.duplicate()` per ioredis convention. Connect/disconnect is one socket per BFF instance. - **No explicit shutdown hook yet.** Node's process-exit handlers and ioredis's own cleanup take care of the socket on SIGTERM / Ctrl+C. If we see stuck connections in real load, we wire `OnApplicationShutdown` + `redis.quit()`. - **Sentinel-style config stays in the future-vars comment.** ioredis supports it natively, but plumbing it on top of the URL form complicates the validator and the factory for zero v1 payoff. Lands with the prod infrastructure ADR. ## Verification - `nx run-many -t lint test build --projects=portal-bff` — green. - **62 / 62 specs** (was 52; +10 — `check-redis-config` covers happy path + 6 failure modes; `redis.module` covers DI resolution against an unreachable URL plus the missing-env failure). - Boot smoke against the local Compose stack: Pino's `redis` context shows `redis.connect` → `redis.ready` on startup; killing the Redis container produces `redis.close` / `redis.reconnecting` lines. ## What this PR explicitly does NOT do - Mount `express-session` + `connect-redis` middleware. The next PR wires the session cookie (`__Host-portal_session`), the encrypted payload, and the lookup middleware that attaches `user` to every request. - Plug the callback into session creation. Auth still ends with a Pino log + redirect; the SPA still sees the user anonymous on the next request. - Sentinel / TLS configuration. Future-var keys are documented in `.env.example` for when the prod deploy lands. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #109 |
||
|
|
0eb404d111 |
feat(portal-bff): /auth/login route — pkce flow start + signed cookie (#105)
## 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
|
||
|
|
58e3b65bd9 |
feat(portal-bff): entra config foundation — boot validator + auth module (#102)
## 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 |
||
|
|
c3c15585ff |
style(portal-bff): apply prettier to check-database-url.ts (#71)
## Summary Cosmetic-only follow-up to #70. Collapses one over-cautious multi-line `throw new Error(...)` into a single line that fits within the project's 100-char `printWidth`. The reformat was produced by lint-staged **during** the amend that landed #70, but applied to the working tree only — the modification never made it back into the staged content of the amend. The merged commit therefore carries the un-prettified version. Spotted locally with `pnpm exec prettier --check apps/portal-bff/src/config/check-database-url.ts` failing. Left unchecked, the next PR's `check` job would break at `format:check` for unrelated reasons. ## Test plan - [ ] CI green on this PR (`format:check` clean). - [ ] No behavioural change — only whitespace. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #71 |
||
|
|
b74d3f1b9b |
feat(portal-bff): observability foundations (Pino + CLS + OTel) (#70)
## Summary
Implements ADR-0012 phase 1, BFF side. The SPA wiring is a separate phase-2 PR.
The BFF now emits structured JSON logs to stdout, tagged with `trace_id` / `span_id` from the active OTel context, and exports OTLP traces over HTTP/Protobuf to the Collector that already runs in the local-dev compose. Anything Nest, Express, HTTP-out, Prisma (Postgres) or `ioredis` does is auto-spanned. A `GET /api/health` liveness endpoint is added to round things out.
## What lands
**Runtime libs added** (production deps):
- `nestjs-pino`, `pino`, `pino-http` — structured logging
- `nestjs-cls` — request-scoped context
- `@opentelemetry/api` / `sdk-node` / `resources` / `semantic-conventions`
- `@opentelemetry/exporter-trace-otlp-proto` (HTTP/Protobuf, port 4318)
- `@opentelemetry/instrumentation-{http,express,nestjs-core,pg,ioredis,pino}` — curated, **no** `auto-instrumentations-node` mass-import (anti-bricolage)
Dev: `pino-pretty` (gated by `NODE_ENV`).
**Code:**
- `apps/portal-bff/src/observability/tracing.ts` — OTel `NodeSDK` bootstrap. Documents the load-order constraint inline (must be the very first import of `main.ts`). Pure side-effect module.
- `apps/portal-bff/src/observability/observability.module.ts` — composes `ClsModule` (UUID per request stored as `request_id`) and `LoggerModule` (`pino-pretty` in dev, raw JSON in prod, `LOG_LEVEL` env-driven, `/health` excluded from auto-logging, `X-Request-Id` honoured if inbound).
- `apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.ts` — `GET /api/health` returning `{status, uptimeSeconds, service, version}`. Cheap liveness only — `/readiness` lands when dependencies have a readiness story.
- `apps/portal-bff/src/config/check-database-url.{ts,spec.ts}` — fail-fast validator called from `main.ts` before NestFactory boots. Catches the same family of bug that bit pgweb in #63: a literal special character in `POSTGRES_PASSWORD` that needs URL-encoding in `DATABASE_URL`. Prisma requires a URL string (no discrete-flag escape hatch), so early validation + a clear error message is the v1 mitigation. Six unit tests cover happy path, missing URL, wrong scheme, encoded special chars, literal `@` in password, malformed URL.
**Wiring:**
- `main.ts` imports `./observability/tracing` as line 1, then uses `app.get(Logger)` from `nestjs-pino` with `bufferLogs: true` so early-bootstrap lines are not lost.
- `app.module.ts` imports `ObservabilityModule` first, then `PrismaModule`, then `HealthModule`.
- `apps/portal-bff/.env.example` promotes `LOG_LEVEL`, `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_TRACES_SAMPLER` from the "future" comment to active settings — defaults target the local-dev Collector.
- Both `apps/portal-bff/.env.example` and `infra/local/.env.example` now spell out the URL-encoding constraint on `POSTGRES_PASSWORD` with the char-by-char encoding table (`@` → `%40`, etc.).
**ADR-0012 §Confirmation** rewritten to distinguish what landed in this PR from what is wired as the corresponding feature ADRs ship (CLS keys for `session_id` / `user_id_hash` / `audience`, `LOG_USER_ID_SALT` enforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config).
## Trace ↔ log correlation
Automatic via `@opentelemetry/instrumentation-pino` — every Pino record gets `trace_id` and `span_id` injected from the active OTel context. No CLS gymnastics needed for that concern.
## Verification
```bash
pnpm exec nx run-many -t lint test build # 8 projects green
pnpm audit --audit-level=moderate # 0 vulnerabilities
./infra/local/dev.sh up observability # start Collector + Jaeger
cp apps/portal-bff/.env.example apps/portal-bff/.env
pnpm nx serve portal-bff
curl http://localhost:3000/api/health
# → {"status":"ok","uptimeSeconds":N,"service":"portal-bff","version":"dev"}
```
Then hit `GET http://localhost:3000/api` once or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matching `trace_id`.
## Test plan
- [ ] `nx run-many -t lint test build` green on this PR's CI run.
- [ ] `pnpm audit` clean.
- [ ] BFF boots, `/api/health` returns the expected JSON.
- [ ] Pino logs in dev are colourised one-liners; in prod they would be raw JSON (toggled by `NODE_ENV=production`).
- [ ] With the local-dev stack's `--profile observability` active, traces are visible in Jaeger UI.
- [ ] Each Pino log line for a request carries the same `trace_id` as the trace span in Jaeger.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #70
|