mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
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:
parent
d8a2059df8
commit
84cb22039b
14 changed files with 2133 additions and 8 deletions
22
.env.example
22
.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)
|
||||
# ============================================================
|
||||
|
|
|
|||
23
app.py
23
app.py
|
|
@ -245,6 +245,7 @@ app.add_middleware(_SlowRequestLogMiddleware)
|
|||
|
||||
# ========= AUTH =========
|
||||
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
||||
from core.oidc import init_oidc_manager
|
||||
|
||||
auth_manager = AuthManager()
|
||||
app.state.auth_manager = auth_manager
|
||||
|
|
@ -263,6 +264,9 @@ if AUTH_ENABLED:
|
|||
"/api/auth/features",
|
||||
"/api/auth/settings",
|
||||
"/api/auth/integrations/presets",
|
||||
"/api/auth/oidc/login",
|
||||
"/api/auth/oidc/callback",
|
||||
"/api/auth/oidc/config",
|
||||
"/api/health",
|
||||
"/api/version",
|
||||
"/login",
|
||||
|
|
@ -627,7 +631,6 @@ webhook_manager = WebhookManager(api_key_manager=api_key_manager)
|
|||
auth_router = setup_auth_routes(auth_manager)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
@app.post("/api/activity/heartbeat")
|
||||
async def activity_heartbeat():
|
||||
from src.interactive_gate import mark_browser_activity
|
||||
|
|
@ -641,6 +644,24 @@ async def activity_heartbeat():
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
# OIDC (single sign-on) — initialised after auth_manager so the OIDC routes
|
||||
# can look up / create users.
|
||||
# Register routes whenever OIDC_ENABLED=true (even if provider discovery
|
||||
# hasn't succeeded yet) so the /config endpoint can report the error to
|
||||
# the login page instead of 404-ing silently.
|
||||
_OIDC_ENABLED = os.getenv("OIDC_ENABLED", "false").lower() == "true"
|
||||
oidc_manager = init_oidc_manager() if _OIDC_ENABLED else None
|
||||
if _OIDC_ENABLED:
|
||||
from routes.oidc_routes import setup_oidc_routes
|
||||
oidc_router = setup_oidc_routes(auth_manager, oidc_manager)
|
||||
app.include_router(oidc_router)
|
||||
if oidc_manager is not None:
|
||||
logger.info("OIDC routes registered — provider discovered")
|
||||
else:
|
||||
logger.info("OIDC routes registered — provider not yet reachable")
|
||||
else:
|
||||
logger.info("OIDC disabled (set OIDC_ENABLED=true and provider vars to enable)")
|
||||
|
||||
# Uploads
|
||||
from routes.upload_routes import setup_upload_routes
|
||||
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
|
||||
|
|
|
|||
131
core/auth.py
131
core/auth.py
|
|
@ -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
355
core/oidc.py
Normal 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
|
||||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ bcrypt
|
|||
mcp
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
authlib
|
||||
croniter
|
||||
pytest
|
||||
pytest-asyncio
|
||||
|
|
|
|||
|
|
@ -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
188
routes/oidc_routes.py
Normal 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
|
||||
|
|
@ -1981,7 +1981,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" id="settings-pw-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
||||
<div class="settings-col">
|
||||
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
|
|
|
|||
|
|
@ -2144,6 +2144,13 @@ function initAccount() {
|
|||
const initial = (d.username || '?')[0].toUpperCase();
|
||||
avatarEl.textContent = initial;
|
||||
}
|
||||
// OIDC users don't have a password — hide password change and 2FA.
|
||||
if (d.is_oidc) {
|
||||
const pwCard = document.getElementById('settings-pw-card');
|
||||
const tfaCard = document.getElementById('settings-2fa-card');
|
||||
if (pwCard) pwCard.style.display = 'none';
|
||||
if (tfaCard) tfaCard.style.display = 'none';
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Update password placeholder and policy from server
|
||||
|
|
|
|||
|
|
@ -183,6 +183,20 @@
|
|||
.toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
|
||||
.toggle a { color: var(--red); cursor: pointer; text-decoration: none; }
|
||||
.toggle a:hover { text-decoration: underline; }
|
||||
/* OIDC / SSO button */
|
||||
.oidc-divider { display:flex; align-items:center; margin:1.15rem 0 1rem; }
|
||||
.oidc-divider::before, .oidc-divider::after { content:''; flex:1; border-top:1px solid var(--border); }
|
||||
.oidc-divider span { padding:0 0.75rem; color:color-mix(in srgb, var(--fg) 40%, transparent); font-size:0.75rem; text-transform:uppercase; }
|
||||
.oidc-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
width: 100%; padding: 0.65rem 0;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--panel); color: var(--fg);
|
||||
font-size: 0.95rem; font-weight: 500; text-decoration: none; cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.oidc-btn:hover { background: color-mix(in srgb, var(--panel) 90%, var(--fg)); border-color: color-mix(in srgb, var(--border) 60%, var(--fg)); }
|
||||
.oidc-btn svg { flex-shrink: 0; }
|
||||
.pw-wrapper { position: relative; margin-bottom: 1rem; }
|
||||
.pw-wrapper input:not(.remember-check) { padding-right: 2.5rem; margin-bottom: 0; }
|
||||
.pw-toggle {
|
||||
|
|
@ -290,6 +304,14 @@
|
|||
<button type="submit" id="submitBtn">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div id="oidcSection" style="display:none">
|
||||
<div class="oidc-divider" aria-hidden="true"><span>or</span></div>
|
||||
<a class="oidc-btn" id="oidcBtn" href="/api/auth/oidc/login">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
<span id="oidcLabel">Sign in with SSO</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="toggle" id="toggleArea" style="display:none">
|
||||
<span id="toggleText">Don't have an account? </span>
|
||||
<a id="toggleLink" href="#">Sign up</a>
|
||||
|
|
@ -309,6 +331,20 @@
|
|||
}
|
||||
} catch(e) {}
|
||||
|
||||
// OIDC / SSO button — fetch config and show if enabled
|
||||
try {
|
||||
const oc = await fetch('/api/auth/oidc/config');
|
||||
if (oc.ok) {
|
||||
const od = await oc.json();
|
||||
if (od.enabled) {
|
||||
const section = document.getElementById('oidcSection');
|
||||
const label = document.getElementById('oidcLabel');
|
||||
if (section) section.style.display = 'block';
|
||||
if (label) label.textContent = 'Sign in with ' + (od.provider_name || 'SSO');
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Prefill last username
|
||||
const usernameInput = document.getElementById('username');
|
||||
const savedUser = localStorage.getItem('odysseus-last-user');
|
||||
|
|
|
|||
292
tests/test_oidc_auth.py
Normal file
292
tests/test_oidc_auth.py
Normal 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
511
tests/test_oidc_manager.py
Normal 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
566
tests/test_oidc_routes.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue