0b900037a76c0cb94358a81fb4c7c30ac81ba722
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aca9e8d155 |
feat(portal-bff): user directory upserted at sign-in (#140)
## Summary
First PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the **write side** only:
1. A new `public.users` table that holds the BFF's local cache of identities seen sign in to either portal-shell or portal-admin.
2. A `UserDirectoryService.recordSignIn(user)` upsert called from `SessionEstablisher.establish` after the blocking audit write.
The read side (`GET /api/admin/users` + the admin viewer SPA screen) lands in two follow-up PRs of the same chantier.
## Schema
[`prisma/schema.prisma`](apps/portal-bff/prisma/schema.prisma) gains a `User` model in the `public` schema:
| Column | Type | Notes |
| --- | --- | --- |
| `oid` | TEXT, PK | Entra's stable per-user identifier inside the tenant. Per-tenant uniqueness is sufficient for v1's single-workforce-tenant design (ADR-0008). |
| `tid` | TEXT | Tenant id. Updated on every upsert because a dual-audience future may legitimately change it. |
| `audience` | TEXT | `'workforce'` \| `'customer'`. Hardcoded to `workforce` in v1 per ADR-0008's simplification; will read from session/claims when External ID activates. |
| `username` | TEXT | Updated on every upsert (Entra-side rename possible). |
| `display_name` | TEXT | Same. |
| `first_seen_at` | TIMESTAMPTZ | Set once at first sign-in via DEFAULT NOW(); **never overwritten** thereafter. Enables "users since <date>" without joining anything. |
| `last_seen_at` | TIMESTAMPTZ | Updated on every upsert. Enables "most recently active" without scanning `audit.events`. |
Indexes:
- `last_seen_at DESC` — admin default sort.
- `username` — prefix filtering.
Migration in [`prisma/migrations/20260514192014_users_directory/`](apps/portal-bff/prisma/migrations/20260514192014_users_directory/migration.sql).
## [`UserDirectoryService`](apps/portal-bff/src/users/user-directory.service.ts)
```ts
async recordSignIn(entry): Promise<void> {
try {
await prisma.user.upsert({
where: { oid },
create: { oid, tid, audience, username, displayName },
update: { tid, audience, username, displayName, lastSeenAt: new Date() },
});
} catch (err) {
// logged, never propagated
}
}
```
**Best-effort write.** Catches its own errors, logs a Pino warn (`user_directory.record_sign_in_failed`), returns `undefined`. The directory is a convenience for admin browsing, not a security boundary — a Postgres hiccup must not lock a user out of sign-in. ADR-0013's "no audit ⇒ no action" applies to the audit module only.
## [`SessionEstablisher`](apps/portal-bff/src/auth/session-establisher.service.ts) wiring
The directory call lands right after the existing audit emission:
```ts
await this.audit.signIn({ actor: user, sessionId: req.sessionID }); // blocking per ADR-0013
await this.userDirectory.recordSignIn({ ...user, audience: 'workforce' }); // best-effort
this.logger.log(...);
```
Two invariants the tests pin:
1. **Audit-first**: when `audit.signIn` throws, `userDirectory.recordSignIn` is NOT called. The directory never holds a row for a sign-in the audit log doesn't carry.
2. **Awaited**: an admin who just signed in sees themselves on the user list immediately — no race between the upsert and the response.
## Module wiring
[`UsersModule`](apps/portal-bff/src/users/users.module.ts) is declared `@Global()` so `SessionEstablisher` (which lives in `AuthModule`) injects `UserDirectoryService` without forcing `AuthModule` to import `UsersModule`. The directory is a true cross-cutting concern: one writer (the auth callback) and one future reader (the admin endpoint).
Wired into [`AppModule`](apps/portal-bff/src/app/app.module.ts) alongside the other v1 modules. `auth.module.spec.ts` updated to also import `UsersModule` in its slice-of-graph compile (otherwise the test fails to resolve the new `SessionEstablisher` dep).
## Notes for the reviewer
- The directory write **awaits** (not fire-and-forget). The cost is one round-trip per sign-in on the response-critical path; the benefit is the no-race property called out above. If sign-in p95 becomes an issue we can revisit (e.g. background job) but the simpler shape is correct first.
- `firstSeenAt` is intentionally absent from the `update` payload. The Prisma upsert's `update` block is precisely what changes on conflict; omitting the field leaves it untouched at the column level (Postgres-side default doesn't refire on UPDATE).
- The model lives in `public`, not in a dedicated `identity` or `cms` schema. ADR-0020 enumerates `cms.*` for editorial data and `audit.*` for the audit ledger but doesn't require a separate schema for user-directory data. We can promote it to its own schema later if a role-isolation need emerges; the migration would be a `ALTER TABLE users SET SCHEMA …`.
- `audit.events.actor_id_hash` is **not** stored on `public.users`. A future admin endpoint that joins sign-in counts from `audit.events` can compute the hash on-the-fly via `HashUserIdService` — keeping the salted-hash invariant from ADR-0013 intact (the salt stays inside the audit module).
## Test plan
- [x] `pnpm nx test portal-bff` — **365 specs pass** (was 358; +7: UserDirectoryService 4, SessionEstablisher integration 3).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing rate-limit warnings + one unused-eslint-disable from PR #137 are unrelated).
- [x] Prisma `migrate diff` confirms the model matches the migration SQL.
- [ ] e2e — after merge: sign in via portal-shell or portal-admin, expect a row in `public.users` with the right `oid` / `last_seen_at`; sign in again, expect the same row's `last_seen_at` to advance and `first_seen_at` to stay put.
## What's next
The chantier sequence:
1. **This PR** — write side: schema + service + sign-in upsert.
2. **PR 2** — BFF `GET /api/admin/users` (paginated + filterable, gated by `@RequireAdmin`, emits `admin.users.query` audit).
3. **PR 3** — portal-admin `/users` screen (table + filter form), promote the sidebar entry from "Soon" badge to live link.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #140
|
||
|
|
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
|
||
|
|
f9f0151717 |
feat(portal-bff): extract Entra roles claim onto AuthenticatedUser (#126)
## 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
|
||
|
|
0e6c114ba7 |
feat(portal-bff): rate limiting + structured error filter (#123)
## 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 |
||
|
|
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 |
||
|
|
a97be121e6 |
fix(portal-bff): audit writes use raw INSERT (audit_writer has no SELECT for RETURNING) (#121)
## 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 |
||
|
|
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 |
||
|
|
c3de2340e7 |
feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010 (#115)
## 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
|
||
|
|
0464ce3ac8 |
feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout (#112)
## 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
|
||
|
|
c50794eceb |
feat(portal-bff): /auth/callback route — token exchange + amr check (#107)
## 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
|
||
|
|
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
|