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>
This commit is contained in:
holden093 2026-06-26 20:56:11 +02:00
parent 739eb0faf1
commit 8375322d70
6 changed files with 373 additions and 21 deletions

View file

@ -4,12 +4,14 @@ Config stored in data/auth.json. Uses bcrypt directly.
"""
import enum
import fcntl
import json
import os
import secrets
import threading
import time
import logging
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Dict, Any, List
@ -112,6 +114,10 @@ class AuthManager:
# Guards the first-run setup check-and-write so concurrent requests
# cannot both observe is_configured==False and both create admin accounts.
self._setup_lock = threading.Lock()
# Path for the inter-process file lock (fcntl.flock). Shared across
# all uvicorn workers so first-admin bootstrap and auth.json mutations
# are serialised across processes, not just threads within one worker.
self._ipc_lock_path = auth_path + ".lock"
self._load()
self._load_sessions()
self._migrate_single_user()
@ -259,9 +265,28 @@ class AuthManager:
# Account management
# ------------------------------------------------------------------
@contextmanager
def _interprocess_auth_lock(self):
"""Acquire an exclusive inter-process file lock on auth.json.
Uses fcntl.flock so the kernel releases the lock automatically
when the process exits a crash cannot leave a stale lock.
"""
# Open in read-write mode; create the lock file if it doesn't exist.
fd = os.open(self._ipc_lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def setup(self, username: str, password: str) -> bool:
"""First-run admin setup. Only works if no users exist."""
with self._setup_lock:
with self._interprocess_auth_lock(), self._setup_lock:
# Reload from disk so we see what another worker may have
# written since our last _load().
self._load()
if self.is_configured:
return False
return self.create_user(username, password, is_admin=True)
@ -321,7 +346,11 @@ class AuthManager:
logger.warning("Refused OIDC user with reserved username '%s'", username)
return None
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
# Reload from disk so we see what another process (or the
# local-setup path) may have written since our last _load().
self._load()
if "users" not in self._config:
self._config["users"] = {}
users = self._config["users"]
@ -336,8 +365,9 @@ class AuthManager:
# Bootstrap: if no users exist yet, OIDC_ADMIN_GROUPS is
# unset, and OIDC_FIRST_USER_IS_ADMIN isn't explicitly false,
# make the first OIDC user an admin. The check is inside the
# lock so two concurrent first-login callbacks cannot both
# observe an empty user map and both persist as admin.
# inter-process + process-local locks so two workers (or a
# concurrent local setup) cannot both observe an empty user
# map and both persist as admin.
if not is_admin:
first_user_admin = os.getenv("OIDC_FIRST_USER_IS_ADMIN", "true").lower() != "false"
oidc_admin_groups = os.getenv("OIDC_ADMIN_GROUPS", "").strip()

View file

@ -277,7 +277,14 @@ class OidcManager:
if access_token:
try:
userinfo = self._fetch_userinfo(access_token)
userinfo_available = True
# _fetch_userinfo returns None when discovery has no
# userinfo_endpoint (rather than an empty dict, which
# would be ambiguous). Only mark userinfo_available
# when we actually made a request to a live endpoint.
if userinfo is not None:
userinfo_available = True
else:
userinfo = {}
# Reject mismatched sub — the subject in UserInfo must match
# the already-verified id_token subject.
ui_sub = userinfo.get("sub")
@ -485,11 +492,17 @@ class OidcManager:
pass
return {}
def _fetch_userinfo(self, access_token: str) -> Dict[str, Any]:
"""Fetch claims from the UserInfo endpoint (if available)."""
def _fetch_userinfo(self, access_token: str) -> Optional[Dict[str, Any]]:
"""Fetch claims from the UserInfo endpoint.
Returns None when discovery has no userinfo_endpoint so the
caller can distinguish "no endpoint configured" from "endpoint
returned an empty profile". An empty dict means the endpoint
was reached but returned no claims.
"""
userinfo_endpoint = self._config.get("userinfo_endpoint")
if not userinfo_endpoint:
return {}
return None
resp = httpx.get(
userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},

View file

@ -39,22 +39,41 @@ def _load_or_create_key() -> bytes:
return _KEY_PATH.read_bytes()
# Slow path: create the key atomically. On a fresh multi-worker
# deployment two workers may race here — the O_EXCL open guarantees
# exactly one writer wins; losers read the winner's key so every
# process ends up with the same bytes.
# deployment two workers may race here. We write the complete key
# to a temp file, fsync it, then use os.link to make it visible at
# the final path in a single atomic step. If os.link fails because
# another worker already created the final file, we read the
# winner's key. This avoids the O_CREAT-then-write window where a
# racing reader can see an empty or partial key file.
key = Fernet.generate_key()
_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp_path = _KEY_PATH.parent / f".app_key.tmp.{os.getpid()}"
try:
fd = os.open(_KEY_PATH, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
# Another process created the file between our exists() check
# and the open — read the winner's key instead.
logger.info("App key already created by another worker — reusing")
return _KEY_PATH.read_bytes()
with os.fdopen(fd, "wb") as f:
f.write(key)
logger.info(f"Generated new app key at {_KEY_PATH}")
return key
# Use os.open + os.fdopen so we can set 0o600 at creation time
# (plain open() applies umask, typically giving 0o644).
tmp_fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(tmp_fd, "wb") as f:
f.write(key)
f.flush()
os.fsync(f.fileno())
# Atomic: either the link succeeds and the complete key is
# visible, or FileExistsError means another worker won the
# race and we read its (complete) key. No reader ever sees
# a partial file.
try:
os.link(tmp_path, _KEY_PATH)
except FileExistsError:
logger.info("App key already created by another worker — reusing")
return _KEY_PATH.read_bytes()
logger.info(f"Generated new app key at {_KEY_PATH}")
return key
finally:
# Best-effort cleanup of the temp file — not critical if it
# lingers (a stale tmp uses negligible space).
try:
tmp_path.unlink()
except OSError:
pass
def _get_fernet() -> Fernet:

View file

@ -508,3 +508,84 @@ class TestFirstOidcAdminBootstrapConcurrency:
assert results[0] == results[1]
# Only one user entry must exist
assert len(mgr.users) == 1
class TestInterprocessFirstAdminSerialisation:
"""Two independent AuthManager instances sharing the same auth.json
path must serialise the first-admin decision across processes the
inter-process file lock (fcntl.flock) must prevent two workers from
both creating an admin when the store is empty."""
def test_two_managers_single_first_admin(self, tmp_path, monkeypatch):
"""Two managers with the same auth path: if one calls create_user_oidc
first, the other's setup must see the store is already configured."""
monkeypatch.delenv("OIDC_ADMIN_GROUPS", raising=False)
from core.auth import AuthManager
import threading
auth_path = str(tmp_path / "auth.json")
mgr_a = AuthManager(auth_path)
mgr_b = AuthManager(auth_path)
results = {}
barrier = threading.Barrier(2, timeout=5)
def oidc_first():
barrier.wait()
u = mgr_a.create_user_oidc("alice", "sub-a", "https://idp.example.com")
results["oidc"] = u
def local_setup():
barrier.wait()
ok = mgr_b.setup("admin", "password123")
results["setup"] = ok
t_oidc = threading.Thread(target=oidc_first)
t_setup = threading.Thread(target=local_setup)
t_oidc.start()
t_setup.start()
t_oidc.join()
t_setup.join()
# The inter-process lock serialises the critical sections.
# The first operation through the lock sees an empty store and
# creates an admin. The second operation may still succeed at
# creating a *non-admin* user (different username → no collision).
# The key property: exactly one admin must exist.
oidc_created = results.get("oidc") is not None
setup_created = results.get("setup") is True
assert oidc_created or setup_created, (
f"At least one first-admin path must succeed; "
f"oidc={oidc_created}, setup={setup_created}"
)
# Reload mgr_a and verify exactly one user is admin.
mgr_a._load()
admin_count = sum(1 for u in mgr_a.users.values() if u.get("is_admin"))
assert admin_count == 1, (
f"Expected exactly 1 admin after concurrent bootstrap; "
f"found {admin_count}. Users: {list(mgr_a.users.keys())}"
)
def test_setup_sees_oidc_bootstrap(self, tmp_path, monkeypatch):
"""After create_user_oidc bootstraps the first admin, a subsequent
setup() call on a different manager must see is_configured == True."""
monkeypatch.delenv("OIDC_ADMIN_GROUPS", raising=False)
from core.auth import AuthManager
auth_path = str(tmp_path / "auth.json")
mgr_a = AuthManager(auth_path)
mgr_b = AuthManager(auth_path)
# Manager A creates the first OIDC user (bootstrap admin)
username = mgr_a.create_user_oidc("bob", "sub-b", "https://idp.example.com")
assert username is not None
assert len(mgr_a.users) == 1
# Manager B: setup must now be denied — the store is configured
ok = mgr_b.setup("admin", "password123")
assert ok is False, "setup must not succeed when OIDC already bootstrapped"

View file

@ -1139,3 +1139,162 @@ class TestRedirectUriBinding:
# Verify the token endpoint received the stored URI
call_data = mock_post.call_args.kwargs["data"]
assert call_data["redirect_uri"] == stored_uri
class TestUserinfoEndpointMissing:
"""When discovery has no userinfo_endpoint, _fetch_userinfo must return
None (not {}), and exchange_code must NOT set _userinfo_available=True.
Otherwise the callback treats "no endpoint" as authoritative group
evidence and silently demotes an existing OIDC admin."""
def test_no_userinfo_endpoint_marks_unavailable(self):
"""Discovery lacking userinfo_endpoint → _fetch_userinfo returns
None _userinfo_available stays False."""
jwt_jwks, _ = _make_test_jwks_and_key()
nonce = "n" * 64
id_token = _make_id_token("user-no-ui", nonce)
import core.oidc as mod
# Discovery doc without a userinfo_endpoint
discovery_no_ui = dict(DISCOVERY_DOC)
del discovery_no_ui["userinfo_endpoint"]
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_FakeResponse(200, discovery_no_ui),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
# _mock_token_response already includes an access_token.
mock_post.return_value = _mock_token_response(id_token)
state = mod._encode_state(nonce, "https://app.example.com/callback")
claims = mgr.exchange_code("code", state, "https://app.example.com/callback")
assert claims["sub"] == "user-no-ui"
# _fetch_userinfo must have returned None because there is
# no userinfo_endpoint in discovery.
assert claims["_userinfo_available"] is False, (
"_userinfo_available must be False when discovery has no "
"userinfo_endpoint — an empty dict return would be ambiguous"
)
def test_userinfo_endpoint_present_marks_available(self):
"""Discovery with userinfo_endpoint + successful fetch →
_userinfo_available must be True (positive control)."""
jwt_jwks, _ = _make_test_jwks_and_key()
nonce = "n" * 64
id_token = _make_id_token("user-with-ui", nonce)
import core.oidc as mod
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
_FakeResponse(200, {"sub": "user-with-ui", "email": "u@example.com"}),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
mock_post.return_value = _mock_token_response(id_token)
state = mod._encode_state(nonce, "https://app.example.com/callback")
claims = mgr.exchange_code("code", state, "https://app.example.com/callback")
assert claims["sub"] == "user-with-ui"
assert claims["_userinfo_available"] is True
assert claims.get("email") == "u@example.com"
class TestAppKeyAtomicCreation:
"""Regression: on a fresh multi-worker deployment, the shared app key
must be created atomically so no racing reader ever sees an empty or
partial key file."""
def test_key_file_never_empty(self, tmp_path, monkeypatch):
"""The final key file must never be observable as empty or partial —
it either does not exist, or it contains a complete Fernet key."""
import src.secret_storage as ss
from pathlib import Path
tmp_key = tmp_path / ".app_key"
monkeypatch.setattr(ss, "_KEY_PATH", tmp_key)
monkeypatch.setattr(ss, "_fernet", None)
# Sanity: no key yet
assert not tmp_key.exists()
# Trigger key creation
fernet = ss._get_fernet()
assert fernet is not None
assert tmp_key.exists()
# The file must contain a valid Fernet key (44 URL-safe base64 bytes)
key_bytes = tmp_key.read_bytes()
assert len(key_bytes) >= 44, (
f"Key file must contain a complete Fernet key, got {len(key_bytes)} bytes"
)
# Must be usable as a Fernet key
from cryptography.fernet import Fernet
f = Fernet(key_bytes)
token = f.encrypt(b"test")
assert f.decrypt(token) == b"test"
def test_racing_reader_gets_valid_key(self, tmp_path, monkeypatch):
"""Simulate a race: pause the writer after temp-file write but
before the atomic link. A concurrent reader must either see no
key (and create its own, which will hit FileExistsError) or see
a complete key never an empty file."""
import src.secret_storage as ss
from pathlib import Path
from cryptography.fernet import Fernet
tmp_key = tmp_path / ".app_key"
monkeypatch.setattr(ss, "_KEY_PATH", tmp_key)
monkeypatch.setattr(ss, "_fernet", None)
# Intercept os.link so we can pause between temp-file write and link.
real_link = Path.__class__.link if hasattr(Path, "link") else type(tmp_key).__dict__.get("link")
# os.link is a module-level function, not a Path method.
import os as real_os
original_link = real_os.link
link_called = []
def intercept_link(src, dst, *args, **kwargs):
link_called.append(str(src))
# Before the link completes, simulate a racing reader.
# The reader must not see an empty key file at dst.
if tmp_key.exists():
content = tmp_key.read_bytes()
# This would fail if the file were empty/partial; in our
# implementation the final path is never exposed until
# os.link completes, so tmp_key.exists() should be False.
assert False, (
f"Key file already visible before atomic link — "
f"reader would see {len(content)} bytes"
)
return original_link(src, dst, *args, **kwargs)
monkeypatch.setattr(real_os, "link", intercept_link)
# Trigger key creation — must succeed despite the interceptor.
fernet = ss._get_fernet()
assert fernet is not None
assert len(link_called) >= 1
assert tmp_key.exists()
# The key file must be complete and usable.
key_bytes = tmp_key.read_bytes()
f = Fernet(key_bytes)
token = f.encrypt(b"test")
assert f.decrypt(token) == b"test"

View file

@ -892,3 +892,53 @@ class TestAdminDemotionProtection:
# id_token groups are authoritative — admin sync must run
auth.set_oidc_user_admin.assert_called_once_with("idtoken-admin", True)
def test_no_userinfo_endpoint_preserves_existing_admin(self, monkeypatch):
"""Existing OIDC admin + OIDC_ADMIN_GROUPS + access_token present
+ no userinfo_endpoint in discovery + no id_token groups
admin must NOT be demoted (missing endpoint is not group evidence)."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins")
mgr = MagicMock()
mgr.configured = True
mgr.redirect_uri_override = None
mgr.issuer = "https://idp.example.com"
# Simulate: discovery had no userinfo_endpoint, so _fetch_userinfo
# returned None → _userinfo_available stayed False. The id_token
# has no groups claim. This is the exact scenario where the
# callback must NOT treat "no endpoint" as authoritative
# non-membership evidence.
mgr.exchange_code.return_value = {
"sub": "endpointless-admin",
"email": "nobody@example.com",
"_userinfo_available": False,
# no "groups" key in the id_token — no group evidence at all
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = "endpointless-admin"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
asyncio.run(
ep(
_fake_request_with_params(
{"code": "code", "state": "state"},
cookies={"odysseus_oidc_csrf": "state"},
),
SimpleNamespace(
set_cookie=MagicMock(),
delete_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
# Admin sync must NOT be called — without a userinfo_endpoint,
# _userinfo_available is False, and the id_token has no groups.
# An existing admin must not be demoted on missing evidence.
auth.set_oidc_user_admin.assert_not_called()