Commit graph

10 commits

Author SHA1 Message Date
holden093
8375322d70 fix(oidc): serialize auth across workers, guard UserInfo demotion, atomic key creation
- Add fcntl.flock inter-process file lock shared by setup() and
  create_user_oidc() so multi-worker first-admin bootstrap is
  serialised across processes, not just threads within one worker.
  Both methods reload auth.json inside the lock so the loser sees
  the winner's write.

- _fetch_userinfo() now returns None (not {}) when discovery has
  no userinfo_endpoint, and exchange_code() only sets
  _userinfo_available=True when a live endpoint was reached.
  Prevents the callback from treating 'no endpoint' as
  authoritative group non-membership evidence.

- Rewrite _load_or_create_key() to write the Fernet key to a temp
  file, fsync, then atomically os.link() into place.  No reader
  ever sees the final path before the complete key bytes are
  available — a racing worker either sees no file or a complete
  one, never an empty/partial file.

105 tests pass (97 existing + 8 new regressions covering the
three fixes).

Co-Authored-By: Kevin <holden093@users.noreply.github.com>
2026-07-25 18:32:56 +02:00
holden093
739eb0faf1 fix(oidc): serialize admin bootstrap, atomic key creation, guard against silent demotion
Three fixes from second review pass:

1. (security) Serialize the first-OIDC-user admin bootstrap inside
   _config_lock.  Previously the  check and username
   collision resolution happened outside the lock, so two concurrent
   first-login callbacks could both observe an empty user map and
   both persist as admin.  Now idempotent lookup, bootstrap decision,
   and collision resolution are all inside one critical section.

2. (auth) Make data/.app_key creation atomic via O_EXCL open so two
   racing workers on a fresh deployment cannot generate different
   keys.  The loser reads the winner's key, guaranteeing every worker
   shares the same Fernet key for OIDC state encryption.

3. (auth) Track whether UserInfo was successfully fetched
   (_userinfo_available flag in claims).  The callback now skips
   admin group sync for existing users when UserInfo is unavailable
   AND the id_token lacks a groups claim — a transient provider
   failure no longer silently demotes existing OIDC admins.  When
   UserInfo succeeds or the id_token carries groups, admin status
   syncs as before.

Regression tests: concurrent admin bootstrap, key-creation race,
UserInfo-unavailable preserves admin, UserInfo-available demotes,
id_token groups authoritative without UserInfo.
2026-07-25 18:32:56 +02:00
holden093
9215195631 fix(oidc): throttle failed JWKS refreshes and persist state key across workers
Two fixes from review feedback:

1. (security/availability) Record _last_jwks_refresh timestamp BEFORE
   calling _refresh_jwks() so a failed JWKS fetch is also throttled
   by the 60-second cooldown.  Previously the timestamp was only set
   after success, so an outage or key rotation would retry the IdP
   on every login attempt.

2. (auth) Use the shared persistent app key from secret_storage
   (_get_fernet) for OIDC state encryption instead of reading
   data/.app_key directly and falling back to a per-process key.
   This guarantees all uvicorn workers share the same Fernet key,
   even on a fresh data directory — worker A's state is always
   decryptable by worker B on the callback.

Add regression tests for both fixes.
2026-07-25 18:32:56 +02:00
holden093
d303739228 fix(oidc): wrap JWKS errors as OidcError, bind token exchange to stored redirect_uri
1. JWKS fetch/parse errors now wrapped as OidcError instead of escaping
   as raw exceptions. Transient network failures, HTTP errors, and bad
   JSON responses from the JWKS endpoint all produce a controlled
   OidcError, which the callback route already redirects to
   /login?error=oidc_failed instead of a 500.

2. exchange_code() now extracts the stored redirect_uri from the
   Fernet-encrypted state token and rejects any callback-derived
   redirect_uri that differs. The token exchange POSTs the stored
   value — the one the IdP saw in the authorization request — removing
   the last callback dependence on request-derived URI behaviour.

Tests: 5 new (3 JWKS error wrapping + 2 redirect_uri binding)
Total: 130 passing (81 OIDC + 49 regression), 0 failures.
2026-07-25 18:32:56 +02:00
holden093
c87bcfb8cb fix(oidc): address remaining review items — issuer fail-closed, multi-audience azp, OIDC 2FA guards
1. Discovery issuer mismatch now raises OidcError instead of logging
   a warning (OIDC Discovery §1.1 requires mismatch abort).

2. Multi-audience ID tokens without azp are now rejected (OIDC Core
   §2 requires azp when aud has multiple values).

3. /change-password, /2fa/setup, /2fa/confirm, and /2fa/disable now
   reject OIDC users with a clear message. The frontend already hides
   these cards, but the backend must also enforce the policy.

113 passing (76 OIDC + 37 regression), 0 failures.
2026-07-25 18:32:56 +02:00
holden093
7c3c4db8db fix(oidc): address RaresKeY review — bootstrap gating and UserInfo sub protection
1. Suppress first-user bootstrap admin when OIDC_ADMIN_GROUPS is
   configured — a non-admin IdP user must not get admin just by being
   the first to log in. Group membership is the only path when groups
   are set.
2. Reject mismatched UserInfo sub: the UserInfo endpoint MUST NOT
   overwrite the verified id_token subject. Also guard all verified
   identity claims (sub, iss, aud, exp, iat, nonce, azp) from being
   overwritten by UserInfo.
2026-07-25 18:32:56 +02:00
holden093
879a1fefbb Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-25 18:32:56 +02:00
holden093
873fd1997d fix(oidc): address second-pass review — CSRF cookie, admin demotion, JWKS cooldown, secure cookies, dead code
1. Fix bootstrap admin demotion: skip set_oidc_user_admin when
   OIDC_ADMIN_GROUPS is unset so bootstrap/manual admin survives
   subsequent logins.
2. Login CSRF: bind state to an HttpOnly cookie set at /login and
   verified with constant-time compare at /callback.
3. JWKS cooldown: throttle refresh to once per 60s to prevent
   attacker-triggered unbounded IdP fetches via random kid values.
4. Secure cookies: derive secure flag from request scheme when
   SECURE_COOKIES is not explicitly set; OIDC session defaults
   to secure on HTTPS connections.
5. Remove dead validation-shaped code: the jwt.decode block with
   None key that swallowed all exceptions and discarded output.
2026-07-25 18:32:56 +02:00
holden093
f75e4bd68d fix(oidc): address review items — aud arrays, JWKS cache, alg pinning, redirect_uri, stateless state, first-user-admin
1. aud validation: handle JSON array audiences per OIDC spec (check
   membership, validate azp for multi-audience tokens).
2. JWKS caching: cache keys after first fetch; refresh only on unknown
   kid — avoids live IdP round-trip on every login.
3. Algorithm pinning: restrict to RS256/ES256/etc from discovery doc,
   never allow HS256 or 'none'.
4. OIDC_REDIRECT_URI: support a fixed redirect URI (proxy-safe,
   doesn't trust Host header).
5. Stateless state: replace in-memory dict with Fernet-encrypted state
   tokens — works across multiple uvicorn workers/processes.
6. OIDC_FIRST_USER_IS_ADMIN: bootstrap first OIDC user as admin when
   no users exist and OIDC_ADMIN_GROUPS is unset — prevents zero-admin
   lockout.
2026-07-25 18:32:56 +02:00
holden093
84cb22039b feat(auth): add generic OpenID Connect (OIDC) single sign-on
Adds OIDC authentication as an alternative to password login, enabling
sign-in via any standard provider (Authentik, Keycloak, Authelia, etc.).

New features:
- Generic OIDC provider support via .well-known discovery (authlib)
- Coexists with existing password auth — users choose at login
- Auto-creates local users on first OIDC login
- Admin group mapping: OIDC_ADMIN_GROUPS grants admin based on IdP groups
- Admin status syncs on every login (follows IdP membership)
- OIDC users cannot use password login or set up 2FA
- UI hides change-password and 2FA cards for OIDC users

New env vars:
- OIDC_ENABLED, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
- OIDC_SCOPES, OIDC_ADMIN_GROUPS

New files:
- core/oidc.py — OidcManager (discovery, auth URL, code exchange,
  id_token verification with JWT/JWKS)
- routes/oidc_routes.py — /api/auth/oidc/{login,callback,config}

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:32:56 +02:00