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