Files
apf_portal/docs/decisions/0011-mfa-enforcement-entra-conditional-access.md
Julien Gautier 0e58e32d29 chore: relocate ADRs from decisions/ to docs/decisions/ to consolidate documentation
Move the ADR folder under docs/ alongside the rest of the project
documentation. Convention (flat folder, globally-sequential 4-digit
numbering, tags-based categorization, MADR 4.0.0 format) is unchanged
- only the path moved.

- git mv decisions docs/decisions preserves history for all 18 ADRs +
  README + template (19 files renamed in this commit).
- ADR-0001 amended in-place with a dated note documenting the
  relocation. Status remains 'accepted' - the location detail
  changed, the decision did not.
- All cross-references updated:
  - CLAUDE.md (~17 ADR links + 3 mentions of decisions/ in the Project
    rules section)
  - docs/README.md (now references decisions/ as a sibling under docs/)
  - docs/setup/03-angular-nx-monorepo.md (paths shortened from
    ../../decisions/ to ../decisions/, since setup/ and decisions/ are
    now both inside docs/)
  - docs/decisions/0003 ../CLAUDE.md adjusted to ../../CLAUDE.md
    (one extra level of nesting)
  - docs/decisions/template.md mention of the README path
  - notes/asvs-level-decision-briefing-rssi.md mention of the index

Sanity verified: every ADR link in CLAUDE.md, docs/setup/03, and
docs/decisions/0001 resolves to an existing file. pnpm nx run-many
-t lint passes on 8 projects.
2026-04-30 18:57:59 +02:00

157 lines
12 KiB
Markdown

---
status: accepted
date: 2026-04-29
decision-makers: R&D Lead
tags: [security]
---
# MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in
## Context and Problem Statement
Multi-factor authentication is mandatory for the portal's workforce audience. The question is _where_ MFA logic lives, _how_ the BFF gains assurance that an authenticated session has actually been multi-factored, and _how_ the application is prepared for future step-up MFA on sensitive operations — without writing any of it in v1.
Identity is Microsoft Entra ID ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) and the auth flow is OIDC Authorization Code + PKCE via MSAL Node ([ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)). The v1 portal has no admin UI and no defined "sensitive" operations beyond authentication itself, so step-up MFA is a design concern, not a v1 implementation concern.
## Decision Drivers
- MFA is a non-negotiable security baseline for workforce.
- Application code must not implement MFA mechanics — that belongs in the IdP. "Anti-bricolage" applies particularly here.
- The mechanism chosen at sign-in must be auditable in the session (was MFA actually performed?).
- The architecture must support future step-up MFA (a sensitive operation requires _fresh_ MFA, beyond what was done at sign-in) without retrofitting authorization.
- Configuration responsibility must be clearly separated: the IdP (org IT) owns the policy; the BFF owns the verification.
## Considered Options
### Where MFA is enforced
- **Microsoft Entra Conditional Access policy at the tenant level.** (Chosen.)
- MFA logic in the BFF (e.g., a second-factor prompt after password).
### How the BFF validates that MFA happened
- **Sanity-check the `amr` claim of the id_token at session creation.** (Chosen.)
- Require an explicit Authentication Context Class Reference (ACR) value via `acr_values`/`claims` request parameter.
- Trust Entra unconditionally without verification.
### Step-up MFA in v1
- **Designed-in via a `@RequireMfa()` decorator and a claims-challenge mechanism, but not applied to any v1 route.** (Chosen.)
- Built and exercised in v1 — premature, no consumer.
- Deferred entirely (no decorator, no hook) — costly to retrofit.
## Decision Outcome
**MFA enforcement** lives in **Microsoft Entra Conditional Access**. The org's IT/identity contact configures a Conditional Access policy at the tenant level that requires MFA for any sign-in to the BFF's app registration, for both the prod tenant and the M365 Developer tenant used for non-prod. Recommended settings (org-side, not in our code):
- _Assignment._ Cloud apps → the BFF app. Users → All users (or "All workforce", depending on how the org structures groups). Excluded: break-glass accounts, by Microsoft's own recommendation.
- _Conditions_ (recommended, not strictly required): device compliance, location, sign-in risk (Entra ID P2 for risk-based — opt-in).
- _Grant._ Require multi-factor authentication. Prefer phishing-resistant methods (FIDO2 / passkeys / Windows Hello for Business). Fall back to authenticator-app push or TOTP. SMS/voice deprecated, to be avoided.
This policy is **out of scope for application code**. Our app does not implement MFA — it consumes the MFA outcome.
**BFF verification.** At session creation (after the OIDC callback exchange and id_token validation), the BFF performs a sanity-check on the `amr` claim of the id_token. The session is rejected with a 401 and an explicit error if the claim is absent, empty, or contains only password-class values without an MFA-class indicator. The expected normal value emitted by Entra after a CA-enforced MFA sign-in is `["pwd","mfa"]` (or equivalent depending on the factor).
This is _defense in depth_, not the primary control: the primary control is the CA policy. The sanity-check exists so that a misconfigured or disabled CA policy does not silently regress the security posture without anyone noticing — the BFF will simply refuse to grant sessions if MFA assurance is missing.
A small validation table maintained in the BFF (in source, reviewed in PRs) lists the `amr` values accepted as multi-factor:
```ts
// Values Entra emits in 'amr' that we treat as evidence of MFA having occurred.
// Not exhaustive — extended on review when Entra emits new values.
const MFA_AMR_VALUES = ['mfa', 'otp', 'fido', 'wia', 'phr'] as const;
```
This list is reviewed against Microsoft's documentation as part of the security review cadence.
**Step-up MFA — designed-in, dormant.** A `@RequireMfa()` controller-method decorator and an associated guard are implemented in v1, but no v1 route uses them. The guard, when active, will:
1. Inspect the session for a recent MFA assertion (a `mfaVerifiedAt` timestamp added to the session payload, set to `lastSeenAt` of the auth callback).
2. If the timestamp is older than a configurable freshness window (`MFA_FRESHNESS_SECONDS`, default 600 = 10 min), respond with 401 + a `WWW-Authenticate` header carrying a _claims challenge_ (an opaque blob produced by MSAL Node).
3. The Angular SPA HTTP interceptor catches the 401 + claims challenge, redirects the browser to `/auth/login?claims=<challenge>`, which forwards the challenge to Entra. Entra re-prompts MFA, returns a fresher `amr`/`mfaVerifiedAt`, and the original request is retried.
Authentication Context Classes (`acr_values`) are _not_ used in v1. The freshness-timestamp approach is sufficient for the baseline. ACR-based step-up (e.g., "this action requires CA policy `c2`") will be considered later, gated behind a follow-up ADR if specific operations demand it.
**Service accounts / app-only tokens.** Out of scope. The BFF authenticates end-users via OIDC; service-to-service tokens (if needed for downstream APIs in a future ADR) follow a different flow — On-Behalf-Of or app-only — and have their own MFA story (i.e., none, because no human is involved).
### Consequences
- Good, because MFA mechanics live where they belong (the IdP). The application is not in the business of implementing OTP delivery, push notifications, or hardware-key challenges.
- Good, because the IdP configuration can evolve independently — adding device compliance, sign-in risk, location-based rules, or moving to phishing-resistant-only — without code changes.
- Good, because the `amr` sanity-check in the BFF catches a category of misconfiguration that would otherwise silently regress security.
- Good, because step-up MFA is designed-in via a single decorator. When a sensitive operation appears in a future feature, marking it requires only `@RequireMfa()` — no architectural change.
- Good, because separating freshness-based step-up (default) from ACR-based step-up (advanced) keeps v1 simple while leaving a clean upgrade path.
- Bad, because the `amr` value list is implicitly tied to Entra's emitted values; if Microsoft introduces a new factor with a new `amr` token, the BFF rejects sessions until the list is updated. Mitigated by a documented review cadence.
- Bad, because step-up MFA needs SPA cooperation (the HTTP interceptor that handles 401 + claims challenge); if a future SPA feature bypasses the interceptor, step-up will silently degrade to plain 401. Mitigated by integration tests on the interceptor.
- Bad, because Conditional Access policies — especially advanced ones — require Entra ID P1 (and P2 for risk-based). Already flagged in [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), restated here.
- Neutral, because v1 does not exercise the step-up path. Hooks must be kept alive by automated tests, not by being used in production traffic.
### Confirmation
- `apps/portal-bff/src/auth/mfa.ts` exports the `MFA_AMR_VALUES` constant and a `wasMultiFactor(claims): boolean` helper.
- The OIDC callback path rejects sessions for which `wasMultiFactor(idTokenClaims)` is false, with a structured 401 (logged as an audit event in the future audit-log ADR).
- Each session payload carries `mfaVerifiedAt: number` (epoch ms), set at the callback and refreshed on any subsequent step-up auth completion.
- `apps/portal-bff/src/auth/require-mfa.guard.ts` provides the `RequireMfaGuard` and the `@RequireMfa()` decorator (on top of the global `AuthGuard` from ADR-0009).
- `MFA_FRESHNESS_SECONDS` is read from env, defaulting to 600. The BFF refuses to start with a value below 60 (to catch misconfiguration).
- The Angular HTTP interceptor in `portal-shell` handles `401` + `WWW-Authenticate: Bearer error="insufficient_user_authentication"` (or the claims-challenge equivalent) and redirects to `/auth/login` with the challenge propagated.
- Integration tests cover: a sign-in without MFA assertion is rejected; a sign-in with valid MFA assertion succeeds; a route protected by `@RequireMfa()` accepts a fresh session, rejects with claims challenge after `MFA_FRESHNESS_SECONDS`, and accepts again after re-auth.
- The org-side Conditional Access policy is documented in an operational runbook (out of repo, owned by IT) and reviewed during onboarding of each tenant (prod + dev).
## Pros and Cons of the Options
### Where MFA is enforced
#### Conditional Access (chosen)
- Good, because it is the Microsoft-native, audited, IdP-managed mechanism — exactly the kind of "recognised, battle-tested" choice the project values.
- Good, because changes to MFA policy are a tenant-level config, not a deployment.
- Good, because the IdP catalogue of factors evolves without us — passkeys, FIDO2, certificate-based, etc.
- Bad, because it requires Entra ID P1 minimum at the tenant level (cost flagged in ADR-0008).
#### MFA logic in the BFF
- Bad, because it would re-implement OTP/push/key flows the IdP already provides — exactly the bricolage we are forbidden.
- Bad, because secret material (TOTP shared secrets, etc.) ends up in our Postgres, expanding the attack surface.
### How the BFF validates MFA
#### `amr` sanity-check (chosen)
- Good, because cheap, immediate, no extra round trip.
- Good, because catches a real failure mode (CA policy disabled, scope misconfigured).
- Bad, because dependent on Entra's `amr` emission values — list maintenance required.
#### Required ACR via `claims` parameter
- Good, because explicit and machine-verifiable per request.
- Bad, because requires Authentication Context Classes to be configured tenant-side and exchanged on every request — heavy for a baseline "MFA at sign-in" need. Re-evaluate when fine-grained step-up arrives.
#### No verification
- Bad, because a CA policy disabled by mistake would silently regress security with no audit signal until an incident.
### Step-up MFA in v1
#### Designed-in via decorator, dormant (chosen)
- Good, because the cost is minimal: one guard, one decorator, one timestamp in the session.
- Good, because step-up is a one-line annotation on any future sensitive route.
- Bad, because a dormant code path drifts unless covered by tests — addressed in the Confirmation section.
#### Built and exercised in v1
- Bad, because no v1 route is sensitive enough to require it. Building a feature with no consumer is bricolage by another name.
#### Deferred entirely
- Bad, because retrofitting step-up after the fact means refactoring guards, sessions, and the SPA interceptor. Cost asymmetric vs. the dormant-hook option.
## More Information
- Microsoft Entra Conditional Access overview: https://learn.microsoft.com/entra/identity/conditional-access/overview
- `amr` claim values (OpenID Connect): https://www.iana.org/assignments/authentication-method-reference-values/
- Microsoft `amr` documentation: https://learn.microsoft.com/azure/active-directory/develop/access-tokens
- Authentication Context Classes (ACR) and claims challenges: https://learn.microsoft.com/entra/identity-platform/v2-conditional-access-dev-guide
- Phishing-resistant MFA (passkeys / FIDO2): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-passwordless
- Related ADRs: [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model — P1 licensing flagged), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (session — `mfaVerifiedAt` lives here), and the future ADRs for downstream APIs (On-Behalf-Of), audit trail (rejected-MFA logged events), and the operational runbook for tenant Conditional Access policies.