# ADR 0021: External authentication via OIDC; local passwords optional - Status: proposed - Date: 2026-07-29 ## Context ADR 0007 established sessions and identities with OIDC in mind: `UserIdentity.provider` is documented as `"password"` today and `"oidc:"` later, with `@@unique([provider, subject])` already in place. No OIDC code exists — the readiness is structural only. Authentication is a security base function under §52 VSA (ADR 0019), so it belongs to the operator's platform. An authority environment additionally brings its own account lifecycle: joiners, movers and leavers are managed in the IdP, and a second account store inside the application would drift from it. Some environments terminate authentication at the perimeter instead and expect the application to trust a header or a client certificate. ## Decision 1. **OIDC Authorization Code with PKCE is the primary path**, configured by discovery, validated against JWKS. Keycloak is the reference IdP we verify against; nothing in the implementation is Keycloak-specific. 2. **Identities use the existing slot**: `provider = "oidc:"`, `subject` from the token. Linking an OIDC identity to an existing local user follows an explicit, documented rule — never silently by e-mail address, which would be an account-takeover path. 3. **Local authentication is switchable off in full**, via `auth.local.enabled = false`. "In full" means every credential-issuing flow: password login, self-service signup, password reset, verification-as-login, and the token flows (PAT, feed tokens). A half-closed local path makes the operating concept untrue, which is worse than not closing it. 4. **Proxy header and mTLS are a supported alternative path, off by default.** When enabled they require an allowlist of trusted peers; a request carrying the header from an untrusted peer is rejected and audited. The trust boundary is stated explicitly in the security documentation. 5. **Claims map onto the existing permission model** declaratively, and mapped grants are written through the same service path as manual ones so the permission cache stays correct. The application gains no second authorization model. 6. **No MFA, no password policy engine of our own** (ADR 0019). Both are the IdP's. ## Decisions taken in #214 - **Implementation on `jose` + `fetch`** — the vetted JWT library from #188 plus the platform HTTP client. `openid-client` was rejected: no new dependency enters the supply chain for a security base function, and the code path (discovery, authorize URL, code exchange, JWKS validation) is small enough to own. - **ID-token algorithms**: explicit `RS256`/`ES256` allowlist; HS* and `none` can never verify. - **Client authentication**: `client_secret_post` when a secret is configured; a public client runs on PKCE alone (PKCE is always sent). - **No IdP-initiated single logout**: sessions are short-bounded (#190), the leaver case is covered by claim-mapping revocation (#217) and the disable flag. Front-channel logout would add an unauthenticated, spoofable endpoint for marginal gain. - **JIT accounts** arrive ACTIVE and mail-verified, but only when the IdP asserts a verified address (`email_verified` must not be false; missing e-mail refuses the login). The linking refusal (`oidc_link_required`) plus the explicit `GET /auth/oidc/link` flow (audited `auth.identity_linked`) is the documented linking rule. ## Decisions taken in #215 - **Peer check against the TCP peer address only** — a forwarded `X-Forwarded-For` is attacker-influenced and never consulted. - **Untrusted peer + header ⇒ reject the whole request (403) and audit** (`auth.proxy_rejected`), even when a valid session cookie rides along: a poisoned request is rejected, not partially trusted. - **Trusted peer + header ⇒ the header is the identity**; a session cookie never escalates beyond it. No just-in-time creation — the header carries no verified e-mail, so accounts come from OIDC or an admin. - **mTLS is proxy-terminated**: the application never touches TLS; the terminator forwards the certificate subject DN and the configured attribute (default CN) is the identity, under the same peer rules. ## Decisions taken in #216 - **Deploy-level, not runtime**: the switch is the environment variable `AUTH_LOCAL_ENABLED` (default true). A compromised Site Admin cannot reopen the local path — the runtime-flip residual risk from the consequences below therefore does NOT materialize (R-02 closed). - **404 semantics** on every marked flow (login, signup, verification, resend, password forgot/reset/change), enforced centrally in the auth guard via the `@LocalCredentialFlow()` marker; an enumeration fence fails when an auth route is neither marked nor on the reviewed allowlist, so a new credential flow cannot ship unswitched. - **Bootstrap**: complete the first-run setup (or the `SETUP_ADMIN_*` pre-seed, which does not run through HTTP routes) BEFORE flipping to false — the wizard needs no permanent exemption. The api warns at boot when local auth is off and neither OIDC nor proxy auth is configured. - **PAT and feed-token issuance stay available** to (IdP-)authenticated sessions: they authorize API access under their own switches (`api.enabled`, `feeds.enabled`), they are not interactive sign-in. ## Decisions taken in #217 - **Reconciliation at login**, not by background sync: fresh claims exist only there; between logins the leaver case belongs to the IdP (no new login) and the operator's disable flag. - **Ownership via `role_grants.origin` and `users.is_site_admin_managed`** — the mapping creates, updates and revokes only what it owns; manual grants and hand-promoted admins always win. A colliding manual grant is left in place rather than adopted. - **Failure containment**: unknown pond slugs and the last-Pond-Admin protection log-and-skip — a mapping problem must never lock users out. - Full semantics: `docs/architecture/permissions.md` §IdP claim mapping. ## Consequences - Bootstrapping needs a documented answer: the first-run wizard creates a local admin, so either it stays exempt with a stated compensating control, or setup itself runs against the IdP. The choice is recorded in #216. - Whether the switch is deploy-level or runtime matters: a runtime setting can be flipped back by a compromised Site-Admin. If it stays runtime, that residual risk goes into #231. - Existing password hashes remain in the database after the switch. Their deletion is out of scope and, being Argon2id, they are not a confidentiality problem — but the fact is documented. - Session handling is unchanged: OIDC produces a session through the same service, so there is exactly one session mechanism (see #190 for its bounds). - SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we target; adding SAML would be a new decision. ## Implementing issues #214 (OIDC + PKCE), #215 (proxy header / mTLS), #216 (`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the vetted JWT implementation.