# 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. ## 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.