Files
apf_portal/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md
julien ee51efb688
CI / scan (push) Successful in 2m13s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 3m46s
feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
## Summary

PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar.

| PR | Périmètre |
| --- | --- |
| PR 1  | Shared `UserMenu` dropdown + integration. |
| PR 2  | `/profile` pages on both apps. |
| **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. |

## What lands

### BFF — `GET /api/me/capabilities`

New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint:

```ts
GET /api/me/capabilities → { canAccessAdmin: boolean }
```

Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array.

### ADR-0009 amendment

[`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging:

> The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface.

The routes table grows a row for `/me/capabilities`.

### Portal-shell — capabilities-driven UI

- **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway.
- **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink.
- **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed:
  - `Anonymous` when no session.
  - `Administrator` when `canAccessAdmin()` is true.
  - `User` otherwise (signed-in, no admin).

  The aria-label gains a `role` placeholder so screen readers hear the live value.

### Portal-admin — symmetric cross-app entry

- **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface.

### Environments

`adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018.

### i18n

| Key | EN source | FR target |
| --- | --- | --- |
| `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal |
| `sidebar.role.administrator` | Administrator | Administrateur |
| `sidebar.role.user` | User | Utilisateur |
| `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise |

Admin-side strings stay in English source per ADR-0020.

## Notes for the reviewer

- **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit.
- **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`.
- **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request.
- **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`.

## Test plan

- [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`).
- [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke (with a `Portal.Admin`-assigned account):
  - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`.
  - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`.
  - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent.
  - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button.

## What's next

Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #151
2026-05-15 16:11:26 +02:00

257 lines
18 KiB
Markdown

---
status: accepted
date: 2026-04-29
decision-makers: R&D Lead
tags: [security, backend]
---
# Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern
## Context and Problem Statement
[ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) fixed the identity model: multi-tenant Entra ID for workforce, dual-audience design, M365 Developer tenant for non-prod. We now need to fix the _technical flow_: which OAuth/OIDC mechanism, which library, how tokens are obtained and held, how the session cookie is shaped, how CSRF is handled, how logout works.
The SPA must never hold tokens — that is the BFF security pattern, recommended by Microsoft and by the OAuth 2.0 BCP for browser-based apps (`draft-ietf-oauth-browser-based-apps`). The BFF holds the tokens server-side; the browser only carries an opaque session cookie.
## Decision Drivers
- Conform to the current IETF best current practice for browser-based apps (BFF pattern).
- Conform to Microsoft's recommended path for multi-tenant Entra workforce apps.
- Tokens never leave the server — the SPA cannot leak them via XSS.
- Support a future On-Behalf-Of flow when the BFF needs to call downstream Entra-protected APIs (covered by a later ADR).
- Defense in depth: hardened cookies, CSRF protection, refresh-token rotation.
- Consistent route shape so frontend, backend, and operators speak the same vocabulary.
## Considered Options
### Library
- **`@azure/msal-node`** (`ConfidentialClientApplication`). (Chosen.)
- `openid-client` — generic OIDC client.
- `passport-azure-ad` — deprecated, in maintenance only.
### OAuth flow
- **Authorization Code Flow with PKCE.** (Chosen.)
- Implicit Flow — deprecated by IETF, rejected.
- Hybrid Flow — legacy, rejected.
- Resource Owner Password Credentials — forbidden by Microsoft for production scenarios.
### Token storage
- **Tokens held in the BFF session, never sent to the browser.** (Chosen — the BFF pattern itself.)
- Tokens in browser memory or localStorage / sessionStorage — XSS-exfiltrable, rejected.
### Session cookie shape
- **`__Host-portal_session`** with `HttpOnly`, `Secure`, `SameSite=Lax`. (Chosen.)
- Same attributes without the `__Host-` prefix — weaker (subdomain bleed, downgrade attacks).
### CSRF
- **Double-submit cookie pattern.** (Chosen.) `__Host-portal_csrf` cookie (readable by the SPA) + matching `X-CSRF-Token` header on every state-changing request.
- Synchronizer token (server-side store) — heavier, requires a dedicated CSRF token store.
- No CSRF protection — unacceptable.
### Logout
- **RP-initiated logout.** (Chosen.) BFF invalidates the session, then redirects the browser to Entra's `end_session_endpoint`, which clears the Entra SSO session and redirects back.
- Local-only logout — the Entra SSO session stays live, the user can be silently re-authenticated without intent.
## Decision Outcome
The end-to-end flow at a glance:
```mermaid
sequenceDiagram
autonumber
actor U as User (browser)
participant SPA as portal-shell
participant BFF as portal-bff
participant E as Microsoft Entra ID
participant R as Redis (session store)
U->>SPA: clicks "Sign in"
SPA->>BFF: GET /auth/login?returnTo=…
BFF->>BFF: generate state + nonce<br/>+ code_verifier + code_challenge
BFF-->>U: 302 → Entra /authorize<br/>(client_id, code_challenge, state, nonce, scope)
U->>E: authenticate<br/>(Conditional Access enforces MFA)
E-->>U: 302 → BFF /auth/callback?code&state
U->>BFF: GET /auth/callback?code&state
BFF->>BFF: verify state matches<br/>(CSRF defence on the OIDC roundtrip)
BFF->>E: POST /token<br/>(code + code_verifier + client_secret/cert)
E-->>BFF: id_token + access_token + refresh_token
BFF->>BFF: validate id_token<br/>(sig, iss in allowlist, aud, exp/nbf)<br/>+ sanity-check amr (ADR-0011)<br/>+ map audience claim → Audience enum
BFF->>R: SET session:{opaque_id}<br/>payload incl. tokens encrypted AES-256-GCM (ADR-0010)
BFF-->>U: 302 → returnTo<br/>+ Set-Cookie __Host-portal_session<br/>+ Set-Cookie __Host-portal_csrf
U->>SPA: render destination (cookies attached on subsequent calls)
```
The numbered steps line up with the prose below: library / flow / tokens / token validation / refresh / cookies / CSRF / routes / auth layer.
**Library.** `@azure/msal-node`, instance of `ConfidentialClientApplication`, configured with the multi-tenant authority and the tenant allowlist from ADR-0008. PKCE is used despite the confidential-client setup, per IETF current BCP.
**Flow.** OAuth 2.0 Authorization Code Flow with PKCE, executed entirely on the BFF. The SPA never sees `code`, `code_verifier`, or any token.
**Tokens.** ID/access/refresh tokens are stored in the server-side session (storage backend covered by the next ADR). The browser only holds an opaque session identifier in `__Host-portal_session`.
**Token validation.** On callback, MSAL Node validates the `id_token` (signature against JWKS, `iss`, `aud`, `exp`, `nbf`). The BFF additionally enforces:
- `iss` belongs to the tenant allowlist (workforce tenants accepted by ADR-0008);
- `aud` matches our app's `client_id`;
- the audience claim is mapped to our `Audience` enum (workforce tokens → `audience: 'workforce'`; any other classification fails).
**Refresh.** Refresh-token rotation is enabled. MSAL Node's `acquireTokenSilent` is used to refresh access tokens transparently. When the refresh token is itself expired or revoked, the BFF returns 401 to the SPA, which redirects to `/auth/login`.
**Cookies.**
| Cookie | Purpose | Attributes |
| ----------------------- | --------------------------- | -------------------------------------------------------------------------------------------- |
| `__Host-portal_session` | opaque session id | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` (forced by the `__Host-` prefix), no `Domain` |
| `__Host-portal_csrf` | CSRF token (readable by JS) | `Secure`, `SameSite=Lax`, `Path=/`, no `HttpOnly` (the SPA must read it) |
`SameSite=Lax` (not `Strict`) is required because the Entra → BFF callback is a cross-site top-level redirect; `Lax` allows the cookie on top-level navigation, which is exactly what we need. The threat model trade-off is acceptable.
In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefix because it requires `Secure`. The dev environment runs over **HTTPS via mkcert**-issued local certificates so cookie names and attributes match production exactly. There is no fallback to non-prefixed cookies in dev — keeping a single behaviour avoids "works in dev, breaks in prod" surprises.
**CSRF.** Every non-`GET` request to the BFF must carry an `X-CSRF-Token` header whose value equals the value of the `__Host-portal_csrf` cookie. A NestJS interceptor enforces this on every state-changing endpoint and rejects with 403 otherwise. The Angular SPA ships an `HttpInterceptor` that reads the cookie and injects the header on outgoing requests.
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
| Method | Path | Purpose |
| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens, no raw `roles` claim — see "curated public view" below) |
| `GET` | `/me/capabilities` | curated capabilities view derived from the session (currently `{ canAccessAdmin: boolean }`); drives binary SPA gates without exposing the raw `roles` claim — see "curated public view" below |
**Authorization layer.** A NestJS `AuthGuard` checks for a valid session and rejects with 401 otherwise. A `@CurrentUser()` decorator extracts `{ id, audience, claims }` from the request scope. Every controller (other than `/auth/*` itself, `/health`, etc.) is protected by `AuthGuard` by default — the framework default is "denied", explicit allow-listing is required.
**Curated public view.** The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side, consumed by the BFF's own guards (`@RequireAdmin`, downstream-API gates, etc.). The SPA derives binary UX hints from a dedicated companion endpoint, `GET /me/capabilities`, which returns a curated structure (today `{ canAccessAdmin: boolean }`; further keys land as conditional UI requires). The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles (auditor, redactor, …) does not widen the SPA-side surface. The authoritative gate remains BFF-side via `@RequireAdmin` & co.; capabilities is UX-only. The admin-portal `/api/admin/auth/me` exposes `roles` explicitly because it _is_ the admin surface — see ADR-0020.
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
| Variable | Purpose |
| --------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `ENTRA_TENANT_ID` | home tenant id of the BFF (the tenant where the app is registered as the home org) |
| `ENTRA_CLIENT_ID` | app's client id |
| `ENTRA_CLIENT_SECRET` _or_ `ENTRA_CLIENT_CERT_PATH` | confidential-client credential (certificate preferred for production) |
| `ENTRA_ACCEPTED_TENANT_IDS` | comma-separated allowlist for `iss` validation |
| `ENTRA_REDIRECT_URI` | absolute URL of the `/auth/callback` endpoint, registered in the app registration |
| `ENTRA_POST_LOGOUT_REDIRECT_URI` | absolute URL the user lands on after Entra logout |
| `SESSION_SECRET` | symmetric secret for cookie signing (rotated procedure covered in a future ops ADR) |
### Consequences
- Good, because the SPA can never leak tokens — the worst an XSS can do is hijack the session cookie, and `HttpOnly` blocks even that.
- Good, because PKCE protects the code-exchange step against authorization-code interception even on the BFF side.
- Good, because the route shape and configuration surface are simple, predictable, and entirely documented.
- Good, because MSAL Node will let us add the On-Behalf-Of flow in the downstream-API ADR with minimal extra work.
- Good, because RP-initiated logout produces a clean cross-app state; users are not silently re-authenticated against their will.
- Good, because the dual-audience claim mapping is enforced at the validation step — workforce vs customer is a server-side decision, not a client-side hint.
- Bad, because the codebase becomes tightly coupled to MSAL Node and Entra. Switching IdP later means more than swapping a library — but the trade-off is accepted given identity is already locked to Entra.
- Bad, because `SameSite=Lax` (instead of `Strict`) is necessary for the callback to work; this is a known and accepted trade-off, mitigated by HTTPS, HSTS, the `__Host-` prefix, and CSRF.
- Bad, because every state-changing call from the SPA must carry `X-CSRF-Token` — small DX overhead, mitigated by a single Angular HTTP interceptor.
- Bad, because mkcert is required in dev to keep cookie behaviour identical to prod — slightly higher onboarding cost; documented in the dev setup guide.
- Bad, because the tenant allowlist (`ENTRA_ACCEPTED_TENANT_IDS`) must be maintained operationally as new B2B partners are onboarded — this is an operational item, not a one-shot.
### Confirmation
- `apps/portal-bff/src/auth/auth.module.ts` provides a single `ConfidentialClientApplication` configured from env, with PKCE enabled.
- `apps/portal-bff/src/auth/auth.controller.ts` exposes the four routes above and no others.
- `AuthGuard` is registered globally; routes that must be public are explicitly marked with a `@Public()` decorator.
- `__Host-portal_session` and `__Host-portal_csrf` are the only cookies set by the BFF (other than infrastructure cookies, none of which carry session data).
- A NestJS interceptor enforces double-submit CSRF on every non-`GET` request.
- The Angular HTTP interceptor in `portal-shell` injects `X-CSRF-Token` on every outgoing request to the BFF.
- `iss` validation rejects any token whose issuer is not in the tenant allowlist; tested with a token from a non-allowlisted tenant.
- Refresh-token rotation is asserted via integration tests (one expired access token + a still-valid refresh token must succeed silently; an expired refresh token must produce 401 and trigger re-authentication).
- RP-initiated logout is asserted: `POST /auth/logout` invalidates the session, returns the `end_session_endpoint` URL, and a subsequent request without re-authentication is denied.
## Pros and Cons of the Options
### Library
#### `@azure/msal-node` (chosen)
- Good, because Microsoft-official, actively maintained, aligned with Entra's quirks and roadmap (B2B, External ID later, OBO).
- Good, because handles refresh-token rotation, multi-tenant authority routing, and JWKS caching out of the box.
- Bad, because Microsoft-specific — couples the BFF to Entra at the protocol-handler layer.
#### `openid-client`
- Good, because a generic, RFC-faithful OIDC client; would work with any IdP.
- Bad, because lacks Microsoft-specific helpers, especially the OBO flow we'll need in the downstream-API ADR.
- Bad, because we'd be writing the Microsoft-specific glue ourselves — bricolage on a security-critical surface.
#### `passport-azure-ad`
- Bad, because deprecated by Microsoft; no new features, only critical fixes. Rejected.
### OAuth flow
#### Authorization Code with PKCE (chosen)
- Good, because IETF BCP for browser-based apps, recommended by Microsoft for confidential clients on the BFF.
- Good, because PKCE adds a binding between the auth-request and the code-exchange that defeats code-interception attacks.
#### Implicit Flow
- Bad, because deprecated; tokens were returned in the URL fragment, exposed to browser history and the SPA. Rejected.
#### Hybrid Flow
- Bad, because legacy; combines Code and Implicit. Rejected.
#### Resource Owner Password Credentials
- Bad, because the BFF would handle the user's credentials directly — forbidden by Microsoft for production. Rejected.
### Cookie shape
#### `__Host-` prefix (chosen)
- Good, because browsers enforce `Secure`, `Path=/`, and absence of `Domain` — no subdomain bleed, no downgrade.
- Bad, because requires HTTPS in dev (mkcert).
#### Without the prefix
- Bad, because subdomain cookies and HTTP-downgrade scenarios become attack surface.
### CSRF
#### Double-submit cookie (chosen)
- Good, because stateless server-side — no token store to maintain.
- Good, because the cookie is `SameSite=Lax`, so it isn't sent on cross-site requests; combined with the header check, an attacker on another origin cannot synthesise a valid request.
#### Synchronizer token
- Good, because slightly stronger isolation (the token is server-only).
- Bad, because requires a server-side store and synchronisation — heavier for marginal gain in our threat model.
#### No protection
- Bad, because cookies are sent on cross-site form submissions; state-changing endpoints would be trivially exploitable. Rejected.
### Logout
#### RP-initiated (chosen)
- Good, because the user's Entra SSO state is cleared too — explicit, no silent re-auth.
#### Local-only
- Bad, because the user remains signed in to Entra; "log out" feels reversible to the attacker who got their hands on the session for a moment, not to the user.
## More Information
- IETF "OAuth 2.0 for Browser-Based Apps" BCP draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
- Microsoft BFF guidance: https://learn.microsoft.com/azure/architecture/patterns/backends-for-frontends
- MSAL Node overview: https://learn.microsoft.com/azure/active-directory/develop/msal-node-overview
- Cookie `__Host-` prefix: https://developer.mozilla.org/docs/Web/HTTP/Cookies#cookie_prefixes
- mkcert: https://github.com/FiloSottile/mkcert
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), and the future ADRs for session storage (Redis), MFA enforcement, downstream On-Behalf-Of flow, and audit trail.