diff --git a/.env.example b/.env.example index 2d1be3373..3538df0f9 100644 --- a/.env.example +++ b/.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) # ============================================================ diff --git a/app.py b/app.py index 8363ba4e9..a74f41139 100644 --- a/app.py +++ b/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) diff --git a/core/atomic_io.py b/core/atomic_io.py index 81c640d8a..420c7a751 100644 --- a/core/atomic_io.py +++ b/core/atomic_io.py @@ -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()) diff --git a/core/auth.py b/core/auth.py index 4bc9a70dd..4ce4ac095 100644 --- a/core/auth.py +++ b/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]: diff --git a/core/oidc.py b/core/oidc.py new file mode 100644 index 000000000..00874b1db --- /dev/null +++ b/core/oidc.py @@ -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//) 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 diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 9699fc038..a3c4de2e7 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -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:-} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index 804a0a14e..16e9dc13b 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -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:-} diff --git a/docker-compose.yml b/docker-compose.yml index b0efb4439..082b57ebb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/requirements.txt b/requirements.txt index 3c5114f53..b34bd7ee2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,6 +44,7 @@ bcrypt mcp<2 pyotp qrcode[pil] +authlib>=1.3.0,<2 croniter pytest pytest-asyncio diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 5c7a4e04a..e591694a0 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -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} diff --git a/routes/oidc_routes.py b/routes/oidc_routes.py new file mode 100644 index 000000000..c21e4c9a3 --- /dev/null +++ b/routes/oidc_routes.py @@ -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." + ) diff --git a/src/secret_storage.py b/src/secret_storage.py index c4a08be1d..96568a637 100644 --- a/src/secret_storage.py +++ b/src/secret_storage.py @@ -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 diff --git a/static/index.html b/static/index.html index 8257660fe..d1063e912 100644 --- a/static/index.html +++ b/static/index.html @@ -1981,7 +1981,7 @@ -
+

Change Password

diff --git a/static/js/settings.js b/static/js/settings.js index 540acff00..49a869931 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -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 diff --git a/static/login.html b/static/login.html index eeece7cc3..418164c79 100644 --- a/static/login.html +++ b/static/login.html @@ -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 @@ + +