1. Serialize set_oidc_user_admin() across workers via
_interprocess_auth_lock() with reload-recheck-save, preventing
stale in-memory snapshots from overwriting users concurrently
created by another worker.
2. Accept single-element aud arrays without azp (OIDC Core §2 only
requires azp for multi-audience tokens). Normalize aud to a list
and only enforce azp when len > 1.
3. Add threading.Lock around _get_fernet() key creation to prevent
two threads in the same process from racing on the shared temp-file
path and destroying each other's work.
4. Guard import fcntl with try/except so AuthManager imports on native
Windows. _interprocess_auth_lock degrades to intra-process-only
when fcntl is unavailable.
Tests: +4 regression tests (108 total, 0 failures).
- 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. 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>