mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge 99a1a10b4a into 20e7fc0164
This commit is contained in:
commit
16045a34fa
21 changed files with 5747 additions and 66 deletions
45
.env.example
45
.env.example
|
|
@ -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
23
app.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
519
core/auth.py
519
core/auth.py
|
|
@ -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
795
core/oidc.py
Normal 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
|
||||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ bcrypt
|
|||
mcp<2
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
authlib>=1.3.0,<2
|
||||
croniter
|
||||
pytest
|
||||
pytest-asyncio
|
||||
|
|
|
|||
|
|
@ -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
336
routes/oidc_routes.py
Normal 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."
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
803
tests/test_oidc_auth.py
Normal 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
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
1229
tests/test_oidc_routes.py
Normal file
File diff suppressed because it is too large
Load diff
47
tests/test_password_login_cookie_secure.py
Normal file
47
tests/test_password_login_cookie_secure.py
Normal 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
|
||||
151
tests/test_session_cross_worker.py
Normal file
151
tests/test_session_cross_worker.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue