External authentication (ADR 0021) built on jose (#188's vetted library) plus fetch — no new dependency enters the supply chain for a security base function. Discovery-configured; ID tokens validate against the IdP's JWKS under an explicit RS256/ES256 allowlist with issuer, audience, expiry and nonce binding. State, nonce and the PKCE verifier travel in a signed HttpOnly Lax cookie keyed by a dedicated HKDF purpose (oidc-state, ADR 0020). Deploy-level configuration (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES/ PROVIDER_LABEL): who authenticates users is a platform decision. The login page discovers the provider via GET /auth/methods and renders the SSO button (i18n de+en). Identities use the existing slot (provider oidc:<issuer>, subject from the token). First login creates the account just-in-time — ACTIVE and mail-verified only when the IdP asserts a verified address. An existing local account is NEVER adopted silently by e-mail (account-takeover path): login refuses with oidc_link_required and the owner links explicitly via GET /auth/oidc/link (audited auth.identity_linked, catalogue v1.3). Sessions come from the one existing session service. Tests run the full flow against a protocol-faithful fake IdP: PKCE verifier at the token endpoint, JIT creation incl. personal pond, invalid state/nonce/signature/issuer/audience/expiry each rejected, the linking refusal and the explicit link flow. Verified end-to-end against a real Keycloak 26.0 (repeatable procedure documented in security.md §External authentication). Refs #214. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
4.5 KiB
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:<issuer>" 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
- 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.
- Identities use the existing slot:
provider = "oidc:<issuer>",subjectfrom 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. - 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. - 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.
- 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.
- 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-clientwas 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/ES256allowlist; HS* andnonecan never verify. - Client authentication:
client_secret_postwhen 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_verifiedmust not be false; missing e-mail refuses the login). The linking refusal (oidc_link_required) plus the explicitGET /auth/oidc/linkflow (auditedauth.identity_linked) is the documented linking rule.
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.