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
93 lines
4.5 KiB
Markdown
93 lines
4.5 KiB
Markdown
# 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
|
|
|
|
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:<issuer>"`,
|
|
`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.
|
|
|
|
## 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.
|