Render the OAuth 2.0 Authorization Code + PKCE flow as a Mermaid sequence diagram at the top of the Decision Outcome section. Five participants (user, SPA, BFF, Entra, Redis), thirteen autonumbered steps covering the authorize redirect, the user-side authentication (with the Conditional Access MFA enforcement annotated), the callback, the state verification, the token exchange, the id_token validation pipeline (signature, iss against the tenant allowlist, aud, exp/nbf, amr sanity-check, audience-claim mapping), the encrypted session write to Redis, and the cookie set. Inline rather than in docs/architecture.md per the convention stated at the top of architecture.md: cross-cutting diagrams live in the architecture file, single-decision diagrams live inside the ADR they visualise. ADR-0009 IS the auth flow decision; the sequence diagram belongs here. Cross-references the related ADRs (0010 sessions, 0011 MFA, 0008 audience model) so the diagram reads as the integration point of the security stack rather than as an isolated picture.
17 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) |
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.
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.