> ## Documentation Index
> Fetch the complete documentation index at: https://docs.findmydata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0035 entra oidc signin

# ADR-0035: Entra OIDC production sign-in — real authorization-code flow on the seam

**Status:** Accepted · 2026-07-17 — implemented in 26.7.16.14

## Context

ADR-0004 established the authentication posture: a banner-marked dev persona
picker for development, and an **Entra OIDC seam** as the production path that
returned an explicit `501 not_configured` until implemented. Every release
since has run in dev identity mode, honest at `/api/ready` (`entraSignIn:
"blocked"` when `FMD_AUTH_MODE=entra` but unimplemented). Phase 1 ("make
production real") closes that seam: a customer must be able to sign in real
users against their own Entra tenant.

This is the highest-stakes code path in the platform — a defect here is an
authentication bypass, not a missed file — so it was built to be **verifiable
offline in full** (a self-signing fake IdP over an injected fetch) and put
through adversarial multi-agent review before release.

## Decisions

### 1. Authorization-code flow with PKCE, state, and nonce — no implicit, no fallback

`GET /api/auth/entra/login` mints a flow (`code_verifier`/`code_challenge`
S256, `state`, `nonce`), stores it in a signed `fmd_oidc` cookie, and redirects
to the tenant `authorize` endpoint with scope `openid profile email` and
`prompt=select_account`. `GET /api/auth/entra/callback` verifies the returned
`state` against the cookie, exchanges the code (with the `code_verifier`) for an
ID token, and validates it. There is **no implicit/hybrid flow** (no token in
the URL) and **no silent fallback to dev** — an unconfigured `entra` mode still
returns `501 not_configured`, and a dev-mode deployment returns `404` for these
routes. The flow cookie is `httpOnly`, `SameSite=Lax`, `Secure` by transport,
HMAC-signed with a 10-minute TTL, and deleted on callback (single use).

### 2. ID-token validation is the trust boundary — pinned RS256, full claim set

`verifyIdToken` (in `identity/entra-oidc.ts`) is the whole security boundary and
is written defensively:

* **Algorithm pinned to RS256.** `alg != RS256` or a missing `kid` is rejected
  before anything else (no `alg:none`, no HS256 key-confusion).
* **Signature checked before any claim** via the tenant JWKS
  (`createPublicKey({ key: jwk, format: "jwk" })` + `RSA-SHA256` verify), with a
  one-time JWKS refresh on an unknown `kid` (key rotation) and no infinite
  refetch.
* **Every claim checked:** `iss` equals the discovery issuer (with the
  `{tenantid}` template substituted), `aud` includes our `client_id`, `exp`
  (+60 s skew) and `nbf`, the `nonce` matches the flow cookie, `sub` is present,
  and `tid` equals the configured tenant — a token minted for another tenant is
  refused even if otherwise well-formed.

All HTTP is injectable (`fetchImpl`), so discovery, JWKS, and token exchange are
exercised by a fake IdP that signs real RS256 tokens with a generated keypair —
the entire flow is validated with no network and no live tenant.

### 3. Immutable identity binding — `(issuer, subject)`, never email

An external identity maps to an internal principal by the immutable
`(issuer, subject)` pair (the `principals` unique index over
`(tenant_id, issuer, subject) WHERE issuer IS NOT NULL`), **never** by email or
display name (which are mutable and reassignable). First login binds a
**pre-provisioned** principal by matching the token `oid` against a persisted
`external_id` where `issuer IS NULL`, then stamps `issuer`/`subject`/
`source='entra'` so the binding is fixed thereafter. This is the governance
posture: access is granted **explicitly** by provisioning the person, and the
first real sign-in claims that identity.

### 4. Auto-provision is opt-in and grants nothing

`FMD_ENTRA_AUTO_PROVISION=true` lets an authenticated directory user with no
matching principal be JIT-created — but as a **bare principal with no roles**
(they can sign in and see nothing until an admin grants capability). It is
**off by default**: an unrecognized user is refused with
`identity_not_provisioned`. So even with JIT on, authentication never implies
authorization.

### 5. Pre-auth failures log, they don't audit

The callback's failure path (`fail(code)`) writes a structured
`log.warn("entra_login_failed", { reason, correlationId })` and redirects to
`/?login_error=<code>` — it does **not** call `appendAudit`, because a failed
login has no established tenant context and the audit table's tenant FK must not
be forged with a synthetic tenant. Only a **successful** callback (which has
resolved a real principal) writes an audit record and issues the session cookie
(reusing the existing signed-session infrastructure, source `entra`).

## Consequences

* The production sign-in path is real and self-contained: a customer configures
  four env vars (below), registers the redirect URI, and users sign in against
  their own tenant — no vendor involvement, no content leaving the boundary.
* `/api/ready` now reports `entraSignIn: "live"` only when the app registration
  **and** a resolvable client secret **and** a redirect URI are all present;
  `"blocked"` when `entra` mode is selected but unconfigured; `"disabled"` in
  dev. The honesty contract is unchanged.
* The live end-to-end validation against a real tenant (redirect-URI
  round-trip, real consent) remains an **operator step** — it needs a tenant and
  an app registration this repository does not hold. The code path is complete
  and fully tested offline; §"Operator validation" in the installation guide
  gives the exact steps.

## Configuration

| Env var                       | Required                                  | Purpose                                                                                                |
| ----------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `FMD_AUTH_MODE=entra`         | yes                                       | Selects the production sign-in path.                                                                   |
| `FMD_ENTRA_TENANT_ID`         | yes (falls back to `FMD_GRAPH_TENANT_ID`) | The Entra tenant that issues tokens; also checked as the `tid` claim.                                  |
| `FMD_ENTRA_CLIENT_ID`         | yes                                       | The sign-in app registration's application (client) id.                                                |
| `FMD_ENTRA_CLIENT_SECRET_REF` | yes                                       | **Reference** to the client secret (resolved from the environment, never stored inline, never logged). |
| `FMD_ENTRA_REDIRECT_URI`      | yes                                       | The exact `.../api/auth/entra/callback` URL, also registered on the app.                               |
| `FMD_ENTRA_LOGIN_HOST`        | no                                        | Override the login authority host for a national cloud (e.g. `login.microsoftonline.us`).              |
| `FMD_ENTRA_AUTO_PROVISION`    | no (default off)                          | JIT-create a bare, role-less principal for an unrecognized directory user.                             |

Production additionally requires a non-default `FMD_SESSION_SECRET` (unchanged).

## Verification

`identity/entra-oidc.test.ts` (14 tests, all offline via a self-signing fake
IdP): valid token accepted; tampered signature → `bad_signature`; wrong `aud`,
expired, `nbf` in the future, wrong `nonce`, wrong tenant (`tid`) → refused;
`alg != RS256` and missing `kid` → refused; unknown `kid` →
`unknown_signing_key` after a single JWKS refresh; `authorizeUrl` carries the
S256 `code_challenge`/`state`/`nonce`; flow-cookie sign/verify round-trip,
tamper, and expiry; `resolveEntraPrincipal` (oid binding on first login,
`(issuer, subject)` rebind thereafter, unknown refused, autoProvision JIT →
no roles); `/api/ready` honesty; and a full end-to-end
`login → callback → session` run plus the CSRF mismatched-`state`
(`→ login_error`), `501`-unconfigured, and `404`-dev-mode paths. Full suite 498
green. **Adversarial multi-agent review (3 dimensions × per-finding
verification, security dimension at high effort) returned zero confirmed
findings.**
