> ## 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 0020 production idp clerk

# ADR-0020: Production identity for the Account portal (Clerk)

**Status:** Accepted · 2026-07-15

## Context

ADR-0017 shipped the Account portal with a `dev` (email-only) auth mode and an
`idp` mode that returned 501 pending an external identity provider — production
was blocked on a real IdP. This ADR implements that IdP with **Clerk**.

## Decisions

### 1. Clerk, verified locally against its public JWKS — no secret key

The portal accepts a **Clerk session token** (a short-lived RS256 JWT the Clerk
frontend mints) and verifies it locally (`account/idp-clerk.ts`): RS256 signature
by `kid` against Clerk's public JWKS, then `iss` = configured issuer, `exp`/`nbf`
temporal validity, and `azp` ∈ configured authorized parties. Only Clerk's
**public** keys are used — no Clerk secret key is held, logged, or committed, so
there is no high-value credential in the vendor plane. The JWKS is fetched +
cached (10 min); a fetch failure fails closed (`jwks_unavailable`).

Why Clerk over the alternatives, for this portal specifically:

* **Clerk** — best developer experience for a modern web portal, drop-in hosted
  sign-in, MFA/passkeys/social + SAML enterprise connections, generous free
  tier. Chosen (the operator already uses it).
* **WorkOS** — strongest if the priority is reselling enterprise SSO/SAML/SCIM;
  more B2B-plumbing than end-user auth UI.
* **Microsoft Entra External ID** — natural adjacency (Find My Data customers are
  M365/Entra shops), but heavier to stand up for the commercial portal.
* **Auth0** — mature but pricier at scale.
  Clerk covers the enterprise-SSO case too (SAML connections), so it doesn't
  foreclose the Entra/WorkOS path later.

### 2. JIT provisioning by verified email; our own session after

On a verified token the portal **just-in-time provisions** the `portal_user` by
email (reuse if present, else create) and then issues its **own** opaque
server-side session (the same session model as dev mode) — so the rest of the
portal (CSRF, membership guards, org views) is unchanged and IdP-agnostic. The
email must be present + well-formed in the token; the operator adds
`"email": "{{user.primary_email_address}}"` to Clerk's session-token
customization (a public setting). A token without a verifiable email is rejected
— we never guess an identity from `sub` alone.

### 3. Fail-safe mode selection + config guards

`idp` mode requires `FMD_CLERK_ISSUER` (https) + `FMD_CLERK_PUBLISHABLE_KEY`
(both public); the JWKS URL derives from the issuer, and authorized parties parse
from a comma list. Production still **refuses** `dev` auth mode. In `idp` mode the
dev self-service `register` + email `session` routes return 501, and
`/session/idp` is the only sign-in path; in `dev` mode `/session/idp` returns
501\. The publishable key (public) is exposed via `/health` so the UI can
initialize Clerk.

### 4. UI loads Clerk only in idp mode

The portal UI reads `authMode` from `/health`; in `idp` mode it loads Clerk.js
(from the Clerk CDN derived from the publishable key), presents "Sign in with your
organization," and on success exchanges the Clerk session token at
`/session/idp`. In `dev` mode it shows the email forms and loads no external
script. (A production deployment behind a CSP must allow the Clerk frontend
origin.)

## Consequences

* Production sign-in for the Account portal is implemented and testable; the
  operator supplies the public Clerk config to activate it.
* The verifier is the whole trust boundary — hence it pins RS256 (rejects
  `alg:none`/HS256), checks the signature before any claim, and is covered by
  forgery/tamper/expiry/azp/alg tests + an adversarial review.
* Out of scope (future): Clerk **Organizations** → portal org mapping (today an
  IdP user JIT-creates a personal portal user who then creates/joins orgs via the
  existing flow), SCIM deprovisioning, and step-up auth for sensitive actions.

## Verification

`account/idp-clerk.test.ts` (12) — valid-token accept + email normalization;
wrong-issuer / expired / not-yet-valid; unauthorized `azp`; missing email;
cross-key forgery + unknown `kid`; tampered payload; `alg:none` rejected; JWKS
fetch failure fails closed; the `/session/idp` endpoint (JIT provisioning + real
session, 401 on bad token, 501 outside idp mode, health advertises the public
key); and the config guards. All 290 server tests pass; typecheck + lint clean;
adversarially reviewed.
