feat(auth): add generic OpenID Connect (OIDC) single sign-on

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 <noreply@anthropic.com>
This commit is contained in:
holden093 2026-06-07 17:05:54 +02:00
parent d8a2059df8
commit 84cb22039b
14 changed files with 2133 additions and 8 deletions

View file

@ -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)
# ============================================================

23
app.py
View file

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

View file

@ -289,6 +289,110 @@ class AuthManager:
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 @@ 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."""
@ -476,7 +589,10 @@ class AuthManager:
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 @@ 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."""

355
core/oidc.py Normal file
View file

@ -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/<slug>/) work correctly.
well_known_url = self.issuer + "/.well-known/openid-configuration"
if not well_known_url.startswith(("http://", "https://")):
well_known_url = f"https://{well_known_url}"
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

View file

@ -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:-}

View file

@ -41,6 +41,7 @@ bcrypt
mcp
pyotp
qrcode[pil]
authlib
croniter
pytest
pytest-asyncio

View file

@ -186,6 +186,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

188
routes/oidc_routes.py Normal file
View file

@ -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

View file

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

View file

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

View file

@ -183,6 +183,20 @@
.toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
.toggle a { color: var(--red); cursor: pointer; text-decoration: none; }
.toggle a:hover { text-decoration: underline; }
/* OIDC / SSO button */
.oidc-divider { display:flex; align-items:center; margin:1.15rem 0 1rem; }
.oidc-divider::before, .oidc-divider::after { content:''; flex:1; border-top:1px solid var(--border); }
.oidc-divider span { padding:0 0.75rem; color:color-mix(in srgb, var(--fg) 40%, transparent); font-size:0.75rem; text-transform:uppercase; }
.oidc-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
width: 100%; padding: 0.65rem 0;
border: 1px solid var(--border); border-radius: 6px;
background: var(--panel); color: var(--fg);
font-size: 0.95rem; font-weight: 500; text-decoration: none; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.oidc-btn:hover { background: color-mix(in srgb, var(--panel) 90%, var(--fg)); border-color: color-mix(in srgb, var(--border) 60%, var(--fg)); }
.oidc-btn svg { flex-shrink: 0; }
.pw-wrapper { position: relative; margin-bottom: 1rem; }
.pw-wrapper input:not(.remember-check) { padding-right: 2.5rem; margin-bottom: 0; }
.pw-toggle {
@ -290,6 +304,14 @@
<button type="submit" id="submitBtn">Sign In</button>
</form>
<div id="oidcSection" style="display:none">
<div class="oidc-divider" aria-hidden="true"><span>or</span></div>
<a class="oidc-btn" id="oidcBtn" href="/api/auth/oidc/login">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
<span id="oidcLabel">Sign in with SSO</span>
</a>
</div>
<div class="toggle" id="toggleArea" style="display:none">
<span id="toggleText">Don't have an account? </span>
<a id="toggleLink" href="#">Sign up</a>
@ -309,6 +331,20 @@
}
} catch(e) {}
// OIDC / SSO button — fetch config and show if enabled
try {
const oc = await fetch('/api/auth/oidc/config');
if (oc.ok) {
const od = await oc.json();
if (od.enabled) {
const section = document.getElementById('oidcSection');
const label = document.getElementById('oidcLabel');
if (section) section.style.display = 'block';
if (label) label.textContent = 'Sign in with ' + (od.provider_name || 'SSO');
}
}
} catch(e) {}
// Prefill last username
const usernameInput = document.getElementById('username');
const savedUser = localStorage.getItem('odysseus-last-user');

292
tests/test_oidc_auth.py Normal file
View file

@ -0,0 +1,292 @@
"""Tests for AuthManager OIDC methods — user creation, lookup, and password rejection."""
import pytest
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _real_auth_module():
"""Import the real core.auth module."""
import importlib, sys
if "core.auth" in sys.modules:
return sys.modules["core.auth"]
import core.auth
return core.auth
def _make_manager(tmp_path: Path):
"""Create an AuthManager pointed at a temp auth.json."""
auth = _real_auth_module()
mgr = auth.AuthManager(str(tmp_path / "auth.json"))
return mgr
# ---------------------------------------------------------------------------
# User creation
# ---------------------------------------------------------------------------
def test_create_user_oidc_basic(tmp_path):
mgr = _make_manager(tmp_path)
username = mgr.create_user_oidc(
"alice", sub="abc123", issuer="https://idp.example.com", email="alice@example.com",
)
assert username == "alice"
assert mgr.is_oidc_user("alice")
assert "alice" in mgr.users
assert mgr.users["alice"]["password_hash"] is None
assert mgr.users["alice"]["oidc_sub"] == "abc123"
assert mgr.users["alice"]["oidc_issuer"] == "https://idp.example.com"
assert mgr.users["alice"]["oidc_email"] == "alice@example.com"
def test_create_user_oidc_lowercases_username(tmp_path):
mgr = _make_manager(tmp_path)
username = mgr.create_user_oidc(
"Alice", sub="abc123", issuer="https://idp.example.com", email="alice@example.com",
)
assert username == "alice"
def test_create_user_oidc_rejects_reserved(tmp_path):
mgr = _make_manager(tmp_path)
for reserved in ("internal-tool", "api", "demo", "system"):
username = mgr.create_user_oidc(
reserved, sub="sub", issuer="https://idp.example.com",
)
assert username is None, f"Should reject reserved username {reserved!r}"
def test_create_user_oidc_empty_username(tmp_path):
mgr = _make_manager(tmp_path)
assert mgr.create_user_oidc(" ", sub="sub", issuer="https://idp.example.com") is None
assert mgr.create_user_oidc("", sub="sub", issuer="https://idp.example.com") is None
def test_create_user_oidc_idempotent(tmp_path):
"""Calling create_user_oidc with the same (sub, issuer) returns the
existing username, even if the suggested raw username differs."""
mgr = _make_manager(tmp_path)
first = mgr.create_user_oidc(
"alice", sub="abc123", issuer="https://idp.example.com", email="alice@example.com",
)
second = mgr.create_user_oidc(
"alice_renamed", sub="abc123", issuer="https://idp.example.com",
)
assert first == "alice"
assert second == "alice" # same identity, no new user created
def test_create_user_oidc_username_collision_with_password_user(tmp_path):
"""When the desired username is taken by a local password user, append
a numeric suffix."""
mgr = _make_manager(tmp_path)
# Create a password user first
ok = mgr.create_user("alice", "hunter2", is_admin=False)
assert ok
# Now try to create an OIDC user with the same username
oidc_user = mgr.create_user_oidc(
"alice", sub="oidc_sub", issuer="https://idp.example.com",
)
assert oidc_user is not None
assert oidc_user != "alice" # should get a different name
assert oidc_user.startswith("alice")
assert mgr.is_oidc_user(oidc_user)
assert not mgr.is_oidc_user("alice") # the password user is unaffected
def test_create_user_oidc_username_collision_with_other_oidc_user(tmp_path):
"""Two OIDC users with different identities but the same preferred
username should get distinct accounts."""
mgr = _make_manager(tmp_path)
alice1 = mgr.create_user_oidc(
"alice", sub="sub1", issuer="https://idp1.example.com",
)
alice2 = mgr.create_user_oidc(
"alice", sub="sub2", issuer="https://idp2.example.com",
)
assert alice1 == "alice"
assert alice2 is not None
assert alice2 != "alice"
assert alice2.startswith("alice")
assert mgr.is_oidc_user(alice1)
assert mgr.is_oidc_user(alice2)
def test_create_user_oidc_multiple_collisions(tmp_path):
"""A large number of collisions still resolves (suffix increment on each)."""
mgr = _make_manager(tmp_path)
# Create 5 users named "bob" through different identities
usernames = set()
for i in range(5):
u = mgr.create_user_oidc(
"bob", sub=f"sub_{i}", issuer="https://idp.example.com",
)
assert u is not None
usernames.add(u)
assert len(usernames) == 5
assert "bob" in usernames
assert "bob2" in usernames or "bob3" in usernames
# ---------------------------------------------------------------------------
# get_user_by_oidc
# ---------------------------------------------------------------------------
def test_get_user_by_oidc_found(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc(
"alice", sub="abc123", issuer="https://idp.example.com",
)
assert mgr.get_user_by_oidc("abc123", "https://idp.example.com") == "alice"
def test_get_user_by_oidc_wrong_sub(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert mgr.get_user_by_oidc("wrong_sub", "https://idp.example.com") is None
def test_get_user_by_oidc_wrong_issuer(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert mgr.get_user_by_oidc("abc123", "https://other-idp.example.com") is None
def test_get_user_by_oidc_no_users(tmp_path):
mgr = _make_manager(tmp_path)
assert mgr.get_user_by_oidc("any", "https://any.example.com") is None
# ---------------------------------------------------------------------------
# is_oidc_user
# ---------------------------------------------------------------------------
def test_is_oidc_user_true(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert mgr.is_oidc_user("alice")
def test_is_oidc_user_false_for_password_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "hunter2")
assert not mgr.is_oidc_user("bob")
def test_is_oidc_user_false_for_nonexistent(tmp_path):
mgr = _make_manager(tmp_path)
assert not mgr.is_oidc_user("ghost")
# ---------------------------------------------------------------------------
# Password rejection for OIDC users
# ---------------------------------------------------------------------------
def test_verify_password_rejects_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
assert not mgr.verify_password("alice", "any_password")
def test_create_session_rejects_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
token = mgr.create_session("alice", "any_password")
assert token is None
def test_change_password_rejects_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
ok = mgr.change_password("alice", "any_password", "new_password")
assert not ok
def test_oidc_user_session_via_create_session_trusted(tmp_path):
"""An OIDC user can still get a session via the trusted path (used
after successful OIDC flow)."""
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com")
token = mgr.create_session_trusted("alice")
assert token is not None
assert mgr.validate_token(token)
assert mgr.get_username_for_token(token) == "alice"
# ---------------------------------------------------------------------------
# list_users includes OIDC info
# ---------------------------------------------------------------------------
def test_list_users_includes_oidc_info(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "hunter2")
mgr.create_user_oidc("alice", sub="abc123", issuer="https://idp.example.com",
email="alice@example.com")
users = mgr.list_users()
alice_entry = next((u for u in users if u["username"] == "alice"), None)
bob_entry = next((u for u in users if u["username"] == "bob"), None)
assert alice_entry is not None
assert alice_entry.get("oidc") is True
assert alice_entry.get("oidc_issuer") == "https://idp.example.com"
assert alice_entry.get("oidc_email") == "alice@example.com"
assert bob_entry is not None
assert bob_entry.get("oidc") is None # password users don't have oidc flag
# ---------------------------------------------------------------------------
# set_oidc_user_admin
# ---------------------------------------------------------------------------
def test_set_oidc_user_admin_promotes(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=False)
assert not mgr.is_admin("alice")
assert mgr.set_oidc_user_admin("alice", True)
assert mgr.is_admin("alice")
# Privileges should be upgraded to ADMIN_PRIVILEGES
privs = mgr.get_privileges("alice")
assert privs["can_use_bash"] is True
def test_set_oidc_user_admin_demotes(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=True)
assert mgr.is_admin("alice")
assert mgr.set_oidc_user_admin("alice", False)
assert not mgr.is_admin("alice")
# Privileges should be downgraded to DEFAULT_PRIVILEGES
privs = mgr.get_privileges("alice")
assert privs["can_use_bash"] is False
def test_set_oidc_user_admin_noop_when_unchanged(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user_oidc("alice", sub="abc", issuer="https://idp.example.com",
is_admin=False)
assert mgr.set_oidc_user_admin("alice", False) # still returns True
assert not mgr.is_admin("alice")
def test_set_oidc_user_admin_rejects_non_oidc_user(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "hunter2", is_admin=False)
# Can't promote a password user via this method
assert not mgr.set_oidc_user_admin("bob", True)
assert not mgr.is_admin("bob")
def test_set_oidc_user_admin_rejects_nonexistent(tmp_path):
mgr = _make_manager(tmp_path)
assert not mgr.set_oidc_user_admin("ghost", True)

511
tests/test_oidc_manager.py Normal file
View file

@ -0,0 +1,511 @@
"""Tests for OidcManager — discovery, auth URL, code exchange, id_token verification."""
import json
import time
import pytest
from unittest.mock import patch, MagicMock
# ---------------------------------------------------------------------------
# Helpers — fake OIDC provider
# ---------------------------------------------------------------------------
FAKE_ISSUER = "https://idp.example.com"
FAKE_CLIENT_ID = "test-client"
FAKE_CLIENT_SECRET = "test-secret"
DISCOVERY_DOC = {
"issuer": FAKE_ISSUER,
"authorization_endpoint": f"{FAKE_ISSUER}/authorize",
"token_endpoint": f"{FAKE_ISSUER}/token",
"jwks_uri": f"{FAKE_ISSUER}/jwks",
"userinfo_endpoint": f"{FAKE_ISSUER}/userinfo",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
}
# Module-level cache so _make_id_token and the tests share the same key
_test_jwks_cache = None
_test_jwk_key_cache = None
def _make_test_jwks_and_key():
"""Generate an RSA key pair and return (jwks_dict, private_jwk).
The key pair is cached at module level so id_token signing and JWKS
verification use the same key calling this multiple times returns
the same pair.
"""
global _test_jwks_cache, _test_jwk_key_cache
if _test_jwks_cache is not None:
return _test_jwks_cache, _test_jwk_key_cache
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from authlib.jose import JsonWebKey
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# Build JWKS (public key)
public_jwk = JsonWebKey.import_key(
key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
),
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test-key-1"},
)
jwk_dict = json.loads(public_jwk.as_json())
jwks = {"keys": [jwk_dict]}
# Private key JWK for signing
private_jwk = JsonWebKey.import_key(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
),
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test-key-1"},
)
_test_jwks_cache = jwks
_test_jwk_key_cache = private_jwk
return jwks, private_jwk
def _make_id_token(sub, nonce, issuer=FAKE_ISSUER, aud=FAKE_CLIENT_ID, exp=None):
"""Sign a test id_token with the test RSA key."""
from authlib.jose import jwt
_, jwk = _make_test_jwks_and_key()
if exp is None:
exp = int(time.time()) + 3600
header = {"alg": "RS256", "kid": "test-key-1"}
payload = {
"iss": issuer,
"sub": sub,
"aud": aud,
"exp": exp,
"iat": int(time.time()),
"nonce": nonce,
"email": f"{sub}@example.com",
"name": sub.title(),
"preferred_username": sub,
}
return jwt.encode(header, payload, jwk).decode()
# ---------------------------------------------------------------------------
# Mock httpx responses
# ---------------------------------------------------------------------------
class _FakeResponse:
"""Minimal httpx.Response stand-in."""
def __init__(self, status_code=200, json_data=None, text=""):
self.status_code = status_code
self._json = json_data or {}
self.text = text
def raise_for_status(self):
if self.status_code >= 400:
from httpx import HTTPStatusError
raise HTTPStatusError("error", request=MagicMock(), response=self)
def json(self):
return self._json
def _mock_discovery_response():
return _FakeResponse(200, DISCOVERY_DOC)
def _mock_token_response(id_token):
return _FakeResponse(200, {
"access_token": "fake-access-token",
"id_token": id_token,
"token_type": "Bearer",
"expires_in": 3600,
})
def _mock_jwks_response(jwks):
return _FakeResponse(200, jwks)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestOidcManagerInit:
def test_discovery_success(self):
jwt_jwks, _ = _make_test_jwks_and_key()
import core.oidc as mod
with patch.object(mod.httpx, "get") as mock_get:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks), # JWKS fetch happens in exchange_code, not init
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
mgr._config = DISCOVERY_DOC # ensure config is set for subsequent tests
assert mgr.configured
assert mgr.issuer == FAKE_ISSUER
def test_discovery_failure_raises(self):
import core.oidc as mod
with patch.object(mod.httpx, "get") as mock_get:
mock_get.return_value = _FakeResponse(500, {"error": "down"}, "server error")
with pytest.raises(mod.OidcError, match="Failed to fetch OIDC discovery"):
mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
def test_discovery_missing_endpoint_raises(self):
import core.oidc as mod
bad_doc = dict(DISCOVERY_DOC)
del bad_doc["authorization_endpoint"]
with patch.object(mod.httpx, "get") as mock_get:
mock_get.return_value = _FakeResponse(200, bad_doc)
with pytest.raises(mod.OidcError, match="authorization_endpoint"):
mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
def test_provider_name_from_hostname(self):
jwt_jwks, _ = _make_test_jwks_and_key()
import core.oidc as mod
with patch.object(mod.httpx, "get") as mock_get:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
assert mgr.provider_name == "idp.example.com"
class TestAuthorizationUrl:
def test_returns_url_and_state(self):
jwt_jwks, _ = _make_test_jwks_and_key()
import core.oidc as mod
with patch.object(mod.httpx, "get") as mock_get:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, nonce = mgr.get_authorization_url("https://app.example.com/callback")
assert url.startswith(f"{FAKE_ISSUER}/authorize?")
assert "response_type=code" in url
assert f"client_id={FAKE_CLIENT_ID}" in url
assert "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback" in url
assert f"state={state}" in url
assert f"nonce={nonce}" in url
assert len(state) == 64 # 32 hex bytes
assert len(nonce) == 64
class TestExchangeCode:
def test_successful_exchange(self):
jwt_jwks, jwk = _make_test_jwks_and_key()
nonce = "a" * 64
id_token = _make_id_token("user123", nonce)
import core.oidc as mod
# Clear state store
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
# Discovery
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks), # JWKS for id_token verification
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
# Generate state first
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
# We need to override the nonce in the stored state to match our id_token
# Replace the state entry with our controlled nonce
mod._state_store[state] = {
"nonce": nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
# Token exchange
mock_post.return_value = _mock_token_response(id_token)
# Also need to handle any additional get calls
mock_get.reset_mock()
mock_get.side_effect = [
_mock_jwks_response(jwt_jwks), # JWKS fetch in _verify_id_token
]
claims = mgr.exchange_code("auth_code_xyz", state, "https://app.example.com/callback")
assert claims["sub"] == "user123"
assert claims["email"] == "user123@example.com"
assert claims["nonce"] == nonce
def test_state_not_found(self):
jwt_jwks, _ = _make_test_jwks_and_key()
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
with pytest.raises(mod.OidcError, match="state not found"):
mgr.exchange_code("code", "nonexistent_state", "https://app.example.com/callback")
def test_no_id_token_in_response(self):
jwt_jwks, _ = _make_test_jwks_and_key()
nonce = "b" * 64
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
mod._state_store[state] = {
"nonce": nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
# Token response without id_token
mock_post.return_value = _FakeResponse(200, {"access_token": "fake"})
with pytest.raises(mod.OidcError, match="No id_token"):
mgr.exchange_code("code", state, "https://app.example.com/callback")
def test_token_endpoint_error(self):
jwt_jwks, _ = _make_test_jwks_and_key()
nonce = "c" * 64
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
mod._state_store[state] = {
"nonce": nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
# Token endpoint returns error
mock_post.return_value = _FakeResponse(400, {"error": "invalid_grant"})
with pytest.raises(mod.OidcError):
mgr.exchange_code("bad_code", state, "https://app.example.com/callback")
def test_id_token_wrong_issuer(self):
jwt_jwks, jwk = _make_test_jwks_and_key()
nonce = "d" * 64
id_token = _make_id_token("user123", nonce, issuer="https://evil.example.com")
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
mod._state_store[state] = {
"nonce": nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
mock_post.return_value = _mock_token_response(id_token)
mock_get.reset_mock()
mock_get.side_effect = [
_mock_jwks_response(jwt_jwks),
]
with pytest.raises(mod.OidcError, match="iss"):
mgr.exchange_code("code", state, "https://app.example.com/callback")
def test_id_token_wrong_audience(self):
jwt_jwks, jwk = _make_test_jwks_and_key()
nonce = "e" * 64
id_token = _make_id_token("user123", nonce, aud="wrong-client")
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
mod._state_store[state] = {
"nonce": nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
mock_post.return_value = _mock_token_response(id_token)
mock_get.reset_mock()
mock_get.side_effect = [
_mock_jwks_response(jwt_jwks),
]
with pytest.raises(mod.OidcError):
mgr.exchange_code("code", state, "https://app.example.com/callback")
def test_id_token_expired(self):
jwt_jwks, jwk = _make_test_jwks_and_key()
nonce = "f" * 64
id_token = _make_id_token("user123", nonce, exp=int(time.time()) - 60)
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
mod._state_store[state] = {
"nonce": nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
mock_post.return_value = _mock_token_response(id_token)
mock_get.reset_mock()
mock_get.side_effect = [
_mock_jwks_response(jwt_jwks),
]
with pytest.raises(mod.OidcError, match="exp"):
mgr.exchange_code("code", state, "https://app.example.com/callback")
def test_id_token_nonce_mismatch(self):
jwt_jwks, jwk = _make_test_jwks_and_key()
nonce_in_token = "g" * 64
different_nonce = "h" * 64
id_token = _make_id_token("user123", nonce_in_token)
import core.oidc as mod
mod._state_store.clear()
with patch.object(mod.httpx, "get") as mock_get, \
patch.object(mod.httpx, "post") as mock_post:
mock_get.side_effect = [
_mock_discovery_response(),
_mock_jwks_response(jwt_jwks),
]
mgr = mod.OidcManager(
issuer=FAKE_ISSUER,
client_id=FAKE_CLIENT_ID,
client_secret=FAKE_CLIENT_SECRET,
)
url, state, gen_nonce = mgr.get_authorization_url("https://app.example.com/callback")
# Store a different nonce than what's in the token
mod._state_store[state] = {
"nonce": different_nonce,
"redirect_uri": "https://app.example.com/callback",
"created": time.time(),
}
mock_post.return_value = _mock_token_response(id_token)
mock_get.reset_mock()
mock_get.side_effect = [
_mock_jwks_response(jwt_jwks),
]
with pytest.raises(mod.OidcError, match="nonce"):
mgr.exchange_code("code", state, "https://app.example.com/callback")

566
tests/test_oidc_routes.py Normal file
View file

@ -0,0 +1,566 @@
"""Tests for OIDC routes — config, login redirect, callback handling."""
import pytest
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from fastapi import APIRouter
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _setup_oidc_routes(auth_manager, oidc_manager):
"""Import and call setup_oidc_routes, returning the router."""
from routes.oidc_routes import setup_oidc_routes
return setup_oidc_routes(auth_manager, oidc_manager)
def _get_endpoint(router: APIRouter, path: str):
"""Find the route endpoint for a given path."""
for route in router.routes:
if getattr(route, "path", "") == path:
return route.endpoint
raise AssertionError(f"No route found for path: {path}")
def _fake_request(base_url="http://testserver"):
"""Build a minimal Request-like object."""
req = SimpleNamespace()
req.base_url = SimpleNamespace()
req.base_url.__str__ = lambda s, b=base_url: b
req.base_url.rstrip = lambda s, strip="/": base_url.rstrip(strip)
req.query_params = {}
req.cookies = {}
return req
def _fake_request_with_params(query_params, base_url="http://testserver"):
req = _fake_request(base_url)
req.query_params = query_params
return req
# ---------------------------------------------------------------------------
# /config
# ---------------------------------------------------------------------------
class TestOidcConfig:
def test_config_disabled_when_manager_none(self):
from core.oidc import OidcError
router = _setup_oidc_routes(MagicMock(), None)
ep = _get_endpoint(router, "/api/auth/oidc/config")
import asyncio
result = asyncio.run(ep())
assert result == {"enabled": False, "error": "OIDC not configured"}
def test_config_enabled_when_configured(self):
mgr = MagicMock()
mgr.configured = True
mgr.provider_name = "Test IDP"
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/config")
import asyncio
result = asyncio.run(ep())
assert result["enabled"] is True
assert result["provider_name"] == "Test IDP"
# ---------------------------------------------------------------------------
# /login
# ---------------------------------------------------------------------------
class TestOidcLogin:
def test_login_redirects_to_provider(self):
mgr = MagicMock()
mgr.configured = True
mgr.get_authorization_url.return_value = (
"https://idp.example.com/authorize?state=abc&nonce=def",
"abc",
"def",
)
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/login")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(ep(_fake_request()))
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
assert result.headers["location"] == "https://idp.example.com/authorize?state=abc&nonce=def"
def test_login_returns_503_when_not_configured(self):
router = _setup_oidc_routes(MagicMock(), None)
ep = _get_endpoint(router, "/api/auth/oidc/login")
import asyncio
from fastapi.responses import JSONResponse
result = asyncio.run(ep(_fake_request()))
assert isinstance(result, JSONResponse)
assert result.status_code == 503
def test_login_handles_oidc_error(self):
from core.oidc import OidcError
mgr = MagicMock()
mgr.configured = True
mgr.get_authorization_url.side_effect = OidcError("bad config")
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/login")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(ep(_fake_request()))
assert isinstance(result, RedirectResponse)
assert "error=oidc_config" in result.headers["location"]
# ---------------------------------------------------------------------------
# /callback
# ---------------------------------------------------------------------------
class TestOidcCallback:
def test_callback_success_creates_session(self):
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "user123",
"email": "alice@example.com",
"preferred_username": "alice",
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None # new user
auth.create_user_oidc.return_value = "alice"
auth.create_session_trusted.return_value = "session-token-abc"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
result = asyncio.run(
ep(
_fake_request_with_params({"code": "authcode", "state": "state123"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
# Check session cookie was set
assert result.status_code == 302
assert result.headers["location"] == "/"
# Check user creation was called correctly
auth.create_user_oidc.assert_called_once_with(
"alice", "user123", "https://idp.example.com", email="alice@example.com",
is_admin=False,
)
def test_callback_existing_user(self):
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "existing_sub",
"email": "bob@example.com",
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = "bob" # existing user
# create_user_oidc should NOT be called for existing users
auth.create_session_trusted.return_value = "session-token-xyz"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
result = asyncio.run(
ep(
_fake_request_with_params({"code": "authcode", "state": "state456"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
assert result.status_code == 302
assert result.headers["location"] == "/"
auth.create_user_oidc.assert_not_called()
auth.create_session_trusted.assert_called_once_with("bob")
def test_callback_provider_error_redirects(self):
mgr = MagicMock()
mgr.configured = True
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(
ep(
_fake_request_with_params(
{"error": "access_denied", "error_description": "User cancelled"}
),
SimpleNamespace(),
)
)
assert isinstance(result, RedirectResponse)
assert "error=oidc_denied" in result.headers["location"]
def test_callback_missing_code_redirects(self):
mgr = MagicMock()
mgr.configured = True
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(
ep(
_fake_request_with_params({"state": "state789"}),
SimpleNamespace(),
)
)
assert isinstance(result, RedirectResponse)
assert "error=oidc_invalid" in result.headers["location"]
def test_callback_exchange_failure_redirects(self):
from core.oidc import OidcError
mgr = MagicMock()
mgr.configured = True
mgr.exchange_code.side_effect = OidcError("token exchange failed")
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(
ep(
_fake_request_with_params({"code": "badcode", "state": "state999"}),
SimpleNamespace(),
)
)
assert isinstance(result, RedirectResponse)
assert "error=oidc_failed" in result.headers["location"]
def test_callback_missing_sub_in_claims(self):
mgr = MagicMock()
mgr.configured = True
mgr.exchange_code.return_value = {"sub": ""} # empty sub
router = _setup_oidc_routes(MagicMock(), mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(),
)
)
assert isinstance(result, RedirectResponse)
assert "error=oidc_failed" in result.headers["location"]
def test_callback_create_user_failure(self):
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "new_user",
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None
auth.create_user_oidc.return_value = None # creation failed
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
from fastapi.responses import RedirectResponse
result = asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(),
)
)
assert isinstance(result, RedirectResponse)
assert "error=oidc_failed" in result.headers["location"]
def test_callback_username_from_email_local_part(self):
"""When no preferred_username, the email local-part is used."""
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "user456",
"email": "charlie@example.com",
# no preferred_username
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None
auth.create_user_oidc.return_value = "charlie"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
result = asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
auth.create_user_oidc.assert_called_once()
call_args = auth.create_user_oidc.call_args
assert call_args[0][0] == "charlie" # first positional arg is username
def test_callback_new_user_admin_from_groups(self, monkeypatch):
"""New user whose groups claim includes an admin group is created as admin."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins,superusers")
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "user1",
"email": "dave@example.com",
"preferred_username": "dave",
"groups": ["users", "odysseus-admins"],
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None # new user
auth.create_user_oidc.return_value = "dave"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
result = asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
# Should be created with is_admin=True
auth.create_user_oidc.assert_called_once()
call_kwargs = auth.create_user_oidc.call_args.kwargs
assert call_kwargs.get("is_admin") is True
assert result.status_code == 302
def test_callback_new_user_no_admin_groups(self, monkeypatch):
"""New user without admin groups is created as non-admin."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins")
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "user2",
"email": "eve@example.com",
"preferred_username": "eve",
"groups": ["users"], # no admin group
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None
auth.create_user_oidc.return_value = "eve"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
call_kwargs = auth.create_user_oidc.call_args.kwargs
assert call_kwargs.get("is_admin") is False
def test_callback_existing_user_promoted_to_admin(self, monkeypatch):
"""Existing OIDC user gets admin synced from groups on login."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins")
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "existing",
"email": "frank@example.com",
"groups": ["odysseus-admins"],
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = "frank" # existing user
auth.set_oidc_user_admin.return_value = True
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
# Existing user should have admin synced, not re-created
auth.set_oidc_user_admin.assert_called_once_with("frank", True)
auth.create_user_oidc.assert_not_called()
def test_callback_existing_user_demoted_from_admin(self, monkeypatch):
"""Existing OIDC user loses admin when removed from admin group."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins")
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "existing",
"email": "grace@example.com",
"groups": ["users"], # no longer in admin group
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = "grace"
auth.set_oidc_user_admin.return_value = True
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
auth.set_oidc_user_admin.assert_called_once_with("grace", False)
def test_callback_no_groups_claim(self, monkeypatch):
"""Provider doesn't return a groups claim — default to non-admin."""
monkeypatch.setenv("OIDC_ADMIN_GROUPS", "odysseus-admins")
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "user3",
"email": "hank@example.com",
# no groups key
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None
auth.create_user_oidc.return_value = "hank"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
call_kwargs = auth.create_user_oidc.call_args.kwargs
assert call_kwargs.get("is_admin") is False
def test_callback_no_admin_groups_configured(self, monkeypatch):
"""OIDC_ADMIN_GROUPS not set — default to non-admin."""
monkeypatch.delenv("OIDC_ADMIN_GROUPS", raising=False)
mgr = MagicMock()
mgr.configured = True
mgr.issuer = "https://idp.example.com"
mgr.exchange_code.return_value = {
"sub": "user4",
"email": "iris@example.com",
"groups": ["odysseus-admins"], # has the group, but not configured
}
auth = MagicMock()
auth.get_user_by_oidc.return_value = None
auth.create_user_oidc.return_value = "iris"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
import asyncio
asyncio.run(
ep(
_fake_request_with_params({"code": "code", "state": "state"}),
SimpleNamespace(
set_cookie=MagicMock(),
status_code=200,
headers={},
),
)
)
call_kwargs = auth.create_user_oidc.call_args.kwargs
assert call_kwargs.get("is_admin") is False