From fe8c8e43977014d895d35cdba704d51185c865e3 Mon Sep 17 00:00:00 2001 From: holden093 Date: Sun, 7 Jun 2026 17:05:54 +0200 Subject: [PATCH 01/24] feat(auth): add generic OpenID Connect (OIDC) single sign-on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OIDC authentication as an alternative to password login, enabling sign-in via any standard provider (Authentik, Keycloak, Authelia, etc.). New features: - Generic OIDC provider support via .well-known discovery (authlib) - Coexists with existing password auth — users choose at login - Auto-creates local users on first OIDC login - Admin group mapping: OIDC_ADMIN_GROUPS grants admin based on IdP groups - Admin status syncs on every login (follows IdP membership) - OIDC users cannot use password login or set up 2FA - UI hides change-password and 2FA cards for OIDC users New env vars: - OIDC_ENABLED, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET - OIDC_SCOPES, OIDC_ADMIN_GROUPS New files: - core/oidc.py — OidcManager (discovery, auth URL, code exchange, id_token verification with JWT/JWKS) - routes/oidc_routes.py — /api/auth/oidc/{login,callback,config} Co-Authored-By: Claude Opus 4.8 --- .env.example | 22 ++ app.py | 23 +- core/auth.py | 131 ++++++++- core/oidc.py | 355 +++++++++++++++++++++++ docker-compose.yml | 6 + requirements.txt | 1 + routes/auth_routes.py | 1 + routes/oidc_routes.py | 188 ++++++++++++ static/index.html | 2 +- static/js/settings.js | 7 + static/login.html | 36 +++ tests/test_oidc_auth.py | 292 +++++++++++++++++++ tests/test_oidc_manager.py | 511 +++++++++++++++++++++++++++++++++ tests/test_oidc_routes.py | 566 +++++++++++++++++++++++++++++++++++++ 14 files changed, 2133 insertions(+), 8 deletions(-) create mode 100644 core/oidc.py create mode 100644 routes/oidc_routes.py create mode 100644 tests/test_oidc_auth.py create mode 100644 tests/test_oidc_manager.py create mode 100644 tests/test_oidc_routes.py diff --git a/.env.example b/.env.example index e96dd151cd..9a891305f6 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,28 @@ 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 +# +# 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 + # ============================================================ # ChromaDB (vector store) # ============================================================ diff --git a/app.py b/app.py index a89f80143c..5c91122884 100644 --- a/app.py +++ b/app.py @@ -245,6 +245,7 @@ async def dispatch(self, request, call_next): # ========= 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 @@ async def dispatch(self, request, call_next): "/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 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): 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 _stop_background(): 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/auth.py b/core/auth.py index 4bc9a70ddb..679a77a292 100644 --- a/core/auth.py +++ b/core/auth.py @@ -289,6 +289,110 @@ def create_user(self, username: str, password: str, is_admin: bool = False) -> b 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). + """ + 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 + + # Idempotent: same identity already exists + existing = self.get_user_by_oidc(sub, issuer) + if existing is not None: + return existing + + # 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 self.users: + suffix += 1 + candidate = f"{base}{suffix}" + if suffix > 100: # safety valve + logger.error("OIDC username collision loop for '%s'", username) + return None + + with self._config_lock: + # Double-check no race; if someone grabbed base while we were + # computing a suffix, re-find the next free name once. + if candidate in self.users: + suffix = 1 + while candidate in self.users: + suffix += 1 + candidate = f"{base}{suffix}" + if suffix > 100: + return None + if "users" not in self._config: + self._config["users"] = {} + self._config["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( + "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). + """ + username = username.strip().lower() + user = self.users.get(username, {}) + if not user.get("oidc_sub"): + return False # not an OIDC user — don't touch + if user.get("is_admin") == is_admin: + return True # no change needed + with self._config_lock: + 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 delete_user(self, username: str, requesting_user: str) -> bool: """Delete a user. Only admins can delete, and can't delete themselves. @@ -377,10 +481,19 @@ def is_admin(self, username: str) -> bool: 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.""" @@ -476,7 +589,10 @@ def change_password(self, username: str, current_password: str, new_password: st username = username.strip().lower() if username not in self.users: return False - if not _verify_password(current_password, self.users[username]["password_hash"]): + 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 with self._config_lock: self._config["users"][username]["password_hash"] = _hash_password(new_password) @@ -576,7 +692,10 @@ def verify_password(self, username: str, password: str) -> bool: 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.""" diff --git a/core/oidc.py b/core/oidc.py new file mode 100644 index 0000000000..2a1931e066 --- /dev/null +++ b/core/oidc.py @@ -0,0 +1,355 @@ +"""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_SCOPES=openid profile email — space-separated scope list + +State is stored in-memory with a 10-minute TTL. No database / file +persistence is needed — a lost state only forces the user to restart the +OIDC flow, which is the expected UX anyway. +""" + +import logging +import os +import secrets +import time +import threading +from typing import Optional, Dict, Any, Tuple + +import httpx + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# In-memory state store +# --------------------------------------------------------------------------- +_STATE_TTL = 600 # 10 minutes + +_state_store: Dict[str, Dict[str, Any]] = {} +_state_lock = threading.Lock() + + +def _store_state(state: str, nonce: str, redirect_uri: str) -> None: + entry = {"nonce": nonce, "redirect_uri": redirect_uri, "created": time.time()} + with _state_lock: + _prune_expired() + _state_store[state] = entry + + +def _pop_state(state: str) -> Optional[Dict[str, Any]]: + with _state_lock: + _prune_expired() + return _state_store.pop(state, None) + + +def _prune_expired() -> None: + now = time.time() + expired = [s for s, v in _state_store.items() if now - v["created"] > _STATE_TTL] + for s in expired: + del _state_store[s] + + +# --------------------------------------------------------------------------- +# OidcManager +# --------------------------------------------------------------------------- + + +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 fetches the JWKS for + id_token signature verification. + """ + + def __init__( + self, + issuer: str, + client_id: str, + client_secret: str, + scopes: str = "openid profile email", + ): + self.issuer = issuer.rstrip("/") + self.client_id = client_id + self.client_secret = client_secret + self.scopes = scopes + self._provider_name: Optional[str] = None + self._config: Dict[str, Any] = {} + self._discover() + + # -- discovery ----------------------------------------------------------- + + def _discover(self) -> None: + """Fetch .well-known/openid-configuration and JWKS.""" + # 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}" + + 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 SHOULD match the configured issuer + doc_issuer = self._config.get("issuer", "") + if doc_issuer and doc_issuer.rstrip("/") != self.issuer: + logger.warning( + "OIDC issuer mismatch: configured=%r doc=%r", self.issuer, doc_issuer, + ) + + logger.info( + "OIDC provider discovered: issuer=%r auth=%r token=%r", + self.issuer, + self._config["authorization_endpoint"], + self._config["token_endpoint"], + ) + + @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) + + # -- 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 caller MUST store *state* + and *nonce* and pass them to :meth:`exchange_code` on callback. + """ + state = secrets.token_hex(32) + nonce = secrets.token_hex(32) + + _store_state(state, nonce, redirect_uri) + + 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, + } + 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. Verify state and recover the nonce + stored = _pop_state(state) + if stored is None: + raise OidcError("OIDC state not found — may be expired or reused") + nonce = stored.get("nonce", "") + + # 2. Exchange code for tokens + token_data = self._token_request(code, redirect_uri) + + # 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 + access_token = token_data.get("access_token") + if access_token: + try: + userinfo = self._fetch_userinfo(access_token) + # userinfo claims supplement the id_token (per OIDC spec, userinfo + # is the authoritative source for profile claims) + claims.update(userinfo) + except Exception as exc: + logger.warning("Failed to fetch userinfo: %s", exc) + + return claims + + def _token_request(self, code: str, redirect_uri: 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, + "client_id": self.client_id, + "client_secret": self.client_secret, + } + try: + resp = httpx.post(token_endpoint, data=payload, 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 + + 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 + + # Fetch JWKS + try: + resp = httpx.get(self._config["jwks_uri"], timeout=15.0) + resp.raise_for_status() + jwks = resp.json() + except Exception as exc: + raise OidcError(f"Failed to fetch JWKS: {exc}") from exc + + # 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) + 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}" + ) + + if claims.get("aud") != self.client_id: + raise OidcError( + f"id_token aud mismatch: expected {self.client_id!r}, got {claims.get('aud')!r}" + ) + + exp = claims.get("exp", 0) + if time.time() > exp: + raise OidcError(f"id_token expired at {exp}") + + # Verify nonce + if claims.get("nonce") != nonce: + raise OidcError("id_token nonce mismatch") + + return claims + + def _fetch_userinfo(self, access_token: str) -> Dict[str, Any]: + """Fetch claims from the UserInfo endpoint (if available).""" + userinfo_endpoint = self._config.get("userinfo_endpoint") + if not userinfo_endpoint: + return {} + 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() + + 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: + _oidc_manager = OidcManager( + issuer=issuer, + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + ) + except OidcError as exc: + _oidc_init_error = str(exc) + logger.error("OIDC init failed: %s", exc) + return None + + return _oidc_manager + + +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.yml b/docker-compose.yml index cbeec1e372..23d2fcc49a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,12 @@ 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:-} - EMBEDDING_URL=${EMBEDDING_URL:-} - EMBEDDING_MODEL=${EMBEDDING_MODEL:-} - EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-} diff --git a/requirements.txt b/requirements.txt index be5f5d4505..4574b3c89a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,6 +41,7 @@ bcrypt mcp pyotp qrcode[pil] +authlib croniter pytest pytest-asyncio diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 5c7a4e04ab..b9aba33c75 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -186,6 +186,7 @@ async def auth_status(request: Request): 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 diff --git a/routes/oidc_routes.py b/routes/oidc_routes.py new file mode 100644 index 0000000000..b63b682b0f --- /dev/null +++ b/routes/oidc_routes.py @@ -0,0 +1,188 @@ +"""OpenID Connect authentication routes — login, callback, config.""" + +import logging +import os +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" + + +def setup_oidc_routes( + auth_manager: AuthManager, + oidc_manager: Optional[OidcManager], +) -> APIRouter: + router = APIRouter(prefix="/api/auth/oidc", tags=["oidc"]) + + @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: + error = get_oidc_init_error() + return {"enabled": False, "error": error or "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. + + Generates state + nonce, stores them server-side, then redirects + the browser to the provider's authorization endpoint. + """ + if oidc_manager is None or not oidc_manager.configured: + return JSONResponse( + {"error": "OIDC is not configured"}, status_code=503, + ) + + # Build the redirect_uri from the incoming request so it works + # behind proxies (use the same scheme/host the browser used). + base = str(request.base_url).rstrip("/") + redirect_uri = f"{base}/api/auth/oidc/callback" + + try: + auth_url, _state, _nonce = 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, + ) + + return RedirectResponse(url=auth_url, status_code=302) + + @router.get("/callback") + async def oidc_callback(request: Request, response: Response): + """Handle the OIDC provider's redirect after authentication. + + Verifies state, 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: + return JSONResponse( + {"error": "OIDC is not configured"}, status_code=503, + ) + + 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 RedirectResponse( + url=f"/login?error=oidc_denied", status_code=302, + ) + + if not code or not state: + logger.warning("OIDC callback missing code or state") + return RedirectResponse( + url=f"/login?error=oidc_invalid", status_code=302, + ) + + # Build redirect_uri matching the one used in /login + base = str(request.base_url).rstrip("/") + redirect_uri = f"{base}/api/auth/oidc/callback" + + # The nonce was stored server-side alongside the state in + # OidcManager.get_authorization_url. exchange_code pops the + # state entry and recovers the nonce internally. + try: + claims = oidc_manager.exchange_code(code, state, redirect_uri) + except OidcError as exc: + logger.error("OIDC code exchange failed: %s", exc) + return RedirectResponse( + url=f"/login?error=oidc_failed", status_code=302, + ) + + # 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 = claims.get("groups", []) + + if not sub: + logger.error("OIDC id_token missing sub claim") + return RedirectResponse( + url=f"/login?error=oidc_failed", status_code=302, + ) + + # 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_group_list = [ + g.strip() for g in os.getenv("OIDC_ADMIN_GROUPS", "").split(",") if g.strip() + ] + is_admin = False + if admin_group_list and groups: + if isinstance(groups, list): + group_set = {str(g) for g in groups} + is_admin = bool(group_set & set(admin_group_list)) + + # 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 — sync admin status from IdP groups + logger.info("OIDC login for existing user '%s'", username) + auth_manager.set_oidc_user_admin(username, is_admin) + 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 RedirectResponse( + url=f"/login?error=oidc_failed", status_code=302, + ) + + # Issue a session cookie (same as password login) + import asyncio + token = await asyncio.to_thread(auth_manager.create_session_trusted, username) + + cookie_kwargs = dict( + key=SESSION_COOKIE, + value=token, + httponly=True, + samesite="lax", + secure=os.getenv("SECURE_COOKIES", "false").lower() == "true", + path="/", + max_age=60 * 60 * 24 * 7, # 7 days + ) + response.set_cookie(**cookie_kwargs) + response.status_code = 302 + response.headers["location"] = "/" + return response + + return router diff --git a/static/index.html b/static/index.html index cb30e84899..9f247b16ae 100644 --- a/static/index.html +++ b/static/index.html @@ -1941,7 +1941,7 @@

+

Change Password

diff --git a/static/js/settings.js b/static/js/settings.js index ad2950f513..20861bb38c 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2143,6 +2143,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 eeece7cc3c..6bdaee679e 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 @@

+ +