2e9605a0789f3de380e011d32b57382440f9cc37
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
2b0e20bd85 |
chore: wire PostgreSQL + Prisma per ADR-0006
Add Prisma 7 + nestjs-prisma. The schema lives at
apps/portal-bff/prisma/schema.prisma with provider postgresql; the new
prisma-client generator (Prisma 7 default) outputs the typed client to
apps/portal-bff/generated/prisma/ which is gitignored.
apps/portal-bff/src/app/app.module.ts imports PrismaModule.forRoot
({ isGlobal: true }) so PrismaService is injectable across the BFF
without per-module imports.
apps/portal-bff/.env.example documents DATABASE_URL with a local-dev
default, plus a forward list of env vars introduced by upcoming phases
and ADRs (auth, sessions, MFA, observability, audit, downstream APIs)
- catalog reference, not implementation. The actual .env stays
gitignored at both repo root and app levels.
prisma.config.ts (Prisma 7's TypeScript config) is committed; it loads
DATABASE_URL via dotenv. Schema and migrations paths are pinned to
prisma/ relative to the bff app.
PostgreSQL provisioning, RLS policies for the dual-audience design,
the dedicated audit schema with role grants (audit_owner / audit_writer
/ audit_reader / audit_archiver per ADR-0013), and column-level
encryption for L3-scoped data are out of scope of this commit -
they belong with the future on-prem infrastructure ADR.
|