dorfteich/docs/architecture/adr/0021-external-authentication.md
Claude Fable 5 4c7f001cab
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#215: trusted reverse-proxy header / mTLS client-certificate path
For perimeters that authenticate before the application (ADR 0021 §4).
Off unless BOTH AUTH_PROXY_HEADER and AUTH_PROXY_TRUSTED_PEERS are set —
nothing about the header is guessed. The peer check runs against the TCP
peer address only (a forwarded header is attacker-influenced): a request
carrying the header from any other peer is rejected outright and audited
as auth.proxy_rejected (catalogue v1.4) — that is a spoof attempt, not a
misconfiguration — even when a valid session cookie rides along. From a
trusted peer the header IS the identity; a session cookie never
escalates beyond it; with the feature off the header is inert.

Mapping is explicit (AUTH_PROXY_MAP: username or e-mail); deliberately
no just-in-time creation — the header carries no verified address. The
mTLS variant (AUTH_PROXY_MODE=mtls-dn) maps the configured attribute
(default CN) out of the certificate subject DN the TLS terminator
forwards, under the same peer rules. Session-less proxy requests key the
read trail per user (user:<id>).

The trust boundary is stated in security.md (the section an assessor
reads closest), the VS-NfD security documentation and the hardening
guide's deploy table. Tests cover all four decisions: off = inert,
trusted peer authenticates (username and DN mapping), untrusted peer
rejected + audited, no escalation past a session cookie.

Refs #215.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:50:09 +02:00

107 lines
5.2 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.
## 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.
## 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.