## 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
18 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | ||
|---|---|---|---|---|---|
| accepted | 2026-04-29 | R&D Lead |
|
Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern
Context and Problem Statement
ADR-0008 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_sessionwithHttpOnly,Secure,SameSite=Lax. (Chosen.)- Same attributes without the
__Host-prefix — weaker (subdomain bleed, downgrade attacks).
CSRF
- Double-submit cookie pattern. (Chosen.)
__Host-portal_csrfcookie (readable by the SPA) + matchingX-CSRF-Tokenheader 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:
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:
issbelongs to the tenant allowlist (workforce tenants accepted by ADR-0008);audmatches our app'sclient_id;- the audience claim is mapped to our
Audienceenum (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
HttpOnlyblocks 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 ofStrict) 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.tsprovides a singleConfidentialClientApplicationconfigured from env, with PKCE enabled.apps/portal-bff/src/auth/auth.controller.tsexposes the four routes above and no others.AuthGuardis registered globally; routes that must be public are explicitly marked with a@Public()decorator.__Host-portal_sessionand__Host-portal_csrfare 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-
GETrequest. - The Angular HTTP interceptor in
portal-shellinjectsX-CSRF-Tokenon every outgoing request to the BFF. issvalidation 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/logoutinvalidates the session, returns theend_session_endpointURL, 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 ofDomain— 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 (NestJS), ADR-0008 (identity model), and the future ADRs for session storage (Redis), MFA enforcement, downstream On-Behalf-Of flow, and audit trail.