mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-11 13:55:30 +00:00
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:
parent
739eb0faf1
commit
8375322d70
6 changed files with 373 additions and 21 deletions
38
core/auth.py
38
core/auth.py
|
|
@ -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()
|
||||
|
|
|
|||
21
core/oidc.py
21
core/oidc.py
|
|
@ -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}"},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue