This commit is contained in:
holden093 2026-08-04 15:27:34 +02:00 committed by GitHub
commit 16045a34fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 5747 additions and 66 deletions

View file

@ -91,6 +91,51 @@ SEARXNG_INSTANCE=http://localhost:8080
# CORS allowed origins (default: localhost-only; restrict to your public origin in production)
# ALLOWED_ORIGINS=http://localhost:7000,http://localhost:8000
# ============================================================
# OpenID Connect (OIDC) — Single Sign-On
# ============================================================
# Enable OIDC authentication alongside the existing password login.
# OIDC_ENABLED=false
#
# OIDC provider issuer URL (must expose .well-known/openid-configuration).
# OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
#
# Client credentials registered with the OIDC provider.
# OIDC_CLIENT_ID=odysseus
# OIDC_CLIENT_SECRET=your_client_secret_here
#
# Scopes to request (openid is required; profile and email are recommended).
# OIDC_SCOPES=openid profile email
#
# Optional fixed redirect URI — use when behind a proxy to avoid
# trusting the Host header. If unset, derived from the inbound request.
# OIDC_REDIRECT_URI=https://odysseus.example.com/api/auth/oidc/callback
#
# Comma-separated list of OIDC group names that grant admin privileges.
# When a user's `groups` claim includes one of these values, they become
# an admin on every login. Removing the group in the IdP revokes admin
# on the next login, so access follows the IdP.
# OIDC_ADMIN_GROUPS=odysseus-admins
#
# When true (default), the first OIDC user becomes admin if no users
# exist yet and OIDC_ADMIN_GROUPS is unset — prevents zero-admin lockout.
# OIDC_FIRST_USER_IS_ADMIN=true
#
# Session cookies use SameSite=Lax (HTTP-only, SameSite=Lax). This
# supports top-level GET redirects (normal OIDC flow) but not cross-site
# iframe/subresource callbacks. When deploying behind a reverse proxy,
# use OIDC_REDIRECT_URI to ensure the callback origin matches the
# browser-visible origin.
# SECURE_COOKIES=true should be set for HTTPS deployments. Set
# TRUST_PROXY_HEADERS=true only when the app is behind a trusted proxy that
# strips/replaces inbound X-Forwarded-Proto; otherwise client-supplied
# forwarded headers are ignored for cookie security decisions.
#
# OIDC session and CSRF cookies always carry the Secure flag, regardless
# of SECURE_COOKIES — SSO implies a TLS deployment. The only opt-out is
# the development-only override below; never enable it in production.
# OIDC_ALLOW_INSECURE_COOKIES=false
# ============================================================
# ChromaDB (vector store)
# ============================================================

23
app.py
View file

@ -245,6 +245,7 @@ app.add_middleware(_SlowRequestLogMiddleware)
# ========= AUTH =========
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
from core.oidc import init_oidc_manager
auth_manager = AuthManager()
app.state.auth_manager = auth_manager
@ -263,6 +264,9 @@ if AUTH_ENABLED:
"/api/auth/features",
"/api/auth/settings",
"/api/auth/integrations/presets",
"/api/auth/oidc/login",
"/api/auth/oidc/callback",
"/api/auth/oidc/config",
"/api/health",
"/api/version",
"/login",
@ -627,7 +631,6 @@ webhook_manager = WebhookManager(api_key_manager=api_key_manager)
auth_router = setup_auth_routes(auth_manager)
app.include_router(auth_router)
@app.post("/api/activity/heartbeat")
async def activity_heartbeat():
from src.interactive_gate import mark_browser_activity
@ -641,6 +644,24 @@ async def activity_heartbeat():
return {"ok": True}
# OIDC (single sign-on) — initialised after auth_manager so the OIDC routes
# can look up / create users.
# Register routes whenever OIDC_ENABLED=true (even if provider discovery
# hasn't succeeded yet) so the /config endpoint can report the error to
# the login page instead of 404-ing silently.
_OIDC_ENABLED = os.getenv("OIDC_ENABLED", "false").lower() == "true"
oidc_manager = init_oidc_manager() if _OIDC_ENABLED else None
if _OIDC_ENABLED:
from routes.oidc_routes import setup_oidc_routes
oidc_router = setup_oidc_routes(auth_manager, oidc_manager)
app.include_router(oidc_router)
if oidc_manager is not None:
logger.info("OIDC routes registered — provider discovered")
else:
logger.info("OIDC routes registered — provider not yet reachable")
else:
logger.info("OIDC disabled (set OIDC_ENABLED=true and provider vars to enable)")
# Uploads
from routes.upload_routes import setup_upload_routes
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)

View file

@ -18,15 +18,27 @@ import os
from typing import Any, Optional
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
def atomic_write_json(
path: str, data: Any, *, indent: Optional[int] = None, mode: Optional[int] = None
) -> None:
"""Atomically persist `data` as JSON at `path`.
The temp file uses the live PID as a suffix so two processes saving the
same file (e.g. unit tests) don't collide on the rename target.
When *mode* is given (e.g. ``0o600`` for files holding secrets), the
temp file is chmod'ed before the rename so the restricted permissions
are in place atomically with the content there is no window where
the target exists with default-umask permissions.
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
if mode is not None:
try:
os.fchmod(f.fileno(), mode)
except AttributeError: # Windows has no fchmod
os.chmod(tmp, mode)
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())

View file

@ -10,9 +10,21 @@ import secrets
import threading
import time
import logging
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Dict, Any, List
# POSIX-only: fcntl provides inter-process file locking used by
# _interprocess_auth_lock. On native Windows it doesn't exist, so
# we fall back to intra-process-only serialisation (single-worker
# deployments are the norm there, and OIDC defaults to off).
try:
import fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
fcntl = None # type: ignore[assignment]
import bcrypt
import pyotp
@ -68,6 +80,17 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# impersonated. (Keep this in sync with that synthetic-owner set.)
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
# Intra-process mutex that serialises all auth.json mutations within the same
# Python process. fcntl.flock (used by _interprocess_auth_lock) only blocks
# *other* processes — two threads in the same process calling flock(LOCK_EX)
# on the same file both succeed immediately. This lock closes that gap so the
# critical section is serialised across both threads and workers.
#
# RLock (reentrant) so a mutation method that acquires the inter-process lock
# can safely call another mutation method that also acquires it (e.g. setup()
# calling create_user()).
_auth_intraprocess_lock = threading.RLock()
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
"""Return a normalized username only when it exists in the auth user map."""
@ -109,9 +132,23 @@ class AuthManager:
# concurrent create/delete/rename/privilege operations don't interleave
# and corrupt the user database.
self._config_lock = threading.Lock()
# 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"
# mtime of sessions.json at last load — lets validate_token cheaply
# detect sessions written by other uvicorn workers (see
# _reload_sessions_if_changed).
self._sessions_mtime_ns = -1
# Tokens present in sessions.json at the last disk sync. Used to
# distinguish "revoked by another worker" (was on disk, now gone —
# drop it) from "issued locally moments ago, racing its own save"
# (never seen on disk — keep it).
self._disk_tokens: set = set()
# Tokens this worker revoked whose removal may not yet be visible
# on disk. A disk sync must never re-add these; pruned once the
# on-disk file no longer contains them.
self._revoked_tokens: set = set()
self._load()
self._load_sessions()
self._migrate_single_user()
@ -121,6 +158,12 @@ class AuthManager:
def _load(self):
try:
if os.path.exists(self.auth_path):
# Contains password hashes — restrict pre-existing files
# written before the 0600 policy.
try:
os.chmod(self.auth_path, 0o600)
except OSError:
pass
with open(self.auth_path, "r", encoding="utf-8") as f:
self._config = json.load(f)
# Normalize all stored usernames to lowercase so they match
@ -144,10 +187,19 @@ class AuthManager:
"""Load persisted session tokens from disk, pruning expired ones."""
try:
if os.path.exists(self._sessions_path):
# Session tokens are bearer credentials — never leave the
# file readable by other local users (same policy as
# data/app.db, #4420).
try:
os.chmod(self._sessions_path, 0o600)
except OSError:
pass
self._sessions_mtime_ns = os.stat(self._sessions_path).st_mtime_ns
with open(self._sessions_path, "r", encoding="utf-8") as f:
data = json.load(f)
now = time.time()
self._sessions = {k: v for k, v in data.items() if v.get("expiry", 0) > now}
self._disk_tokens = set(data)
pruned = len(data) - len(self._sessions)
if pruned > 0:
self._save_sessions()
@ -156,12 +208,111 @@ class AuthManager:
logger.error(f"Failed to load sessions: {e}")
self._sessions = {}
def _save_sessions(self):
"""Persist session tokens to disk (atomic, lock-guarded)."""
def _reload_sessions_if_changed(self):
"""Sync session state written by other uvicorn workers.
The OIDC callback (or a password login/logout) may run on one
worker while the browser's next request lands on another; each
worker loads sessions.json only at startup, so cross-worker
issuance and revocation would otherwise be invisible. Called on
every token validation: when the file's mtime has changed since
the last sync, re-read it and
- add unknown unexpired tokens (issued by another worker), and
- drop in-memory tokens that were on disk at the last sync but
are gone now (revoked by another worker).
A token never yet seen on disk is kept it was issued locally
moments ago and may be racing its own _save_sessions. The mtime
gate keeps the steady-state cost at one os.stat per validation,
not a JSON parse.
"""
try:
with self._sessions_lock:
stat = os.stat(self._sessions_path)
except OSError:
return
with self._sessions_lock:
if stat.st_mtime_ns == self._sessions_mtime_ns:
return
try:
with open(self._sessions_path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.error(f"Failed to reload sessions: {e}")
return
self._sessions_mtime_ns = stat.st_mtime_ns
if not isinstance(data, dict):
return
self._apply_disk_sessions(data)
def _apply_disk_sessions(self, data: Dict[str, Any]) -> None:
"""Merge parsed sessions.json content into memory.
Caller must hold ``_sessions_lock``. Adds unknown unexpired
tokens (unless this worker revoked them and the removal hasn't
reached disk yet), drops tokens revoked by other workers, and
refreshes the disk-snapshot bookkeeping.
"""
now = time.time()
for tok, sess in data.items():
if (
tok not in self._sessions
and tok not in self._revoked_tokens
and isinstance(sess, dict)
and sess.get("expiry", 0) > now
):
self._sessions[tok] = sess
revoked_elsewhere = [
tok for tok in self._sessions
if tok not in data and tok in self._disk_tokens
]
for tok in revoked_elsewhere:
self._sessions.pop(tok, None)
self._disk_tokens = set(data)
# A tombstone is only needed while the token is still on disk.
self._revoked_tokens &= self._disk_tokens
@contextmanager
def _interprocess_sessions_lock(self):
"""Serialise sessions.json read-merge-write cycles across uvicorn
workers. Separate lock file from the auth.json IPC lock so a
session save can never deadlock a caller already holding the auth
lock (flock is not re-entrant across file descriptors)."""
if not HAS_FCNTL:
yield
return
fd = os.open(self._sessions_path + ".lock", 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 _save_sessions(self):
"""Persist session tokens to disk (atomic, merge-on-write).
Merges the current on-disk state before writing, under an
inter-process flock a plain overwrite would clobber sessions
issued by other workers since this worker's last sync (lost
update). Tombstones in ``_revoked_tokens`` keep just-revoked
tokens from being re-merged and resurrected.
"""
try:
with self._interprocess_sessions_lock(), self._sessions_lock:
try:
with open(self._sessions_path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
self._apply_disk_sessions(data)
except OSError:
pass # first save — no file yet
except Exception as e:
logger.error(f"Failed to merge sessions before save: {e}")
snapshot = dict(self._sessions)
_atomic_write_json(self._sessions_path, snapshot)
_atomic_write_json(self._sessions_path, snapshot, mode=0o600)
self._disk_tokens = set(snapshot)
self._revoked_tokens &= self._disk_tokens
except Exception as e:
logger.error(f"Failed to save sessions: {e}")
@ -226,7 +377,8 @@ class AuthManager:
self._save()
def _save(self):
_atomic_write_json(self.auth_path, self._config, indent=2)
# Password hashes — owner-only, same policy as sessions.json.
_atomic_write_json(self.auth_path, self._config, indent=2, mode=0o600)
@property
def users(self) -> Dict[str, Any]:
@ -238,7 +390,8 @@ class AuthManager:
@signup_enabled.setter
def signup_enabled(self, value: bool):
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
self._config["signup_enabled"] = value
self._save()
@ -259,36 +412,255 @@ class AuthManager:
# Account management
# ------------------------------------------------------------------
@contextmanager
def _interprocess_auth_lock(self):
"""Acquire an exclusive lock on auth.json — serialised across both
threads (intra-process) and workers/processes (inter-process).
The module-level threading.Lock serialises threads within the same
Python process. fcntl.flock serialises across different processes
(uvicorn workers). The kernel releases flock automatically when
the process exits, so a crash cannot leave a stale lock.
On platforms without fcntl (native Windows), this degrades to
intra-process-only serialisation. OIDC defaults to off and
single-worker deployments are the norm there, so the degraded
mode is safe for most Windows use cases.
"""
with _auth_intraprocess_lock:
if not HAS_FCNTL:
yield
return
# 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:
username = username.strip().lower()
with self._interprocess_auth_lock(), self._config_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)
# _create_user_locked assumes the interprocess lock is already
# held, avoiding a nested fcntl.flock deadlock (flock is not
# reentrant across different file descriptors).
return self._create_user_locked(username, password, is_admin=True)
def create_user(self, username: str, password: str, is_admin: bool = False) -> bool:
"""Create a new user account."""
"""Create a new user account.
Serialised across workers via the shared inter-process lock so a
concurrent OIDC admin sync cannot lose a newly-created user.
"""
username = username.strip().lower()
if not username:
return False
if username in RESERVED_USERNAMES:
logger.warning("Refused to create reserved username '%s'", username)
return False
with self._config_lock:
if username in self.users:
return False
with self._interprocess_auth_lock(), self._config_lock:
self._load()
return self._create_user_locked(username, password, is_admin)
def _create_user_locked(self, username: str, password: str, is_admin: bool) -> bool:
"""Internal helper — caller must hold _interprocess_auth_lock
and _config_lock. Does not reload (caller did that)."""
username = username.strip().lower()
if username in RESERVED_USERNAMES:
logger.warning("Refused to create reserved username '%s'", username)
return False
if username in self._config.get("users", {}):
return False
if "users" not in self._config:
self._config["users"] = {}
self._config["users"][username] = {
"password_hash": _hash_password(password),
"created": time.time(),
"is_admin": is_admin,
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
}
self._save()
logger.info(f"Created user '{username}' (admin={is_admin})")
return True
def get_user_by_oidc(self, sub: str, issuer: str) -> Optional[str]:
"""Find a username by OIDC (sub, issuer) pair. Returns None if no match."""
for username, data in self.users.items():
if data.get("oidc_sub") == sub and data.get("oidc_issuer") == issuer:
return username
return None
def create_user_oidc(self, username: str, sub: str, issuer: str, email: str = "",
is_admin: bool = False) -> Optional[str]:
"""Create a passwordless user linked to an OIDC identity.
Returns the final username (may differ from *username* if a local
password user already owns that name), or ``None`` when creation
fails (e.g. all candidate usernames collide with different OIDC
identities).
OIDC users have no password hash they can only authenticate
through the OIDC flow. An existing OIDC user with the same
(sub, issuer) is returned as-is (idempotent).
When OIDC is the only auth path (no password admin exists) or
OIDC_ADMIN_GROUPS is unset, the first OIDC user becomes admin
by default to prevent zero-admin lockout. Set
OIDC_FIRST_USER_IS_ADMIN=false to disable this bootstrap.
"""
username = username.strip().lower()
if not username:
return None
if username in RESERVED_USERNAMES:
logger.warning("Refused OIDC user with reserved username '%s'", username)
return None
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"] = {}
self._config["users"][username] = {
"password_hash": _hash_password(password),
users = self._config["users"]
# Idempotent: same identity already exists (inside lock so
# two concurrent callbacks for the same OIDC identity cannot
# both observe an empty user map and create duplicate entries).
for uname, data in users.items():
if data.get("oidc_sub") == sub and data.get("oidc_issuer") == issuer:
return uname
# 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
# 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()
if first_user_admin and not users and not oidc_admin_groups:
is_admin = True
logger.info(
"First OIDC user '%s' promoted to admin (bootstrap, "
"no OIDC_ADMIN_GROUPS configured). "
"Set OIDC_FIRST_USER_IS_ADMIN=false to opt out.",
username,
)
# If the requested username is taken by a *different* identity
# (another OIDC user or a local password user), find a free
# slot by appending a numeric suffix.
base = username
candidate = username
suffix = 1
while candidate in users:
suffix += 1
candidate = f"{base}{suffix}"
if suffix > 100: # safety valve
logger.error("OIDC username collision loop for '%s'", username)
return None
users[candidate] = {
"password_hash": None,
"created": time.time(),
"is_admin": is_admin,
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
"oidc_sub": sub,
"oidc_issuer": issuer,
"oidc_email": email,
}
self._save()
logger.info(f"Created user '{username}' (admin={is_admin})")
logger.info(
"Created OIDC user '%s' (sub=%s issuer=%s admin=%s)",
candidate, sub, issuer, is_admin,
)
return candidate
def is_oidc_user(self, username: str) -> bool:
"""Return True when *username* was created via OIDC (has no password)."""
user = self.users.get(username.strip().lower(), {})
return bool(user.get("oidc_sub"))
def set_oidc_user_admin(self, username: str, is_admin: bool) -> bool:
"""Set (or clear) admin status for an OIDC user.
Called on every OIDC login so admin follows the IdP's group
membership. Returns ``False`` if the user doesn't exist or is
not an OIDC user (password-account admins must be managed manually).
Serialised across workers via the shared inter-process lock so a
stale in-memory snapshot cannot overwrite users concurrently
created by another worker.
"""
username = username.strip().lower()
with self._interprocess_auth_lock(), self._config_lock:
# Reload from disk so we see what another process may have
# written since our last _load() — e.g. a concurrent
# create_user_oidc() on a different worker.
self._load()
user = self._config.get("users", {}).get(username, {})
if not user.get("oidc_sub"):
return False # not an OIDC user (or removed) — don't touch
if user.get("is_admin") == is_admin:
return True # no change needed
# Refuse to demote the only remaining administrator. Group
# membership changes must not silently make the instance
# unadministrable; password admins count as recovery admins.
if user.get("is_admin") and not is_admin:
admin_count = sum(
1 for data in self._config.get("users", {}).values()
if data.get("is_admin")
)
if admin_count <= 1:
logger.warning(
"Refusing to demote last admin '%s' during OIDC sync",
username,
)
return False
self._config["users"][username]["is_admin"] = is_admin
if is_admin:
self._config["users"][username]["privileges"] = dict(ADMIN_PRIVILEGES)
else:
self._config["users"][username]["privileges"] = dict(DEFAULT_PRIVILEGES)
self._save()
logger.info(
"OIDC user '%s' admin=%s (synced from IdP group membership)",
username, is_admin,
)
return True
def check_oidc_totp(self, username: str) -> bool:
"""Return True when *username* has TOTP enabled AND is an OIDC user.
Reloads auth.json from disk under the inter-process lock so a
manually-edited or externally-mutated config is visible.
Callers must invoke this via ``asyncio.to_thread()`` file
locking inside the critical section blocks the calling thread.
This is defense-in-depth: normal OIDC users cannot enable local
TOTP (route guards prevent it), but an externally-edited auth.json or
a pre-OIDC legacy account could have both ``oidc_sub`` and
``totp_enabled`` set.
"""
username = username.strip().lower()
with self._interprocess_auth_lock(), self._config_lock:
self._load()
user = self._config.get("users", {}).get(username, {})
if not user.get("oidc_sub"):
return False # not an OIDC user
return bool(user.get("totp_enabled"))
def delete_user(self, username: str, requesting_user: str) -> bool:
"""Delete a user. Only admins can delete, and can't delete themselves.
@ -298,7 +670,8 @@ class AuthManager:
their cookie expired naturally (default ~30 days).
"""
username = username.strip().lower()
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
if username not in self.users:
return False
if username == requesting_user:
@ -332,6 +705,7 @@ class AuthManager:
if (sess or {}).get("username") == username]
for tok in to_drop:
self._sessions.pop(tok, None)
self._revoked_tokens.add(tok)
revoked += 1
if revoked:
self._save_sessions()
@ -348,7 +722,8 @@ class AuthManager:
if new_username in RESERVED_USERNAMES:
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
return False
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
if old_username not in self.users:
return False
if new_username in self.users:
@ -377,10 +752,19 @@ class AuthManager:
return self.users.get(username, {}).get("is_admin", False)
def list_users(self) -> List[Dict[str, Any]]:
return [
{"username": u, "is_admin": d.get("is_admin", False), "privileges": self.get_privileges(u)}
for u, d in self.users.items()
]
result = []
for u, d in self.users.items():
entry = {
"username": u,
"is_admin": d.get("is_admin", False),
"privileges": self.get_privileges(u),
}
if d.get("oidc_sub"):
entry["oidc"] = True
entry["oidc_issuer"] = d.get("oidc_issuer", "")
entry["oidc_email"] = d.get("oidc_email", "")
result.append(entry)
return result
def get_privileges(self, username: str) -> Dict[str, Any]:
"""Get privileges for a user. Admins get all privileges."""
@ -394,7 +778,8 @@ class AuthManager:
def set_privileges(self, username: str, privileges: Dict[str, Any]) -> bool:
"""Update privileges for a user. Can't modify admin privileges."""
username = username.strip().lower()
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
if username not in self.users:
return False
if self.users[username].get("is_admin"):
@ -430,7 +815,8 @@ class AuthManager:
username = (username or "").strip().lower()
requesting_user = (requesting_user or "").strip().lower()
is_admin = bool(is_admin)
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
target = self._config.get("users", {}).get(username)
if target is None:
return SetAdminResult.USER_NOT_FOUND
@ -474,11 +860,15 @@ class AuthManager:
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
username = username.strip().lower()
if username not in self.users:
return False
if not _verify_password(current_password, self.users[username]["password_hash"]):
return False
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
if username not in self.users:
return False
pw_hash = self.users[username].get("password_hash")
if pw_hash is None:
return False # OIDC-only user — password changes must go through the IdP
if not _verify_password(current_password, pw_hash):
return False
self._config["users"][username]["password_hash"] = _hash_password(new_password)
self._save()
return True
@ -495,10 +885,11 @@ class AuthManager:
def totp_generate_secret(self, username: str) -> Optional[str]:
"""Generate a new TOTP secret for a user. Returns the secret (not yet enabled)."""
username = username.strip().lower()
if username not in self.users:
return None
secret = pyotp.random_base32()
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
if username not in self.users:
return None
self._config["users"][username]["totp_secret_pending"] = secret
self._save()
return secret
@ -511,15 +902,16 @@ class AuthManager:
def totp_confirm_enable(self, username: str, code: str) -> bool:
"""Verify a TOTP code against the pending secret, then enable 2FA."""
username = username.strip().lower()
user = self.users.get(username, {})
secret = user.get("totp_secret_pending")
if not secret:
return False
totp = pyotp.TOTP(secret)
if not totp.verify(code, valid_window=1):
return False
# Enable 2FA
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
user = self._config.get("users", {}).get(username, {})
secret = user.get("totp_secret_pending")
if not secret:
return False
totp = pyotp.TOTP(secret)
if not totp.verify(code, valid_window=1):
return False
# Enable 2FA
self._config["users"][username]["totp_secret"] = secret
self._config["users"][username]["totp_enabled"] = True
self._config["users"][username].pop("totp_secret_pending", None)
@ -545,12 +937,16 @@ class AuthManager:
# Check backup codes first
backup = user.get("totp_backup_codes", [])
if code in backup:
with self._config_lock:
backup.remove(code)
self._config["users"][username]["totp_backup_codes"] = backup
self._save()
logger.info(f"Backup code used for '{username}' ({len(backup)} remaining)")
return True
with self._interprocess_auth_lock(), self._config_lock:
self._load()
latest_backup = self._config.get("users", {}).get(username, {}).get("totp_backup_codes", [])
if code in latest_backup:
latest_backup.remove(code)
self._config["users"][username]["totp_backup_codes"] = latest_backup
self._save()
logger.info(f"Backup code used for '{username}' ({len(latest_backup)} remaining)")
return True
return False
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
@ -559,7 +955,10 @@ class AuthManager:
username = username.strip().lower()
if not self.verify_password(username, password):
return False
with self._config_lock:
with self._interprocess_auth_lock(), self._config_lock:
self._load()
if username not in self.users:
return False
self._config["users"][username].pop("totp_secret", None)
self._config["users"][username].pop("totp_secret_pending", None)
self._config["users"][username].pop("totp_backup_codes", None)
@ -576,7 +975,10 @@ class AuthManager:
username = username.strip().lower()
if username not in self.users:
return False
return _verify_password(password, self.users[username]["password_hash"])
pw_hash = self.users[username].get("password_hash")
if pw_hash is None:
return False # OIDC-only user — no password set
return _verify_password(password, pw_hash)
def create_session(self, username: str, password: str) -> Optional[str]:
"""Verify credentials and return a session token, or None."""
@ -605,6 +1007,8 @@ class AuthManager:
def validate_token(self, token: Optional[str]) -> bool:
if not token:
return False
# Sync issuance/revocation from other workers (mtime-gated).
self._reload_sessions_if_changed()
expired = False
deleted_user = False
with self._sessions_lock:
@ -621,6 +1025,7 @@ class AuthManager:
# silently authenticating against a non-existent account.
if session.get("username") not in self.users:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
deleted_user = True
if expired or deleted_user:
self._save_sessions()
@ -631,6 +1036,8 @@ class AuthManager:
"""Return the username associated with a valid token."""
if not token:
return None
# Sync issuance/revocation from other workers (mtime-gated).
self._reload_sessions_if_changed()
expired = False
deleted_user = False
with self._sessions_lock:
@ -645,6 +1052,7 @@ class AuthManager:
# SECURITY: orphan check — same rationale as validate_token.
if _u not in self.users:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
deleted_user = True
else:
return _u
@ -655,6 +1063,7 @@ class AuthManager:
def revoke_token(self, token: str):
with self._sessions_lock:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
self._save_sessions()
def revoke_user_sessions(self, username: str, except_token: Optional[str] = None) -> int:
@ -668,9 +1077,13 @@ class AuthManager:
]
for token in to_drop:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
revoked += 1
if revoked:
self._save_sessions()
# Save outside _sessions_lock: _save_sessions acquires the
# inter-process flock before _sessions_lock, and taking them in
# the opposite order here could deadlock two threads.
if revoked:
self._save_sessions()
return revoked
def status(self, token: Optional[str]) -> Dict[str, Any]:

795
core/oidc.py Normal file
View file

@ -0,0 +1,795 @@
"""Generic OpenID Connect client — provider discovery, auth flow, id_token verification.
Configuration (env vars):
OIDC_ENABLED=true|false master toggle
OIDC_ISSUER=https://... provider issuer URL (must expose .well-known)
OIDC_CLIENT_ID=odysseus client ID registered with the provider
OIDC_CLIENT_SECRET=... client secret
OIDC_REDIRECT_URI=... optional fixed redirect URI (use when
behind a proxy to avoid trusting the Host
header). If unset, derived from the inbound
request at /login and /callback time.
OIDC_SCOPES=openid profile email space-separated scope list
OIDC_MAX_AGE=3600 optional maximum authentication age in
seconds. When set, the IdP is asked to
re-authenticate the user and the
``auth_time`` claim is verified.
State is carried inside a Fernet-encrypted token embedded in the OIDC
``state`` parameter, so no server-side storage is needed callbacks are
stateless and work across multiple uvicorn workers / processes. The
encryption key is the shared persistent app key (``data/.app_key``,
managed by ``src.secret_storage``).
JWKS keys are cached after first fetch and refreshed only when an unknown
``kid`` is encountered, avoiding a live IdP round-trip on every login. A
60-second cooldown throttles both successful and failed refreshes.
"""
import base64
import hashlib
import json
import logging
import math
import os
import secrets
import time
import threading
from typing import Optional, Dict, Any, List, Tuple
import httpx
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# State token (Fernet-encrypted, carried in the OIDC state param)
# ---------------------------------------------------------------------------
# Instead of an in-memory dict (which breaks with uvicorn --workers > 1), we
# encrypt the nonce + redirect_uri + creation timestamp into the state value
# itself. The callback decrypts it to recover the nonce and validate freshness.
# This is the pattern used by NextAuth.js, oauthlib, and several OIDC SDKs.
_STATE_TTL = 600 # 10 minutes
# DESIGN NOTE — state tokens are deliberately NOT single-use. Enforcing
# one-time consumption would require shared server-side storage, which
# this stateless design intentionally avoids (multi-worker support with
# no session store). Replay of a state within its TTL is mitigated by:
# - the authorization code being single-use at the IdP (a replayed
# callback fails the token exchange),
# - the nonce being bound into the signed id_token and verified,
# - the PKCE verifier being bound to the same encrypted state, and
# - the CSRF cookie requiring the completing browser to hold the state.
_state_fernet_lock = threading.Lock()
_state_fernet = None
def _get_state_fernet():
"""Lazily get or create a Fernet instance for state encryption.
Uses the shared persistent app key (data/.app_key) from
``src.secret_storage._get_fernet()``, which creates the key file on
first access. This guarantees the same Fernet key is available to
all uvicorn workers, even on a fresh data directory the OIDC
authorization state encrypted by worker A can always be decrypted
by worker B on the callback.
"""
global _state_fernet
if _state_fernet is not None:
return _state_fernet
with _state_fernet_lock:
if _state_fernet is not None:
return _state_fernet
from src.secret_storage import _get_fernet
_state_fernet = _get_fernet()
return _state_fernet
def _encode_state(nonce: str, redirect_uri: str, code_verifier: str) -> str:
"""Return a Fernet-encrypted state token containing nonce + metadata."""
fernet = _get_state_fernet()
payload = json.dumps({
"nonce": nonce,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"created": time.time(),
})
return fernet.encrypt(payload.encode()).decode()
def _decode_state(state: str) -> Optional[Dict[str, Any]]:
"""Decrypt and validate a state token. Returns None if expired or invalid."""
fernet = _get_state_fernet()
try:
plain = fernet.decrypt(state.encode())
data = json.loads(plain)
except Exception:
return None
if not isinstance(data, dict):
return None
nonce = data.get("nonce")
redirect_uri = data.get("redirect_uri")
code_verifier = data.get("code_verifier")
created = data.get("created")
if not isinstance(nonce, str) or not nonce:
return None
if not isinstance(redirect_uri, str):
return None
if not isinstance(code_verifier, str) or not code_verifier:
return None
if not _is_numericdate(created):
return None
now = time.time()
if created > now + 60 or now - created > _STATE_TTL:
return None
return data
# ---------------------------------------------------------------------------
# OidcManager
# ---------------------------------------------------------------------------
def _is_numericdate(value) -> bool:
"""Return True when *value* is a finite int/float that is not bool.
Python's json module parses NaN/Inf by default, and isinstance(True,
int) is True. This helper rejects booleans, NaN, ±Inf, and non-
numeric types so numeric claim checks don't silently pass on bogus
input.
"""
if isinstance(value, bool):
return False
if not isinstance(value, (int, float)):
return False
return math.isfinite(value)
class OidcError(Exception):
"""Raised for OIDC configuration or flow errors."""
class OidcManager:
"""Generic OpenID Connect client.
On init, discovers the provider's endpoints via
``.well-known/openid-configuration`` and caches the JWKS for
id_token signature verification.
"""
def __init__(
self,
issuer: str,
client_id: str,
client_secret: str,
scopes: str = "openid profile email",
max_age: Optional[int] = None,
):
self.issuer = issuer.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self.scopes = scopes
# Immutable — set once at init so concurrent callbacks sharing the
# singleton manager see the same value (gpt-5.6-sol gap #1).
self.max_age = max_age
self._provider_name: Optional[str] = None
self._config: Dict[str, Any] = {}
# JWKS cache: kid → key dict, populated on first verification and
# refreshed when an unknown kid is encountered.
self._jwks_cache: Dict[str, Dict[str, Any]] = {}
self._jwks_cache_lock = threading.Lock()
self._allowed_algs: Optional[List[str]] = None
self._token_auth_methods: List[str] = ["client_secret_basic"]
self._discover()
def _use_basic_auth(self) -> bool:
"""True when the token endpoint should use client_secret_basic."""
if "client_secret_basic" in self._token_auth_methods:
return True
if "client_secret_post" in self._token_auth_methods:
return False
# Provider advertises neither shared-secret method — use the OIDC
# default rather than silently leaking the secret in the body.
return True
# -- discovery -----------------------------------------------------------
def _discover(self) -> None:
"""Fetch .well-known/openid-configuration."""
# urljoin drops the issuer's path when the second arg is absolute
# (starts with "/"). Use simple concatenation so issuers with a
# sub-path (e.g. Authentik /application/o/<slug>/) work correctly.
well_known_url = self.issuer + "/.well-known/openid-configuration"
if not well_known_url.startswith(("http://", "https://")):
well_known_url = f"https://{well_known_url}"
# The issuer (and therefore the discovery document) must be HTTPS:
# the authorization redirect carries state/nonce, and an http://
# issuer lets an active network attacker substitute authorization
# codes or rewrite the discovery document entirely.
if not well_known_url.startswith("https://"):
raise OidcError(
f"OIDC issuer must use HTTPS, got {self.issuer!r}. "
"Configure the IdP with TLS or set OIDC_ENABLED=false."
)
try:
resp = httpx.get(well_known_url, timeout=15.0)
resp.raise_for_status()
self._config = resp.json()
except Exception as exc:
raise OidcError(
f"Failed to fetch OIDC discovery document from {well_known_url}: {exc}"
) from exc
# Validate essential endpoints are present
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri", "issuer"):
if key not in self._config:
raise OidcError(
f"OIDC discovery document missing required key: {key}"
)
# The issuer in the discovery doc MUST match the configured issuer
# (OIDC Discovery §1.1). Failing closed prevents trust-path confusion
# where a misconfigured or malicious discovery document could cause
# id_token validation to accept a different issuer.
doc_issuer = (self._config.get("issuer") or "").rstrip("/")
if doc_issuer and doc_issuer != self.issuer:
raise OidcError(
f"OIDC issuer mismatch: configured {self.issuer!r}, "
f"discovery doc returned {doc_issuer!r}"
)
# No OIDC endpoint may use cleartext transport. The back-channel
# endpoints carry client credentials and bearer tokens; the browser-
# facing authorization endpoint carries state/nonce and returns the
# authorization code, so an http:// endpoint enables code
# substitution by an active network observer.
for name in ("authorization_endpoint", "token_endpoint", "jwks_uri", "userinfo_endpoint"):
url = self._config.get(name)
if url and not isinstance(url, str):
raise OidcError(f"OIDC {name} must be a URL string")
if url and not url.startswith("https://"):
raise OidcError(
f"OIDC {name} must use HTTPS, got {url!r}. "
"Configure the IdP with TLS or set OIDC_ENABLED=false."
)
# Pin signing algorithms to those the provider supports.
# Restrict to RS256/ES256 to avoid algorithm confusion attacks;
# HS256 and 'none' are never allowed.
supported = self._config.get("id_token_signing_alg_values_supported", [])
safe = [a for a in supported if a in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512")]
self._allowed_algs = safe or ["RS256"]
# Token-endpoint auth methods. Per OIDC Discovery §3, an omitted
# token_endpoint_auth_methods_supported means client_secret_basic.
methods = self._config.get("token_endpoint_auth_methods_supported")
if not isinstance(methods, list) or not methods:
methods = ["client_secret_basic"]
self._token_auth_methods = methods
logger.info(
"OIDC provider discovered: issuer=%r auth=%r token=%r algs=%s",
self.issuer,
self._config["authorization_endpoint"],
self._config["token_endpoint"],
self._allowed_algs,
)
@property
def provider_name(self) -> str:
"""A human-readable name derived from the issuer URL."""
if self._provider_name:
return self._provider_name
# Use the host portion of the issuer as a readable label.
from urllib.parse import urlparse
parsed = urlparse(self.issuer)
return parsed.hostname or self.issuer
@property
def configured(self) -> bool:
return bool(self._config)
@property
def redirect_uri_override(self) -> Optional[str]:
"""Return OIDC_REDIRECT_URI if explicitly configured, else None."""
val = os.getenv("OIDC_REDIRECT_URI", "").strip()
return val or None
# -- authorization URL ---------------------------------------------------
def get_authorization_url(self, redirect_uri: str) -> Tuple[str, str, str]:
"""Build the provider's authorization URL.
Returns ``(url, state, nonce)``. The *state* value is an encrypted
token that carries *nonce* and *redirect_uri* the caller does NOT
need to store anything server-side; the callback will recover the
nonce from the state parameter itself.
"""
nonce = secrets.token_hex(32)
# PKCE (RFC 7636, S256). The verifier travels inside the encrypted
# state token, so the callback can recover it without server-side
# storage — same carrier as the nonce.
code_verifier = secrets.token_urlsafe(64)
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
.rstrip(b"=")
.decode("ascii")
)
# Encode the nonce + metadata into the state parameter (Fernet-
# encrypted, stateless — works across multiple workers/processes).
state = _encode_state(nonce, redirect_uri, code_verifier)
from urllib.parse import urlencode
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": redirect_uri,
"scope": self.scopes,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
# Request forced re-authentication when OIDC_MAX_AGE is configured.
# The claim is later verified in _verify_id_token against auth_time.
if self.max_age is not None:
params["max_age"] = str(self.max_age)
auth_url = f"{self._config['authorization_endpoint']}?{urlencode(params)}"
return auth_url, state, nonce
# -- token exchange + verification ---------------------------------------
def exchange_code(
self, code: str, state: str, redirect_uri: str
) -> Dict[str, Any]:
"""Exchange authorization code for tokens and verify the id_token.
Returns a dict of claims extracted from the verified id_token.
Raises :class:`OidcError` on any failure.
"""
# 1. Decrypt state and recover the nonce and original redirect_uri
stored = _decode_state(state)
if stored is None:
raise OidcError("OIDC state not found — may be expired, reused, or from a different worker")
nonce = stored.get("nonce", "")
stored_redirect_uri = stored.get("redirect_uri", "")
code_verifier = stored.get("code_verifier", "")
# Bind the token exchange to the redirect_uri that was used in the
# authorization request (carried in the signed state token). Reject
# any callback-derived redirect_uri that differs — this removes the
# last callback dependence on request-derived redirect URI behaviour.
if stored_redirect_uri and stored_redirect_uri != redirect_uri:
raise OidcError(
f"OIDC redirect_uri mismatch: state={stored_redirect_uri!r} "
f"callback={redirect_uri!r}"
)
# 2. Exchange code for tokens (using the stored redirect_uri)
token_data = self._token_request(
code, stored_redirect_uri or redirect_uri, code_verifier
)
# 3. Verify id_token
id_token = token_data.get("id_token")
if not id_token:
raise OidcError("No id_token in token response")
claims = self._verify_id_token(id_token, nonce)
# Optionally merge userinfo if we got an access_token.
# Per OIDC spec, userinfo is authoritative for profile claims (name,
# email, picture, etc.) but MUST NOT overwrite verified identity
# claims from the id_token (sub, iss, aud, exp, iat, nonce, azp).
#
# SECURITY: a UserInfo response without a ``sub`` is not bound to
# the authenticated subject. Any endpoint can return arbitrary
# groups/roles/permissions data; refusing to merge or mark available
# prevents unbound claims from driving local authorisation decisions.
access_token = token_data.get("access_token")
userinfo_available = False
userinfo = {} # ensure defined even if _fetch_userinfo raises
if access_token:
try:
userinfo = self._fetch_userinfo(access_token)
if userinfo is None:
# No userinfo_endpoint in discovery — not an error.
userinfo = {}
elif not isinstance(userinfo, dict):
# Malformed response (list, string, null, …) — log and
# treat as unavailable. Do not merge any claims.
logger.warning(
"UserInfo endpoint returned non-dict type %s"
"treating as unavailable",
type(userinfo).__name__,
)
userinfo = {}
else:
# Require a non-empty sub that exactly matches the
# verified id_token subject before trusting any UserInfo
# claims. No normalization: subs are opaque identifiers
# and trimming could equate two distinct subjects.
ui_sub = userinfo.get("sub")
if not isinstance(ui_sub, str):
ui_sub = ""
if not ui_sub:
logger.warning(
"UserInfo response missing sub claim — "
"discarding entire response to prevent "
"unbound claim injection"
)
userinfo = {}
elif ui_sub != (claims.get("sub") or ""):
raise OidcError(
f"UserInfo sub mismatch: id_token={claims.get('sub')!r} "
f"userinfo={ui_sub!r}"
)
else:
# Sub present and matches — safe to merge.
userinfo_available = True
# Merge only safe profile claims — never overwrite
# verified identity/security fields.
_IDENTITY_CLAIMS = frozenset({
"sub", "iss", "aud", "exp", "iat", "nonce", "azp",
})
for k, v in userinfo.items():
if k not in _IDENTITY_CLAIMS:
claims[k] = v
except OidcError:
raise
except Exception as exc:
logger.warning("Failed to fetch userinfo: %s", exc)
userinfo = {}
# Let the callback know whether UserInfo was successfully fetched.
# When UserInfo is unavailable, group membership claims may be
# incomplete — the callback must not demote existing admins based
# on missing evidence.
claims["_userinfo_available"] = userinfo_available
return claims
def _token_request(
self, code: str, redirect_uri: str, code_verifier: str
) -> Dict[str, Any]:
"""POST the token endpoint to exchange code for tokens."""
token_endpoint = self._config["token_endpoint"]
payload = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
}
# client_secret_basic is the OIDC default and the method the
# conformance suite expects; use it whenever the provider supports
# it (or doesn't advertise methods at all, which per Discovery §3
# means client_secret_basic). Fall back to client_secret_post only
# when the provider explicitly excludes basic.
auth = None
if self._use_basic_auth():
from urllib.parse import quote
auth = (
quote(self.client_id, safe=""),
quote(self.client_secret, safe=""),
)
else:
payload["client_id"] = self.client_id
payload["client_secret"] = self.client_secret
try:
resp = httpx.post(token_endpoint, data=payload, auth=auth, timeout=15.0)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
error_detail = ""
try:
error_detail = exc.response.json().get("error_description", "")
except Exception:
error_detail = exc.response.text[:200]
raise OidcError(
f"Token endpoint returned {exc.response.status_code}: {error_detail}"
) from exc
except Exception as exc:
raise OidcError(f"Token request failed: {exc}") from exc
if "error" in data:
raise OidcError(
f"Token endpoint error: {data.get('error')}{data.get('error_description', '')}"
)
return data
# -- JWKS caching --------------------------------------------------------
def _fetch_jwks(self) -> Dict[str, Any]:
"""Fetch and cache the JWKS, or use cached keys when available.
Returns the full JWKS dict. Keys are cached for reuse; on an unknown
``kid`` the cache is refreshed (one additional fetch per new key
rotation).
"""
# Fast path: cache hit
with self._jwks_cache_lock:
if self._jwks_cache:
return {"keys": list(self._jwks_cache.values())}
# Cache miss — fetch once
return self._refresh_jwks()
def _refresh_jwks(self):
try:
resp = httpx.get(self._config["jwks_uri"], timeout=15.0)
resp.raise_for_status()
jwks = resp.json()
except OidcError:
raise
except Exception as exc:
raise OidcError(f"JWKS fetch/parse failed: {exc}") from exc
keys = jwks.get("keys", [])
with self._jwks_cache_lock:
self._jwks_cache.clear()
for k in keys:
kid = k.get("kid", "")
if kid:
self._jwks_cache[kid] = k
# Always keep at least one entry even without kid
if not self._jwks_cache and keys:
self._jwks_cache["_default"] = keys[0]
return jwks
# -- id_token verification -----------------------------------------------
def _verify_id_token(self, id_token: str, nonce: str) -> Dict[str, Any]:
"""Verify the id_token signature and claims. Returns the decoded payload."""
from authlib.jose import jwt, JsonWebKey
from authlib.jose.errors import JoseError
header = self._peek_jwt_header(id_token)
alg = header.get("alg", "")
kid = header.get("kid", "")
# Reject disallowed algorithms before importing keys or verifying the
# signature. This keeps the JOSE policy independent of Authlib's
# decoded-claims header API.
if not alg or (self._allowed_algs and alg not in self._allowed_algs):
raise OidcError(
f"id_token signed with disallowed algorithm {alg!r} "
f"(allowed: {self._allowed_algs!r})"
)
# Fetch or refresh JWKS
jwks = self._fetch_jwks()
# If the kid from the token header is unknown, refresh the cache.
# Guarded by a cooldown so an attacker can't drive unbounded
# outbound fetches by sending random kid values to the callback.
refresh_jwks = False
if kid:
with self._jwks_cache_lock:
if kid not in self._jwks_cache:
now = time.time()
last_refresh = getattr(self, "_last_jwks_refresh", 0)
if now - last_refresh >= 60:
logger.info("OIDC JWKS cache miss for kid=%r — refreshing", kid)
# Record the attempt before I/O so failed refreshes
# are throttled too. The lock protects this marker
# against concurrent callback threads.
self._last_jwks_refresh = now
refresh_jwks = True
else:
logger.warning(
"OIDC JWKS cache miss for kid=%r but refresh on cooldown "
"(%.0fs remaining)",
kid, 60 - (now - last_refresh),
)
if refresh_jwks:
jwks = self._refresh_jwks()
# authlib needs a key set in the format it expects
try:
key_set = JsonWebKey.import_key_set(jwks)
except Exception as exc:
raise OidcError(f"Failed to import JWKS: {exc}") from exc
# Decode (signature verification via JWKS) with pinned algorithms
try:
claims = jwt.decode(id_token, key_set)
except JoseError as exc:
raise OidcError(f"id_token signature verification failed: {exc}") from exc
claims = dict(claims)
# Manual claim validation — more explicit and version-agnostic
expected_issuer = self._config.get("issuer") or self.issuer
if claims.get("iss") != expected_issuer:
raise OidcError(
f"id_token iss mismatch: expected {expected_issuer!r}, got {claims.get('iss')!r}"
)
# Validate audience: aud may be a string or a JSON array.
# Normalize to a list first so a single-element array (e.g.
# ["client_id"]) is treated identically to a string aud.
# OIDC Core 1.0 § 2: azp is REQUIRED when aud contains multiple
# values, and MUST equal client_id. We reject multi-audience tokens
# without azp — there is no trusted-additional-audience model.
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if self.client_id not in aud_list:
raise OidcError(
f"id_token aud mismatch: client_id {self.client_id!r} not in aud {aud!r}"
)
if len(aud_list) > 1 and not claims.get("azp"):
raise OidcError(
"id_token has multiple audiences but no azp claim "
"(required by OIDC Core 1.0 § 2)"
)
# OIDC Core § 2: whenever azp is present, it must identify this RP,
# including single-audience tokens.
azp = claims.get("azp")
if azp is not None and azp != self.client_id:
raise OidcError(
f"id_token azp mismatch: expected {self.client_id!r}, got {azp!r}"
)
exp = claims.get("exp", 0)
if not _is_numericdate(exp) or time.time() > exp:
raise OidcError(f"id_token expired at {exp}")
# Verify nonce with constant-time comparison.
token_nonce = claims.get("nonce", "")
if not isinstance(token_nonce, str) or not secrets.compare_digest(token_nonce, nonce):
raise OidcError("id_token nonce mismatch")
# Verify auth_time when max_age was requested.
# Validate NumericDate strictly — reject non-numeric,
# boolean, NaN/infinite, missing, or future values.
if self.max_age is not None:
auth_time = claims.get("auth_time")
if not _is_numericdate(auth_time):
raise OidcError(
f"id_token missing or non-numeric auth_time claim "
f"(required when OIDC_MAX_AGE={self.max_age})"
)
now = time.time()
if auth_time > now + 60:
raise OidcError(
f"id_token auth_time {auth_time} is more than 60 s in "
f"the future (clock skew?)"
)
if now - auth_time > self.max_age + 60:
raise OidcError(
f"id_token auth_time {auth_time} exceeds max_age "
f"{self.max_age} s (now={now:.0f}, age={now - auth_time:.0f} s)"
)
# Verify iat (issued-at): required by OIDC Core §2, must be numeric
# and not in the far future.
iat = claims.get("iat")
if iat is None:
raise OidcError("id_token missing iat claim")
if not _is_numericdate(iat):
raise OidcError(f"id_token iat claim is non-numeric: {iat!r}")
if iat > time.time() + 60:
raise OidcError(
f"id_token iat {iat} is more than 60 s in the future"
)
return claims
@staticmethod
def _peek_jwt_header(id_token: str) -> Dict[str, Any]:
"""Extract the JWT header without verifying the signature."""
try:
parts = id_token.split(".")
if len(parts) >= 2:
import base64
# Pad to a multiple of 4 (base64url)
pad_len = (-len(parts[0])) % 4
padded = parts[0] + ("=" * pad_len)
header = json.loads(base64.urlsafe_b64decode(padded))
return header if isinstance(header, dict) else {}
except Exception:
pass
return {}
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 None
resp = httpx.get(
userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15.0,
)
resp.raise_for_status()
return resp.json()
# ---------------------------------------------------------------------------
# Module-level convenience
# ---------------------------------------------------------------------------
_oidc_manager: Optional[OidcManager] = None
_oidc_init_error: Optional[str] = None
def init_oidc_manager() -> Optional[OidcManager]:
"""Create the singleton OidcManager from env vars, or return None if disabled."""
global _oidc_manager, _oidc_init_error
if _oidc_manager is not None:
return _oidc_manager
enabled = os.getenv("OIDC_ENABLED", "false").lower() == "true"
if not enabled:
return None
issuer = os.getenv("OIDC_ISSUER", "").strip()
client_id = os.getenv("OIDC_CLIENT_ID", "").strip()
client_secret = os.getenv("OIDC_CLIENT_SECRET", "").strip()
scopes = os.getenv("OIDC_SCOPES", "openid profile email").strip()
scope_list = [s for s in scopes.split() if s]
if "openid" not in scope_list:
scope_list.insert(0, "openid")
scopes = " ".join(scope_list)
if not issuer or not client_id or not client_secret:
_oidc_init_error = (
"OIDC_ENABLED=true but OIDC_ISSUER, OIDC_CLIENT_ID, or "
"OIDC_CLIENT_SECRET is missing"
)
logger.warning(_oidc_init_error)
return None
try:
max_age = _parse_max_age()
_oidc_manager = OidcManager(
issuer=issuer,
client_id=client_id,
client_secret=client_secret,
scopes=scopes,
max_age=max_age,
)
except OidcError as exc:
_oidc_init_error = str(exc)
logger.error("OIDC init failed: %s", exc)
return None
return _oidc_manager
def _parse_max_age() -> Optional[int]:
"""Parse OIDC_MAX_AGE into an integer or None. Returns None when
unset/empty, raises OidcError on invalid values."""
raw = os.getenv("OIDC_MAX_AGE", "").strip()
if not raw:
return None
try:
value = int(raw)
except ValueError:
raise OidcError(
f"OIDC_MAX_AGE must be an integer, got {raw!r}"
) from None
if value < 0:
raise OidcError(
f"OIDC_MAX_AGE must be >= 0, got {value}"
)
return value
def get_oidc_manager() -> Optional[OidcManager]:
"""Return the singleton OidcManager (may be None if disabled or init failed)."""
return _oidc_manager
def get_oidc_init_error() -> Optional[str]:
"""Return the init error string, if any."""
return _oidc_init_error

View file

@ -50,6 +50,16 @@ services:
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- OIDC_ENABLED=${OIDC_ENABLED:-false}
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

View file

@ -49,6 +49,16 @@ services:
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- OIDC_ENABLED=${OIDC_ENABLED:-false}
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

View file

@ -38,6 +38,16 @@ services:
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- OIDC_ENABLED=${OIDC_ENABLED:-false}
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

View file

@ -44,6 +44,7 @@ bcrypt
mcp<2
pyotp
qrcode[pil]
authlib>=1.3.0,<2
croniter
pytest
pytest-asyncio

View file

@ -84,6 +84,26 @@ class SetOpenRegistrationRequest(BaseModel):
SESSION_COOKIE = "odysseus_session"
def _session_cookie_secure(request: Request) -> bool:
"""Secure flag for the password-login session cookie.
SECURE_COOKIES=true always wins. Unlike the historical behaviour,
SECURE_COOKIES=false (the bundled Compose default) can no longer
downgrade the cookie when the request itself arrived over HTTPS
a stock TLS deployment must not issue a bearer cookie eligible for
cleartext transmission. X-Forwarded-Proto is honoured only when the
deployment explicitly opts in via TRUST_PROXY_HEADERS, so a client
cannot influence cookie policy with a spoofed header.
"""
if os.getenv("SECURE_COOKIES", "").strip().lower() in ("true", "1", "yes"):
return True
forwarded = ""
if os.getenv("TRUST_PROXY_HEADERS", "").strip().lower() in ("true", "1", "yes"):
forwarded = getattr(request, "headers", {}).get("x-forwarded-proto", "")
scheme = forwarded or getattr(getattr(request, "url", None), "scheme", "") or "http"
return scheme == "https"
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -157,7 +177,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
value=token,
httponly=True,
samesite="lax",
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
secure=_session_cookie_secure(request),
path="/",
)
if body.remember:
@ -186,6 +206,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
u = result.get("username")
if u:
result["privileges"] = auth_manager.get_privileges(u)
result["is_oidc"] = auth_manager.is_oidc_user(u)
except Exception:
pass
return result
@ -200,6 +221,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
if auth_manager.is_oidc_user(user):
raise HTTPException(400, "OIDC users don't have a password — manage credentials through your identity provider")
if len(body.new_password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
current_token = request.cookies.get(SESSION_COOKIE)
@ -219,6 +242,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
if auth_manager.is_oidc_user(user):
raise HTTPException(400, "Two-factor authentication is managed by your identity provider for OIDC users")
if auth_manager.totp_enabled(user):
raise HTTPException(400, "2FA is already enabled")
secret = auth_manager.totp_generate_secret(user)
@ -242,6 +267,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
if auth_manager.is_oidc_user(user):
raise HTTPException(400, "Two-factor authentication is managed by your identity provider for OIDC users")
if not auth_manager.totp_confirm_enable(user, body.code):
raise HTTPException(400, "Invalid code — try again")
backup = auth_manager.users.get(user, {}).get("totp_backup_codes", [])
@ -256,6 +283,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
if auth_manager.is_oidc_user(user):
raise HTTPException(400, "Two-factor authentication is managed by your identity provider for OIDC users")
if not auth_manager.totp_disable(user, body.password):
raise HTTPException(400, "Invalid password")
return {"ok": True}

336
routes/oidc_routes.py Normal file
View file

@ -0,0 +1,336 @@
"""OpenID Connect authentication routes — login, callback, config."""
import asyncio
import functools
import logging
import os
import secrets
from typing import Optional
from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse, JSONResponse
from core.auth import AuthManager
from core.oidc import OidcManager, OidcError
logger = logging.getLogger(__name__)
SESSION_COOKIE = "odysseus_session"
OIDC_CSRF_COOKIE = "odysseus_oidc_csrf"
OIDC_CSRF_MAX_AGE = 600 # 10 minutes, matches state TTL
def _admin_group_list() -> list:
"""Return the parsed OIDC_ADMIN_GROUPS list, or empty."""
return [
g.strip()
for g in os.getenv("OIDC_ADMIN_GROUPS", "").split(",")
if g.strip()
]
def setup_oidc_routes(
auth_manager: AuthManager,
oidc_manager: Optional[OidcManager],
) -> APIRouter:
router = APIRouter(prefix="/api/auth/oidc", tags=["oidc"])
def _build_oidc_error_redirect(error_code: str, state: str | None = None) -> RedirectResponse:
"""Build a RedirectResponse that both redirects to /login with
an error and clears the OIDC CSRF cookie."""
from urllib.parse import urlencode
params = {"error": error_code}
if state:
params["state"] = state
qs = urlencode(params)
response = RedirectResponse(url=f"/login?{qs}", status_code=302)
response.delete_cookie(
key=OIDC_CSRF_COOKIE, path="/api/auth/oidc/callback",
)
return response
@router.get("/config")
async def oidc_config():
"""Return public OIDC configuration for the login page.
Never exposes the client secret only enough for the frontend to
decide whether to show the OIDC login button.
"""
from core.oidc import get_oidc_init_error
if oidc_manager is None or not oidc_manager.configured:
init_error = get_oidc_init_error()
if init_error:
logger.warning("OIDC config endpoint: %s", init_error)
return {"enabled": False, "error": "OIDC not configured"}
return {
"enabled": True,
"provider_name": oidc_manager.provider_name,
}
@router.get("/login")
async def oidc_login(request: Request):
"""Initiate OIDC authorization code flow.
Sets an HttpOnly CSRF cookie bound to the state token so the
callback can verify the same browser that started the flow is
the one completing it (login CSRF protection).
"""
if oidc_manager is None or not oidc_manager.configured:
return JSONResponse(
{"error": "OIDC is not configured"}, status_code=503,
)
# Use OIDC_REDIRECT_URI when explicitly configured (proxy-safe).
# Otherwise derive from the inbound request — acceptable for
# single-host deployments but depends on accurate Host header
# behind proxies.
redirect_uri = oidc_manager.redirect_uri_override
if not redirect_uri:
base = str(request.base_url).rstrip("/")
redirect_uri = f"{base}/api/auth/oidc/callback"
try:
auth_url, _state, _ = oidc_manager.get_authorization_url(redirect_uri)
except OidcError as exc:
logger.error("Failed to build OIDC authorization URL: %s", exc)
return RedirectResponse(
url=f"/login?error=oidc_config", status_code=302,
)
# Set a CSRF cookie binding the state to this browser. The
# callback verifies state == csrf_cookie before proceeding.
response = RedirectResponse(url=auth_url, status_code=302)
response.set_cookie(
key=OIDC_CSRF_COOKIE,
value=_state,
httponly=True,
samesite="lax",
secure=_oidc_cookie_secure(),
path="/api/auth/oidc/callback",
max_age=OIDC_CSRF_MAX_AGE,
)
return response
@router.get("/callback")
async def oidc_callback(request: Request, response: Response):
"""Handle the OIDC provider's redirect after authentication.
Verifies CSRF state cookie, exchanges the authorization code for
tokens, validates the id_token, then creates (or looks up) the
local user account and sets a session cookie.
"""
if oidc_manager is None or not oidc_manager.configured:
resp = JSONResponse({"error": "OIDC is not configured"}, status_code=503)
resp.delete_cookie(key=OIDC_CSRF_COOKIE, path="/api/auth/oidc/callback")
return resp
code = request.query_params.get("code")
state = request.query_params.get("state")
error = request.query_params.get("error")
error_description = request.query_params.get("error_description", "")
if error:
logger.warning("OIDC provider returned error: %s%s", error, error_description)
return _build_oidc_error_redirect("oidc_denied", state=state)
if not code or not state:
logger.warning("OIDC callback missing code or state")
return _build_oidc_error_redirect("oidc_invalid")
# Verify the CSRF cookie matches the state parameter — ensures
# the browser completing the flow is the same one that started it.
csrf_cookie = request.cookies.get(OIDC_CSRF_COOKIE, "")
if not csrf_cookie or not secrets.compare_digest(csrf_cookie, state):
logger.warning("OIDC CSRF cookie mismatch")
return _build_oidc_error_redirect("oidc_csrf")
# Use OIDC_REDIRECT_URI when explicitly configured (proxy-safe),
# matching the value used in /login.
redirect_uri = oidc_manager.redirect_uri_override
if not redirect_uri:
base = str(request.base_url).rstrip("/")
redirect_uri = f"{base}/api/auth/oidc/callback"
try:
claims = await asyncio.to_thread(oidc_manager.exchange_code, code, state, redirect_uri)
except OidcError as exc:
logger.error("OIDC code exchange failed: %s", exc)
return _build_oidc_error_redirect("oidc_failed")
# Extract identity claims
sub = claims.get("sub", "")
issuer = oidc_manager.issuer
email = claims.get("email", "")
preferred_username = claims.get("preferred_username", "")
name = claims.get("name", "")
groups_claim_present = "groups" in claims
groups = claims.get("groups", [])
# Authoritative group evidence = a well-formed (list) groups claim
# from the verified id_token or the sub-bound UserInfo response.
# A missing or malformed claim is not evidence of membership loss.
groups_evidence_valid = groups_claim_present and isinstance(groups, list)
# Validate claim types before use — malformed IdP claims must not
# cause 500s or unsafe persistence operations.
# The sub is an opaque identifier (OIDC Core §8): it is validated
# for type and bounds but never normalized — trimming whitespace
# could collapse two distinct verified subjects into one local
# account and hand one subject the other's session and privileges.
if not isinstance(sub, str) or not sub:
logger.error("OIDC id_token sub is not a non-empty string: %r", sub)
return _build_oidc_error_redirect("oidc_failed")
if len(sub) > 512:
logger.error("OIDC id_token sub exceeds maximum length")
return _build_oidc_error_redirect("oidc_failed")
if not isinstance(email, str) or len(email) > 512:
email = ""
if not isinstance(preferred_username, str) or len(preferred_username) > 256:
preferred_username = ""
if not isinstance(name, str) or len(name) > 512:
name = ""
if not isinstance(groups, list):
groups = []
else:
clean_groups = []
for group in groups:
if isinstance(group, str):
if len(group) <= 256:
clean_groups.append(group)
else:
logger.warning("OIDC groups claim element exceeds maximum length")
else:
logger.warning(
"OIDC groups claim contained non-string element %r — coerced",
group,
)
normalized_group = str(group)
if len(normalized_group) <= 256:
clean_groups.append(normalized_group)
groups = clean_groups
userinfo_available = claims.pop("_userinfo_available", False)
# Determine admin status from IdP group membership.
# OIDC_ADMIN_GROUPS is a comma-separated list; the user gets
# admin if their `groups` claim intersects with it.
admin_groups = _admin_group_list()
is_admin = False
if admin_groups and groups:
if isinstance(groups, list):
group_set = {str(g) for g in groups}
is_admin = bool(group_set & set(admin_groups))
# Determine username: use preferred_username first, then email
# local-part, then the sub as a last resort.
raw_username = ""
if preferred_username:
raw_username = preferred_username
elif email:
raw_username = email.split("@")[0]
elif name:
raw_username = name
else:
raw_username = sub[:32]
raw_username = raw_username.strip().lower()
if not raw_username:
raw_username = f"oidc_{sub[:16]}"
# Look up or create the user
username = auth_manager.get_user_by_oidc(sub, issuer)
if username is not None:
# Existing OIDC user — only sync admin status from IdP groups
# when group-based admin management is actually configured.
# Otherwise the bootstrap (or manual grant) would be undone
# on the next login.
if admin_groups:
# Only sync admin status when a trusted source (verified
# id_token or sub-bound UserInfo) supplied a well-formed
# groups claim. A missing or malformed claim — e.g. a
# transient provider failure or a scope/configuration
# change — must not silently demote an existing admin.
if groups_evidence_valid:
logger.info("OIDC login for existing user '%s' (admin=%s)", username, is_admin)
auth_manager.set_oidc_user_admin(username, is_admin)
else:
logger.info(
"OIDC login for existing user '%s' — skipping admin sync "
"(no valid groups claim; userinfo_available=%s)",
username,
userinfo_available,
)
else:
logger.info("OIDC login for existing user '%s'", username)
else:
username = auth_manager.create_user_oidc(
raw_username, sub, issuer, email=email, is_admin=is_admin,
)
if username is None:
logger.error("Failed to create OIDC user for sub=%s", sub)
return _build_oidc_error_redirect("oidc_failed")
# Defense-in-depth: refuse to issue an OIDC session for a user
# that somehow has local TOTP enabled (externally-edited auth.json
# or pre-OIDC legacy account).
if await asyncio.to_thread(auth_manager.check_oidc_totp, username):
logger.warning(
"OIDC user '%s' has TOTP enabled — refusing session "
"(TOTP must be managed through the IdP)",
username,
)
return _build_oidc_error_redirect("oidc_failed")
# Issue a session cookie (same as password login)
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
if token is None:
logger.error("Failed to create OIDC session for '%s'", username)
return _build_oidc_error_redirect("oidc_failed")
cookie_kwargs = dict(
key=SESSION_COOKIE,
value=token,
httponly=True,
samesite="lax",
secure=_oidc_cookie_secure(),
path="/",
max_age=60 * 60 * 24 * 7, # 7 days
)
response.set_cookie(**cookie_kwargs)
# Clear the CSRF cookie (single-use)
response.delete_cookie(key=OIDC_CSRF_COOKIE, path="/api/auth/oidc/callback")
response.status_code = 302
response.headers["location"] = "/"
return response
return router
def _oidc_cookie_secure() -> bool:
"""Determine whether OIDC cookies get the Secure flag.
OIDC cookies are Secure by default: SSO implies a real deployment
behind TLS, and deriving the flag from SECURE_COOKIES (which the
bundled Compose files default to false) or the request scheme (which
is http behind a TLS-terminating proxy) would silently issue bearer
session cookies eligible for cleartext transmission.
The only opt-out is the explicit development override
OIDC_ALLOW_INSECURE_COOKIES=true, for plain-HTTP local testing.
Nothing else not SECURE_COOKIES, not the request scheme can
downgrade OIDC cookies.
"""
if os.getenv("OIDC_ALLOW_INSECURE_COOKIES", "").strip().lower() in ("true", "1", "yes"):
_warn_insecure_cookies_once()
return False
return True
@functools.lru_cache(maxsize=1)
def _warn_insecure_cookies_once() -> None:
# Once per process, not once per login — the flag doesn't change at
# runtime and repeating the warning twice per flow is pure log spam.
logger.warning(
"OIDC_ALLOW_INSECURE_COOKIES=true — OIDC session and CSRF "
"cookies are issued without the Secure flag. Never use this "
"outside plain-HTTP local development."
)

View file

@ -20,11 +20,11 @@ single migration pass rewrites them.
import os
import logging
import threading
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from core.platform_compat import safe_chmod
from src.constants import APP_KEY_FILE
logger = logging.getLogger(__name__)
@ -33,24 +33,62 @@ _KEY_PATH = Path(APP_KEY_FILE)
_PREFIX = "enc:"
_fernet: Fernet | None = None
# Serialises first-use key creation so two threads in the same process
# cannot race on the shared temp-file path inside _load_or_create_key().
_key_creation_lock = threading.Lock()
def _load_or_create_key() -> bytes:
# Fast path: key already exists on disk.
if _KEY_PATH.exists():
return _KEY_PATH.read_bytes()
_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
# Slow path: create the key atomically. On a fresh multi-worker
# 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.write_bytes(key)
# POSIX: lock the key to 0o600. Windows: no-op (the user-profile data dir is
# already ACL-restricted); safe_chmod swallows both cases.
safe_chmod(_KEY_PATH, 0o600)
logger.info(f"Generated new app key at {_KEY_PATH}")
return key
_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp_path = _KEY_PATH.parent / f".app_key.tmp.{os.getpid()}"
try:
# 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:
global _fernet
if _fernet is None:
_fernet = Fernet(_load_or_create_key())
with _key_creation_lock:
# Double-check inside the lock — another thread may have
# finished creation while we were waiting.
if _fernet is None:
_fernet = Fernet(_load_or_create_key())
return _fernet

View file

@ -1981,7 +1981,7 @@
</button>
</div>
</div>
<div class="admin-card">
<div class="admin-card" id="settings-pw-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
<div class="settings-col">
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">

View file

@ -2144,6 +2144,13 @@ function initAccount() {
const initial = (d.username || '?')[0].toUpperCase();
avatarEl.textContent = initial;
}
// OIDC users don't have a password — hide password change and 2FA.
if (d.is_oidc) {
const pwCard = document.getElementById('settings-pw-card');
const tfaCard = document.getElementById('settings-2fa-card');
if (pwCard) pwCard.style.display = 'none';
if (tfaCard) tfaCard.style.display = 'none';
}
}).catch(() => {});
// Update password placeholder and policy from server

View file

@ -183,6 +183,20 @@
.toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
.toggle a { color: var(--red); cursor: pointer; text-decoration: none; }
.toggle a:hover { text-decoration: underline; }
/* OIDC / SSO button */
.oidc-divider { display:flex; align-items:center; margin:1.15rem 0 1rem; }
.oidc-divider::before, .oidc-divider::after { content:''; flex:1; border-top:1px solid var(--border); }
.oidc-divider span { padding:0 0.75rem; color:color-mix(in srgb, var(--fg) 40%, transparent); font-size:0.75rem; text-transform:uppercase; }
.oidc-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
width: 100%; padding: 0.65rem 0;
border: 1px solid var(--border); border-radius: 6px;
background: var(--panel); color: var(--fg);
font-size: 0.95rem; font-weight: 500; text-decoration: none; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.oidc-btn:hover { background: color-mix(in srgb, var(--panel) 90%, var(--fg)); border-color: color-mix(in srgb, var(--border) 60%, var(--fg)); }
.oidc-btn svg { flex-shrink: 0; }
.pw-wrapper { position: relative; margin-bottom: 1rem; }
.pw-wrapper input:not(.remember-check) { padding-right: 2.5rem; margin-bottom: 0; }
.pw-toggle {
@ -290,6 +304,14 @@
<button type="submit" id="submitBtn">Sign In</button>
</form>
<div id="oidcSection" style="display:none">
<div class="oidc-divider" aria-hidden="true"><span>or</span></div>
<a class="oidc-btn" id="oidcBtn" href="/api/auth/oidc/login">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
<span id="oidcLabel">Sign in with SSO</span>
</a>
</div>
<div class="toggle" id="toggleArea" style="display:none">
<span id="toggleText">Don't have an account? </span>
<a id="toggleLink" href="#">Sign up</a>
@ -309,6 +331,20 @@
}
} catch(e) {}
// OIDC / SSO button — fetch config and show if enabled
try {
const oc = await fetch('/api/auth/oidc/config');
if (oc.ok) {
const od = await oc.json();
if (od.enabled) {
const section = document.getElementById('oidcSection');
const label = document.getElementById('oidcLabel');
if (section) section.style.display = 'block';
if (label) label.textContent = 'Sign in with ' + (od.provider_name || 'SSO');
}
}
} catch(e) {}
// Prefill last username
const usernameInput = document.getElementById('username');
const savedUser = localStorage.getItem('odysseus-last-user');
@ -332,6 +368,20 @@
const rememberToggle = document.getElementById('rememberToggle');
// Preserve a human-readable OIDC callback error until initial auth setup
// completes, since setMode() clears the error display.
const oidcErrorMessage = (() => {
const errCode = new URLSearchParams(window.location.search).get('error') || '';
const msgs = {
oidc_failed: 'Sign-in failed. Please try again.',
oidc_csrf: 'Security check failed. Please try signing in again.',
oidc_denied: 'Sign-in was cancelled.',
oidc_invalid: 'Invalid sign-in request.',
oidc_config: 'SSO is not configured.',
};
return msgs[errCode] || (errCode.startsWith('oidc_') ? 'Sign-in error. Please try again.' : '');
})();
function setMode(m) {
mode = m;
errEl.style.display = 'none';
@ -385,6 +435,11 @@
setMode('login');
}
if (oidcErrorMessage) {
errEl.textContent = oidcErrorMessage;
errEl.style.display = 'block';
}
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
setMode(mode === 'login' ? 'signup' : 'login');

View file

@ -138,6 +138,7 @@ def test_login_route_does_not_set_cookie_when_trusted_session_rejects_stale_user
def test_change_password_route_revokes_other_sessions_after_success(monkeypatch):
auth = MagicMock()
auth.get_username_for_token.return_value = "alice"
auth.is_oidc_user.return_value = False
auth.change_password.return_value = True
endpoint, ChangePasswordRequest = _change_password_endpoint(auth)
monkeypatch.setattr(
@ -157,6 +158,7 @@ def test_change_password_route_revokes_other_sessions_after_success(monkeypatch)
def test_change_password_route_wrong_password_does_not_revoke(monkeypatch):
auth = MagicMock()
auth.get_username_for_token.return_value = "alice"
auth.is_oidc_user.return_value = False
auth.change_password.return_value = False
endpoint, ChangePasswordRequest = _change_password_endpoint(auth)
monkeypatch.setattr(

803
tests/test_oidc_auth.py Normal file
View file

@ -0,0 +1,803 @@
"""Tests for AuthManager OIDC methods — user creation, lookup, and password rejection."""
import json
import pytest
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _real_auth_module():
"""Import the real core.auth module."""
import importlib, sys
if "core.auth" in sys.modules:
return sys.modules["core.auth"]
import core.auth
return core.auth
def _make_manager(tmp_path: Path):
"""Create an AuthManager pointed at a temp auth.json."""
auth = _real_auth_module()
mgr = auth.AuthManager(str(tmp_path / "auth.json"))
return mgr
# ---------------------------------------------------------------------------
# User creation
# ---------------------------------------------------------------------------
def test_create_user_oidc_basic(tmp_path):
mgr = _make_manager(tmp_path)
username = mgr.create_user_oidc(
"alice", sub="abc123", issuer="https://idp.example.com", email="alice@example.com",
)
assert username == "alice"
assert mgr.is_oidc_user("alice")
assert "alice" in mgr.users
assert mgr.users["alice"]["password_hash"] is None
assert mgr.users["alice"]["oidc_sub"] == "abc123"
assert mgr.users["alice"]["oidc_issuer"] == "https://idp.example.com"
assert mgr.users["alice"]["oidc_email"] == "alice@example.com"
def test_create_user_oidc_lowercases_username(tmp_path):
mgr = _make_manager(tmp_path)
username = mgr.create_user_oidc(
"Alice", sub="abc123", issuer="https://idp.example.com", email="alice@example.com",
)
assert username == "alice"
def test_create_user_oidc_rejects_reserved(tmp_path):
mgr = _make_manager(tmp_path)
for reserved in ("internal-tool", "api", "demo", "system"):
username = mgr.create_user_oidc(
reserved, sub="sub", issuer="https://idp.example.com",
)
assert username is None, f"Should reject reserved username {reserved!r}"
def test_create_user_oidc_empty_username(tmp_path):
mgr = _make_manager(tmp_path)
assert mgr.create_user_oidc(" ", sub="sub", issuer="https://idp.example.com") is None
assert mgr.create_user_oidc("", sub="sub", issuer="https://idp.example.com") is None
def test_create_user_oidc_idempotent(tmp_path):
"""Calling create_user_oidc with the same (sub, issuer) returns the
existing username, even if the suggested raw username differs."""
mgr = _make_manager(tmp_path)
first = mgr.create_user_oidc(
"alice", sub="abc123", issuer="https://idp.example.com", email="alice@example.com",
)
second = mgr.create_user_oidc(
"alice_renamed", sub="abc123", issuer="https://idp.example.com",
)
assert first == "alice"
assert second == "alice" # same identity, no new user created
def test_create_user_oidc_username_collision_with_password_user(tmp_path):
"""When the desired username is taken by a local password user, append
a numeric suffix."""
mgr = _make_manager(tmp_path)
# Create a password user first
ok = mgr.create_user("alice", "hunter2", is_admin=False)
assert ok
# Now try to create an OIDC user with the same username
oidc_user = mgr.create_user_oidc(
"alice", sub="oidc_sub", issuer="https://idp.example.com",
)
assert oidc_user is not None
assert oidc_user != "alice" # should get a different name
assert oidc_user.startswith("alice")
assert mgr.is_oidc_user(oidc_user)
assert not mgr.is_oidc_user("alice") # the password user is unaffected
def test_create_user_oidc_username_collision_with_other_oidc_user(tmp_path):
"""Two OIDC users with different identities but the same preferred
username should get distinct accounts."""
mgr = _make_manager(tmp_path)
alice1 = mgr.create_user_oidc(
"alice", sub="sub1", issuer="https://idp1.example.com",
)
alice2 = mgr.create_user_oidc(
"alice", sub="sub2", issuer="https://idp2.example.com",
)
assert alice1 == "alice"
assert alice2 is not None
assert alice2 != "alice"
assert alice2.startswith("alice")
assert mgr.is_oidc_user(alice1)
assert mgr.is_oidc_user(alice2)
def test_create_user_oidc_multiple_collisions(tmp_path):
"""A large number of collisions still resolves (suffix increment on each)."""
mgr = _make_manager(tmp_path)
# Create 5 users named "bob" through different identities
usernames = set()
for i in range(5):
u = mgr.create_user_oidc(
"bob", sub=f"sub_{i}", issuer="https://idp.example.com",
)
assert u is not None
usernames.add(u)
assert len(usernames) == 5
assert "bob" in usernames
assert "bob2" in usernames or "bob3" in usernames
# ---------------------------------------------------------------------------
# get_user_by_oidc
# ---------------------------------------------------------------------------
def test_get_user_by_oidc_found(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc(
"alice", sub="abc123", issuer="https://idp.example.com",
)
assert mgr.get_user_by_oidc("abc123", "https://idp.example.com") == "alice"
def test_get_user_by_oidc_wrong_sub(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert mgr.get_user_by_oidc("wrong_sub", "https://idp.example.com") is None
def test_get_user_by_oidc_wrong_issuer(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert mgr.get_user_by_oidc("abc123", "https://other-idp.example.com") is None
def test_get_user_by_oidc_no_users(tmp_path):
mgr = _make_manager(tmp_path)
assert mgr.get_user_by_oidc("any", "https://any.example.com") is None
# ---------------------------------------------------------------------------
# is_oidc_user
# ---------------------------------------------------------------------------
def test_is_oidc_user_true(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert mgr.is_oidc_user("alice")
def test_is_oidc_user_false_for_password_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "hunter2")
assert not mgr.is_oidc_user("bob")
def test_is_oidc_user_false_for_nonexistent(tmp_path):
mgr = _make_manager(tmp_path)
assert not mgr.is_oidc_user("ghost")
# ---------------------------------------------------------------------------
# Password rejection for OIDC users
# ---------------------------------------------------------------------------
def test_verify_password_rejects_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert not mgr.verify_password("alice", "any_password")
def test_create_session_rejects_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
token = mgr.create_session("alice", "any_password")
assert token is None
def test_change_password_rejects_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
ok = mgr.change_password("alice", "any_password", "new_password")
assert not ok
def test_oidc_user_session_via_create_session_trusted(tmp_path):
"""An OIDC user can still get a session via the trusted path (used
after successful OIDC flow)."""
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
token = mgr.create_session_trusted("alice")
assert token is not None
assert mgr.validate_token(token)
assert mgr.get_username_for_token(token) == "alice"
# ---------------------------------------------------------------------------
# list_users includes OIDC info
# ---------------------------------------------------------------------------
def test_list_users_includes_oidc_info(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "hunter2")
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com",
email="alice@example.com")
users = mgr.list_users()
alice_entry = next((u for u in users if u["username"] == "alice"), None)
bob_entry = next((u for u in users if u["username"] == "bob"), None)
assert alice_entry is not None
assert alice_entry.get("oidc") is True
assert alice_entry.get("oidc_issuer") == "https://idp.example.com"
assert alice_entry.get("oidc_email") == "alice@example.com"
assert bob_entry is not None
assert bob_entry.get("oidc") is None # password users don't have oidc flag
# ---------------------------------------------------------------------------
# set_oidc_user_admin
# ---------------------------------------------------------------------------
def test_set_oidc_user_admin_promotes(tmp_path):
mgr = _make_manager(tmp_path)
# Disable auto-bootstrap so is_admin=False is respected
import os
os.environ["OIDC_FIRST_USER_IS_ADMIN"] = "false"
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=False)
assert not mgr.is_admin("alice")
assert mgr.set_oidc_user_admin("alice", True)
assert mgr.is_admin("alice")
# Privileges should be upgraded to ADMIN_PRIVILEGES
privs = mgr.get_privileges("alice")
assert privs["can_use_bash"] is True
def test_set_oidc_user_admin_demotes(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=True)
# A second administrator must exist before group sync can demote alice;
# otherwise the last-admin guard preserves recoverability.
mgr.create_user("recovery", "password123", is_admin=True)
assert mgr.is_admin("alice")
assert mgr.set_oidc_user_admin("alice", False)
assert not mgr.is_admin("alice")
# Privileges should be downgraded to DEFAULT_PRIVILEGES
privs = mgr.get_privileges("alice")
assert privs["can_use_bash"] is False
def test_set_oidc_user_admin_refuses_last_admin_demotion(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=True)
assert not mgr.set_oidc_user_admin("alice", False)
assert mgr.is_admin("alice")
def test_set_oidc_user_admin_noop_when_unchanged(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=False)
assert mgr.set_oidc_user_admin("alice", False) # still returns True
assert not mgr.is_admin("alice")
def test_set_oidc_user_admin_rejects_non_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "hunter2", is_admin=False)
# Can't promote a password user via this method
assert not mgr.set_oidc_user_admin("bob", True)
assert not mgr.is_admin("bob")
def test_set_oidc_user_admin_rejects_nonexistent(tmp_path):
mgr = _make_manager(tmp_path)
assert not mgr.set_oidc_user_admin("ghost", True)
def test_first_user_bootstrap_suppressed_when_admin_groups_configured(
tmp_path, monkeypatch,
):
"""When OIDC_ADMIN_GROUPS is set, the first OIDC user must be in a
group to get admin bootstrap does NOT override group-based policy."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins")
mgr = _make_manager(tmp_path)
username = mgr.create_user_oidc(
"alice", sub="abc", issuer="https://idp.example.com", is_admin=False,
)
assert username == "alice"
assert not mgr.is_admin("alice"), (
"First OIDC user should NOT be admin when OIDC_ADMIN_GROUPS is set "
"and they are not in a group"
)
# ---------------------------------------------------------------------------
# Route-level OIDC guards — 2FA and change-password
# ---------------------------------------------------------------------------
class TestOidcRouteGuards:
"""The auth routes reject local 2FA / password mutations for OIDC users.
OIDC users authenticate through their identity provider; local password
and TOTP controls are not applicable. The frontend already hides these
cards, but the backend must also enforce the policy so a direct API call
cannot create a misleading or stuck 2FA state."""
@pytest.fixture
def setup_router(self, tmp_path):
"""Create an auth router backed by a temp AuthManager with one OIDC user."""
from routes.auth_routes import setup_auth_routes
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com")
# Issue a session so the user is "logged in"
token = mgr.create_session_trusted("alice")
router = setup_auth_routes(mgr)
return router, mgr, token
def _get(self, router, path):
for route in router.routes:
if getattr(route, "path", "") == path:
return route.endpoint
raise AssertionError(f"No route for {path}")
def _fake_req(self, token):
"""Build a fake request with the session cookie set."""
from types import SimpleNamespace
req = SimpleNamespace()
req.cookies = {"odysseus_session": token}
req.client = SimpleNamespace()
req.client.host = "127.0.0.1"
return req
def test_change_password_rejected_for_oidc_user(self, setup_router):
router, mgr, token = setup_router
ep = self._get(router, "/api/auth/change-password")
from pydantic import BaseModel
class PW(BaseModel):
current_password: str = "x"
new_password: str = "password123"
import asyncio
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
asyncio.run(ep(PW(), self._fake_req(token)))
assert exc.value.status_code == 400
assert "OIDC" in exc.value.detail
def test_2fa_setup_rejected_for_oidc_user(self, setup_router):
router, mgr, token = setup_router
ep = self._get(router, "/api/auth/2fa/setup")
import asyncio
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
asyncio.run(ep(self._fake_req(token)))
assert exc.value.status_code == 400
assert "identity provider" in exc.value.detail.lower()
def test_2fa_confirm_rejected_for_oidc_user(self, setup_router):
router, mgr, token = setup_router
ep = self._get(router, "/api/auth/2fa/confirm")
from pydantic import BaseModel
class TOTP(BaseModel):
code: str = "123456"
import asyncio
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
asyncio.run(ep(TOTP(), self._fake_req(token)))
assert exc.value.status_code == 400
assert "identity provider" in exc.value.detail.lower()
def test_2fa_disable_rejected_for_oidc_user(self, setup_router):
router, mgr, token = setup_router
ep = self._get(router, "/api/auth/2fa/disable")
from pydantic import BaseModel
class DisableTOTP(BaseModel):
password: str = "x"
import asyncio
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
asyncio.run(ep(DisableTOTP(), self._fake_req(token)))
assert exc.value.status_code == 400
assert "identity provider" in exc.value.detail.lower()
def test_password_user_still_can_use_2fa(self, setup_router):
"""Regression: local password users must still be able to manage 2FA."""
router, mgr, token = setup_router
# Add a local password user
mgr.create_user("bob", "hunter2")
bob_token = mgr.create_session_trusted("bob")
ep = self._get(router, "/api/auth/2fa/setup")
import asyncio
# Should NOT raise — bob is a password user
result = asyncio.run(ep(self._fake_req(bob_token)))
assert "secret" in result
assert "uri" in result
class TestFirstOidcAdminBootstrapConcurrency:
"""Regression: two concurrent first-OIDC-login callbacks must not
both persist as admin. The first-user bootstrap is serialized
inside _config_lock."""
def test_concurrent_first_oidc_users_only_one_admin(self, monkeypatch):
"""Simulate two fresh callbacks racing to create the first OIDC
user. The lock guarantees exactly one bootstrap admin, not two."""
monkeypatch.setenv("OIDC_FIRST_USER_IS_ADMIN", "true")
monkeypatch.delenv("OIDC_ADMIN_GROUPS", raising=False)
from core.auth import AuthManager
import threading
import tempfile
import os
auth_path = os.path.join(tempfile.mkdtemp(), "auth.json")
# Start with an empty auth store
with open(auth_path, "w") as f:
json.dump({}, f)
mgr = AuthManager(auth_path)
assert len(mgr.users) == 0
results = []
errors = []
def create_user_a():
try:
u = mgr.create_user_oidc("alice", "sub-a", "https://idp.example.com")
results.append(("alice", u, mgr.users.get(u, {}).get("is_admin", False)))
except Exception as e:
errors.append(e)
def create_user_b():
try:
u = mgr.create_user_oidc("bob", "sub-b", "https://idp.example.com")
results.append(("bob", u, mgr.users.get(u, {}).get("is_admin", False)))
except Exception as e:
errors.append(e)
# Start both threads and wait for completion
t1 = threading.Thread(target=create_user_a)
t2 = threading.Thread(target=create_user_b)
t1.start()
t2.start()
t1.join()
t2.join()
assert len(errors) == 0, f"Unexpected errors: {errors}"
assert len(results) == 2
# Exactly one user must be admin — the first one through the lock.
admin_count = sum(1 for _, _, is_admin in results if is_admin)
assert admin_count == 1, (
f"Expected exactly 1 bootstrap admin, got {admin_count}. "
f"Results: {results}"
)
def test_concurrent_same_identity_idempotent(self, monkeypatch):
"""Two concurrent create_user_oidc calls for the same OIDC identity
must return the same username (idempotent inside the lock)."""
monkeypatch.delenv("OIDC_ADMIN_GROUPS", raising=False)
from core.auth import AuthManager
import threading
import tempfile
import os
auth_path = os.path.join(tempfile.mkdtemp(), "auth.json")
with open(auth_path, "w") as f:
json.dump({}, f)
mgr = AuthManager(auth_path)
results = []
def create_same():
u = mgr.create_user_oidc("charlie", "sub-c", "https://idp.example.com")
results.append(u)
t1 = threading.Thread(target=create_same)
t2 = threading.Thread(target=create_same)
t1.start()
t2.start()
t1.join()
t2.join()
assert len(results) == 2
# Both must return the same username — no duplicates
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"
def test_set_oidc_user_admin_preserves_concurrent_user(self, tmp_path):
"""set_oidc_user_admin() must not overwrite users created by another
manager. Manager A creates 'alice' and 'bob', but Manager B has a
stale in-memory snapshot. When B calls set_oidc_user_admin for
'alice', the inter-process lock + reload must preserve 'bob'."""
from core.auth import AuthManager
auth_path = str(tmp_path / "auth.json")
# Manager A: create two OIDC users
mgr_a = AuthManager(auth_path)
alice = mgr_a.create_user_oidc("alice", "sub-a", "https://idp.example.com")
assert alice == "alice"
bob = mgr_a.create_user_oidc("bob", "sub-b", "https://idp.example.com")
assert bob == "bob"
# Make both OIDC users admins so demoting alice does not trigger the
# last-admin guard, and the no-op short-circuit doesn't trigger.
mgr_a._config["users"]["alice"]["is_admin"] = True
mgr_a._config["users"]["bob"]["is_admin"] = True
mgr_a._save()
# Manager B: loaded from disk but now we make its in-memory state
# stale by directly removing 'bob' from its _config (simulating a
# worker that loaded before Manager A created 'bob').
mgr_b = AuthManager(auth_path)
assert "bob" in mgr_b._config["users"]
del mgr_b._config["users"]["bob"]
# B calls set_oidc_user_admin for alice (demote). The inter-process
# lock must force a reload, re-discovering 'bob', so the save does
# not clobber bob.
result = mgr_b.set_oidc_user_admin("alice", False)
assert result is True
# Reload both managers — bob must still exist.
mgr_a._load()
mgr_b._load()
assert "bob" in mgr_a._config["users"], "bob must survive stale set_oidc_user_admin"
assert "bob" in mgr_b._config["users"], "bob must survive stale set_oidc_user_admin"
assert not mgr_a._config["users"]["alice"].get("is_admin"), "alice must be demoted"
def test_fcntl_import_guarded(self, monkeypatch):
"""On a platform without fcntl (simulated), AuthManager must still
import and _interprocess_auth_lock must degrade to intra-process-only."""
import sys
import builtins
# Simulate missing fcntl by hiding it from the import system
real_import = builtins.__import__
def blocking_import(name, *args, **kwargs):
if name == "fcntl":
raise ImportError("No module named 'fcntl'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", blocking_import)
# Force a fresh import of core.auth
import importlib
if "core.auth" in sys.modules:
del sys.modules["core.auth"]
import core.auth as auth_mod
assert auth_mod.HAS_FCNTL is False
assert auth_mod.fcntl is None
# Construct an AuthManager — it must not crash.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
mgr = auth_mod.AuthManager(str(Path(tmp) / "auth.json"))
# The lock context manager must yield without error.
with mgr._interprocess_auth_lock():
pass
def test_secret_storage_key_creation_thread_safe(self, tmp_path, monkeypatch):
"""Two threads racing into _get_fernet() on a fresh data dir must
both receive the same valid Fernet key without exceptions."""
import src.secret_storage as ss
import threading
tmp_key = tmp_path / ".app_key"
monkeypatch.setattr(ss, "_KEY_PATH", tmp_key)
monkeypatch.setattr(ss, "_fernet", None)
results = {}
errors = []
barrier = threading.Barrier(2, timeout=5)
def get_key(idx):
try:
barrier.wait()
f = ss._get_fernet()
results[idx] = f
except Exception as e:
errors.append((idx, e))
t0 = threading.Thread(target=get_key, args=(0,))
t1 = threading.Thread(target=get_key, args=(1,))
t0.start()
t1.start()
t0.join()
t1.join()
assert not errors, f"Unexpected errors in thread race: {errors}"
assert len(results) == 2, f"Expected 2 results, got {len(results)}"
f0, f1 = results[0], results[1]
# Both must be usable Fernet instances that encrypt/decrypt compatibly.
token = f0.encrypt(b"hello")
assert f1.decrypt(token) == b"hello"
token = f1.encrypt(b"world")
assert f0.decrypt(token) == b"world"
def test_create_user_survives_concurrent_set_oidc_user_admin(self, tmp_path):
"""create_user() (password user) must not be lost when a concurrent
set_oidc_user_admin() executes on another manager. Both operations
now take the inter-process lock, so the second operation must reload
and see the first operation's result."""
from core.auth import AuthManager
import threading
auth_path = str(tmp_path / "auth.json")
# Manager A: create an OIDC user (alice) then make her admin so the
# no-op short-circuit in set_oidc_user_admin doesn't trigger. A
# recovery admin ensures concurrent demotion is permitted.
mgr_a = AuthManager(auth_path)
alice = mgr_a.create_user_oidc("alice", "sub-a", "https://idp.example.com")
assert alice == "alice"
mgr_a._config["users"]["alice"]["is_admin"] = True
mgr_a._save()
assert mgr_a.create_user("recovery", "password123", is_admin=True)
# Manager B: a separate instance with the same auth file.
mgr_b = AuthManager(auth_path)
results = {}
barrier = threading.Barrier(2, timeout=5)
def do_create_user():
barrier.wait()
ok = mgr_a.create_user("bob", "password123")
results["create"] = ok
def do_sync_admin():
barrier.wait()
ok = mgr_b.set_oidc_user_admin("alice", False)
results["sync"] = ok
t_create = threading.Thread(target=do_create_user)
t_sync = threading.Thread(target=do_sync_admin)
t_create.start()
t_sync.start()
t_create.join()
t_sync.join()
assert results.get("create") is True, "create_user must succeed"
assert results.get("sync") is True, "set_oidc_user_admin must succeed"
# Reload both — bob and alice must both exist.
mgr_a._load()
mgr_b._load()
users_a = mgr_a._config.get("users", {})
users_b = mgr_b._config.get("users", {})
assert "bob" in users_a, f"bob must survive concurrent admin sync; users: {list(users_a)}"
assert "bob" in users_b, f"bob must survive concurrent admin sync; users: {list(users_b)}"
assert "alice" in users_a
assert "alice" in users_b
assert not users_a["alice"].get("is_admin"), "alice must be demoted"
# ---------------------------------------------------------------------------
# OIDC/TOTP defense-in-depth
# ---------------------------------------------------------------------------
def test_check_oidc_totp_returns_false_for_normal_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("alice", "password123")
assert mgr.check_oidc_totp("alice") is False
def test_check_oidc_totp_returns_true_for_oidc_user_with_totp(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
mgr._config["users"]["alice"]["totp_enabled"] = True
mgr._save()
# check_oidc_totp reloads auth.json, so this reflects an externally
# persisted mutation rather than only the in-memory dictionary.
assert mgr.check_oidc_totp("alice") is True
def test_check_oidc_totp_returns_false_for_non_oidc_user_with_totp(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("alice", "password123")
mgr._config["users"]["alice"]["totp_enabled"] = True
mgr._save()
assert mgr.check_oidc_totp("alice") is False

1667
tests/test_oidc_manager.py Normal file

File diff suppressed because it is too large Load diff

1229
tests/test_oidc_routes.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
"""Regression: the password-login session cookie must be Secure whenever
the request arrived over HTTPS, even with SECURE_COOKIES=false (the
bundled Compose default) a stock TLS deployment must not issue a bearer
cookie eligible for cleartext transmission."""
from types import SimpleNamespace
def _fake_request(scheme="https", headers=None):
req = SimpleNamespace()
req.url = SimpleNamespace()
req.url.scheme = scheme
req.headers = headers or {}
return req
class TestSessionCookieSecure:
def _secure(self, request):
from routes.auth_routes import _session_cookie_secure
return _session_cookie_secure(request)
def test_https_request_secure_despite_secure_cookies_false(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.delenv("TRUST_PROXY_HEADERS", raising=False)
assert self._secure(_fake_request("https")) is True
def test_explicit_true_always_secure(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "true")
assert self._secure(_fake_request("http")) is True
def test_plain_http_not_secure(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.delenv("TRUST_PROXY_HEADERS", raising=False)
assert self._secure(_fake_request("http")) is False
def test_forwarded_proto_ignored_without_trust_optin(self, monkeypatch):
"""A client-spoofed X-Forwarded-Proto must not influence policy."""
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.delenv("TRUST_PROXY_HEADERS", raising=False)
req = _fake_request("http", {"x-forwarded-proto": "https"})
assert self._secure(req) is False
def test_forwarded_proto_honoured_with_trust_optin(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.setenv("TRUST_PROXY_HEADERS", "true")
req = _fake_request("http", {"x-forwarded-proto": "https"})
assert self._secure(req) is True

View file

@ -0,0 +1,151 @@
"""Regression: sessions issued by one uvicorn worker must validate on another.
The OIDC callback (or a password login) can run on worker A while the
browser's next request lands on worker B. Each worker loads sessions.json
only at startup, so without the read-through reload in
AuthManager._reload_sessions_if_changed the new token would be rejected
and a successful login would immediately become a logged-out session.
"""
import importlib
import sys
import types
from pathlib import Path
from tests.helpers.import_state import clear_module
def _real_core_package():
root = Path(__file__).resolve().parent.parent
core_path = str(root / "core")
core = sys.modules.get("core")
if core is None:
core = types.ModuleType("core")
sys.modules["core"] = core
core.__path__ = [core_path]
clear_module("core.auth")
return core
def _auth_module():
_real_core_package()
return importlib.import_module("core.auth")
def _two_workers(tmp_path):
"""Build two AuthManager instances over the same data directory,
simulating two uvicorn worker processes."""
auth_mod = _auth_module()
auth_mod._hash_password = lambda password: f"hash:{password}"
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
auth_path = str(tmp_path / "auth.json")
worker_a = auth_mod.AuthManager(auth_path)
assert worker_a.create_user("alice", "password-1", is_admin=False)
worker_b = auth_mod.AuthManager(auth_path) # boots after user exists
return worker_a, worker_b
class TestCrossWorkerSessions:
def test_session_issued_on_other_worker_validates(self, tmp_path):
worker_a, worker_b = _two_workers(tmp_path)
token = worker_a.create_session_trusted("alice")
assert token is not None
# Worker B has never seen this token in memory — it must read
# through to sessions.json and accept it.
assert worker_b.validate_token(token) is True
assert worker_b.get_username_for_token(token) == "alice"
def test_unknown_token_still_rejected(self, tmp_path):
worker_a, worker_b = _two_workers(tmp_path)
worker_a.create_session_trusted("alice")
assert worker_b.validate_token("f" * 64) is False
assert worker_b.get_username_for_token("f" * 64) is None
def test_expired_session_from_other_worker_rejected(self, tmp_path):
auth_mod = _auth_module()
worker_a, worker_b = _two_workers(tmp_path)
token = worker_a.create_session_trusted("alice")
# Force the persisted expiry into the past, as another worker
# would see it after the TTL elapsed.
with worker_a._sessions_lock:
worker_a._sessions[token]["expiry"] = 1.0
worker_a._save_sessions()
assert worker_b.validate_token(token) is False
assert worker_b.get_username_for_token(token) is None
def test_reload_is_additive_not_destructive(self, tmp_path):
"""A reload must never drop tokens this worker already holds in
memory (e.g. one issued moments ago, racing its own save)."""
worker_a, worker_b = _two_workers(tmp_path)
token_b = worker_b.create_session_trusted("alice")
token_a = worker_a.create_session_trusted("alice")
# B validating A's token triggers a reload; B's own token survives.
assert worker_b.validate_token(token_a) is True
assert worker_b.validate_token(token_b) is True
class TestCrossWorkerRevocation:
def test_revocation_propagates_to_other_worker(self, tmp_path):
"""Logout on worker A must invalidate the token on worker B even
though B holds it in its in-memory map."""
worker_a, worker_b = _two_workers(tmp_path)
token = worker_a.create_session_trusted("alice")
assert worker_b.validate_token(token) is True # B now caches it
worker_a.revoke_token(token)
assert worker_b.validate_token(token) is False
assert worker_b.get_username_for_token(token) is None
def test_revoke_user_sessions_propagates(self, tmp_path):
"""Admin-driven revocation (password change, user deletion) on one
worker must take effect on the others."""
worker_a, worker_b = _two_workers(tmp_path)
token = worker_a.create_session_trusted("alice")
assert worker_b.validate_token(token) is True
assert worker_a.revoke_user_sessions("alice") == 1
assert worker_b.validate_token(token) is False
def test_never_persisted_token_survives_reload(self, tmp_path):
"""A token in memory that was never written to disk (racing its own
save) must not be dropped when a reload observes another worker's
write that lacks it."""
worker_a, worker_b = _two_workers(tmp_path)
import time as _time
phantom = "e" * 64
with worker_b._sessions_lock:
worker_b._sessions[phantom] = {
"username": "alice", "expiry": _time.time() + 3600,
}
token_a = worker_a.create_session_trusted("alice") # bumps mtime
assert worker_b.validate_token(token_a) is True # triggers reload
assert worker_b.validate_token(phantom) is True # survived
class TestSecretFilePermissions:
def test_sessions_file_owner_only(self, tmp_path):
import stat
worker_a, _ = _two_workers(tmp_path)
worker_a.create_session_trusted("alice")
mode = stat.S_IMODE((tmp_path / "sessions.json").stat().st_mode)
assert mode == 0o600
def test_auth_file_owner_only(self, tmp_path):
import stat
_two_workers(tmp_path)
mode = stat.S_IMODE((tmp_path / "auth.json").stat().st_mode)
assert mode == 0o600
def test_preexisting_world_readable_files_restricted_on_load(self, tmp_path):
"""Files written before the 0600 policy get restricted at startup."""
import stat
auth_mod = _auth_module()
auth_mod._hash_password = lambda password: f"hash:{password}"
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
auth_path = str(tmp_path / "auth.json")
mgr = auth_mod.AuthManager(auth_path)
assert mgr.create_user("alice", "password-1", is_admin=False)
mgr.create_session_trusted("alice")
(tmp_path / "auth.json").chmod(0o644)
(tmp_path / "sessions.json").chmod(0o644)
auth_mod.AuthManager(auth_path) # fresh load restricts both
assert stat.S_IMODE((tmp_path / "auth.json").stat().st_mode) == 0o600
assert stat.S_IMODE((tmp_path / "sessions.json").stat().st_mode) == 0o600