dorfteich/docs/architecture/adr/0021-external-authentication.md
Claude Fable 5 6aac785841
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 3m0s
CI / Auth e2e pack (pull_request) Successful in 8m49s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m28s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m15s
CI / Import/export fidelity gate (push) Successful in 59s
#217: map IdP groups and roles onto the permission model
Declarative instance setting idpMapping.rules turns ID-token claims into
pond roles and the site-admin flag on every OIDC login — configuration,
not code. Mapped grants travel through the SAME GrantsService path as
manual ones (permission cache invalidated, collab access notify fires so
live sessions revalidate — asserted by test), never raw rows.

Ownership makes precedence explicit: role_grants.origin marks mapped
rows, users.is_site_admin_managed marks a mapping-set admin flag. The
mapping only creates and revokes what it owns — manual wins: hand-made
grants and hand-promoted admins are never revoked by a missing claim (a
manual toggle clears the marker and takes ownership). Removal of a claim
revokes the mapped grant and the managed flag on the next login. Every
mapping-driven change is audited with origin idp_mapping.

Failure containment: unknown pond slugs and the last-Pond-Admin
protection log-and-skip — a mapping problem must never become a login
lockout. Tests drive real OIDC logins against the fake IdP with group
claims: grant + working access, revocation incl. notify, manual-wins,
managed site-admin promote/demote/hands-off.

Documented in permissions.md (own section), ADR 0021, data-model.md and
the hardening guide (care rule: same PR).

Refs #217.

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

7.0 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

  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.

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.