chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
"""Flask blueprint for OIDC SSO (AUTH_TYPE=oidc)."""
from __future__ import annotations
from flask import Blueprint
from .routes import register as register_routes
oidc_bp = Blueprint("oidc", __name__)
register_routes(oidc_bp)
+107
View File
@@ -0,0 +1,107 @@
"""Redis-backed session denylist for OIDC revocation.
Back-channel logout and SCIM deactivation drop identifiers here; the
request path refuses any session token whose identifiers match. Each entry
stores a revocation *watermark* (a Unix timestamp): a session is denied
only when it was issued (``iat``) at or before the watermark. Storing a
watermark instead of a boolean is what lets a fresh login self-supersede a
prior revocation — its newer ``iat`` simply sits above the watermark —
without deleting the entry and thereby resurrecting still-live sessions
that were revoked on other devices.
Entries live slightly longer than ``OIDC_SESSION_LIFETIME_SECONDS`` —
every session issued at or before the watermark expires before the entry
does, so nothing needs to be stored durably.
Revocation is best-effort by design: if Redis is unreachable the check
fails open (sessions keep working) rather than taking the whole API down.
"""
from __future__ import annotations
import logging
import time
from application.cache import get_redis_instance
from application.core.settings import settings
logger = logging.getLogger(__name__)
_USER_PREFIX = "oidc:deny:user:"
_SUB_PREFIX = "oidc:deny:sub:"
_SID_PREFIX = "oidc:deny:sid:"
def _ttl_seconds() -> int:
return settings.OIDC_SESSION_LIFETIME_SECONDS + 60
def _set(key: str) -> bool:
redis = get_redis_instance()
if redis is None:
logger.error("Redis unavailable — could not denylist %s", key)
return False
try:
# Store the revocation instant; existing entries are overwritten with a
# newer watermark (revoking again only ever moves it forward in time).
redis.set(key, str(int(time.time())), ex=_ttl_seconds())
return True
except Exception:
logger.error("Failed to denylist %s", key, exc_info=True)
return False
def deny_user(user_id: str) -> bool:
"""Revoke every live session of the DocsGPT user ``user_id``."""
return _set(_USER_PREFIX + user_id)
def deny_idp_sub(sub: str) -> bool:
"""Revoke sessions by IdP ``sub`` (back-channel logout tokens carry this)."""
return _set(_SUB_PREFIX + sub)
def deny_sid(sid: str) -> bool:
"""Revoke sessions of one IdP session id (``sid``-only logout tokens)."""
return _set(_SID_PREFIX + sid)
def _watermark(value) -> float:
"""Parse a stored watermark to a float; unparseable values deny everything."""
if isinstance(value, bytes):
value = value.decode("utf-8", "ignore")
try:
return float(value)
except (TypeError, ValueError):
# Corrupt/legacy entry: treat as "deny" (a watermark far in the future).
return float("inf")
def is_denied(decoded_token: dict) -> bool:
"""True when the token was issued at/before a matching revocation watermark."""
keys = []
if decoded_token.get("sub"):
keys.append(_USER_PREFIX + str(decoded_token["sub"]))
if decoded_token.get("oidc_sub"):
keys.append(_SUB_PREFIX + str(decoded_token["oidc_sub"]))
if decoded_token.get("oidc_sid"):
keys.append(_SID_PREFIX + str(decoded_token["oidc_sid"]))
if not keys:
return False
redis = get_redis_instance()
if redis is None:
return False
try:
values = redis.mget(keys)
except Exception:
logger.warning("Denylist check failed — allowing request", exc_info=True)
return False
try:
iat = float(decoded_token.get("iat"))
except (TypeError, ValueError):
# No usable issue time — if any revocation exists for this identity we
# cannot prove the token post-dates it, so deny.
return any(value is not None for value in values)
# Strict ``<``: a session issued in the same second as (or after) the
# revocation — e.g. an immediate re-login — is allowed.
return any(value is not None and iat < _watermark(value) for value in values)
+270
View File
@@ -0,0 +1,270 @@
"""OIDC provider client: discovery, JWKS, token grants, ID/logout-token validation."""
from __future__ import annotations
import logging
import threading
import time
import requests
from jose import jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
from application.core.settings import settings
logger = logging.getLogger(__name__)
DISCOVERY_TTL_SECONDS = 3600
FORCE_REFETCH_COOLDOWN_SECONDS = 10
LEEWAY_SECONDS = 60
HTTP_TIMEOUT_SECONDS = 10
BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
# Asymmetric algorithms only: a symmetric alg here would let an attacker
# forge ID tokens signed with the (public) JWKS material.
ALLOWED_ID_TOKEN_ALGS = [
"RS256", "RS384", "RS512",
"ES256", "ES384", "ES512",
"PS256", "PS384", "PS512",
]
_lock = threading.Lock()
_cache: dict = {
"discovery": None,
"discovery_at": 0.0,
"jwks": None,
"jwks_at": 0.0,
"jwks_force_at": 0.0,
}
class OIDCError(Exception):
"""Raised when an OIDC flow step fails."""
class OIDCTransientError(OIDCError):
"""Raised when the IdP was unreachable or returned 5xx — the call can be retried.
Subclasses OIDCError so existing ``except OIDCError`` handlers still catch
it; callers that can retry (e.g. session refresh) catch this first.
"""
def reset_cache() -> None:
"""Clear the cached discovery document and JWKS (used by tests)."""
with _lock:
_cache.update(
{"discovery": None, "discovery_at": 0.0, "jwks": None, "jwks_at": 0.0, "jwks_force_at": 0.0}
)
def _fetch_json(url: str) -> dict:
try:
response = requests.get(url, timeout=HTTP_TIMEOUT_SECONDS)
except requests.RequestException as exc:
raise OIDCError(f"OIDC request to {url} failed: {exc}") from exc
if response.status_code != 200:
raise OIDCError(f"OIDC request to {url} returned {response.status_code}")
return response.json()
def get_discovery() -> dict:
"""Return the IdP discovery document, fetching/caching it per process."""
with _lock:
if _cache["discovery"] is not None and time.time() - _cache["discovery_at"] < DISCOVERY_TTL_SECONDS:
return _cache["discovery"]
url = settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
document = _fetch_json(url)
with _lock:
_cache["discovery"] = document
_cache["discovery_at"] = time.time()
return document
def get_jwks(force: bool = False) -> dict:
"""Return the IdP JWKS; ``force=True`` bypasses the cache (key rotation)."""
with _lock:
fresh = (
_cache["jwks"] is not None
and time.time() - _cache["jwks_at"] < DISCOVERY_TTL_SECONDS
)
if not force and fresh:
return _cache["jwks"]
if force and fresh:
# Rate-limit forced refetches: unauthenticated callers (back-channel
# logout) must not be able to hammer the IdP through us.
if time.time() - _cache["jwks_force_at"] < FORCE_REFETCH_COOLDOWN_SECONDS:
return _cache["jwks"]
_cache["jwks_force_at"] = time.time()
jwks = _fetch_json(get_discovery()["jwks_uri"])
with _lock:
_cache["jwks"] = jwks
_cache["jwks_at"] = time.time()
return jwks
def _find_key(kid: str | None) -> dict | None:
keys = get_jwks().get("keys", [])
if kid is None:
return keys[0] if len(keys) == 1 else None
return next((key for key in keys if key.get("kid") == kid), None)
def _resolve_signing_key(token: str) -> dict:
"""Return the JWKS key matching the token header, refetching once on unknown kid."""
try:
header = jwt.get_unverified_header(token)
except Exception as exc:
raise OIDCError(f"Malformed token: {exc}") from exc
if header.get("alg") not in ALLOWED_ID_TOKEN_ALGS:
raise OIDCError(f"Disallowed token alg: {header.get('alg')}")
key = _find_key(header.get("kid"))
if key is None:
get_jwks(force=True)
key = _find_key(header.get("kid"))
if key is None:
raise OIDCError("No matching key in IdP JWKS")
return key
def _decode_verified(token: str, options: dict) -> dict:
"""Decode ``token`` against the JWKS, retrying once if the IdP re-keyed.
A signature failure can mean the IdP replaced its signing key while
reusing the same kid — the kid-miss refetch never triggers then, so
retry once against a freshly fetched JWKS (rate-limited in get_jwks).
"""
key = _resolve_signing_key(token)
decode_kwargs = {
"algorithms": ALLOWED_ID_TOKEN_ALGS,
"audience": settings.OIDC_CLIENT_ID,
# Compare against the discovery document's own issuer value —
# some IdPs (Authentik) use a trailing slash the operator may
# not have typed into OIDC_ISSUER.
"issuer": get_discovery()["issuer"],
"options": options,
}
try:
return jwt.decode(token, key, **decode_kwargs)
except (ExpiredSignatureError, JWTClaimsError) as exc:
raise OIDCError(f"token validation failed: {exc}") from exc
except Exception:
get_jwks(force=True)
key = _find_key(jwt.get_unverified_header(token).get("kid"))
if key is None:
raise OIDCError("No matching key in IdP JWKS")
try:
return jwt.decode(token, key, **decode_kwargs)
except Exception as exc:
raise OIDCError(f"token validation failed: {exc}") from exc
def validate_id_token(id_token: str, nonce: str | None = None) -> dict:
"""Verify the ID token's signature, iss, aud, exp, and (when given) nonce; return claims."""
claims = _decode_verified(
id_token,
options={
"verify_at_hash": False,
"leeway": LEEWAY_SECONDS,
"require_iss": True,
"require_aud": True,
"require_exp": True,
"require_sub": True,
},
)
# Refresh-issued id_tokens carry no nonce; callers pass None to skip the check.
if nonce is not None and claims.get("nonce") != nonce:
raise OIDCError("nonce mismatch")
return claims
def validate_logout_token(logout_token: str) -> dict:
"""Verify a back-channel logout token per OIDC Back-Channel Logout 1.0; return claims."""
claims = _decode_verified(
logout_token,
options={
"verify_at_hash": False,
"leeway": LEEWAY_SECONDS,
"require_iss": True,
"require_aud": True,
"require_iat": True,
# jti is REQUIRED by OIDC Back-Channel Logout 1.0 and underpins
# replay protection — reject tokens that omit it. exp is not a
# required logout-token claim, so it stays optional (the caller
# bounds replay via iat freshness instead).
"require_jti": True,
"require_exp": False,
},
)
events = claims.get("events")
if not isinstance(events, dict) or BACKCHANNEL_LOGOUT_EVENT not in events:
raise OIDCError("logout_token missing the backchannel-logout event")
if "nonce" in claims:
raise OIDCError("logout_token must not contain a nonce")
if not claims.get("sub") and not claims.get("sid"):
raise OIDCError("logout_token must contain sub or sid")
return claims
def _token_request(data: dict) -> dict:
"""POST to the token endpoint using the discovery-advertised client auth method."""
discovery = get_discovery()
data = {**data, "client_id": settings.OIDC_CLIENT_ID}
post_kwargs: dict = {"data": data, "timeout": HTTP_TIMEOUT_SECONDS}
if settings.OIDC_CLIENT_SECRET:
# Absent metadata means the RFC 8414 default, client_secret_basic.
methods = discovery.get("token_endpoint_auth_methods_supported") or ["client_secret_basic"]
if "client_secret_post" in methods:
data["client_secret"] = settings.OIDC_CLIENT_SECRET
else:
post_kwargs["auth"] = (settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET)
try:
response = requests.post(discovery["token_endpoint"], **post_kwargs)
except requests.RequestException as exc:
# Network failure — retryable.
raise OIDCTransientError(f"Token request failed: {exc}") from exc
if response.status_code != 200:
logger.error(
"OIDC token request failed (%s): %s", response.status_code, response.text[:500]
)
# 5xx is an IdP-side hiccup (retryable); 4xx (e.g. invalid_grant) is a
# definitive rejection the caller must not retry.
if response.status_code >= 500:
raise OIDCTransientError(f"Token endpoint returned {response.status_code}")
raise OIDCError(f"Token endpoint returned {response.status_code}")
return response.json()
def exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict:
"""Exchange the authorization code at the IdP token endpoint."""
return _token_request(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
}
)
def refresh_grant(refresh_token: str) -> dict:
"""Redeem a refresh token at the IdP token endpoint."""
return _token_request({"grant_type": "refresh_token", "refresh_token": refresh_token})
def fetch_userinfo(access_token: str) -> dict:
"""Fetch claims from the IdP userinfo endpoint with a Bearer access token."""
endpoint = get_discovery().get("userinfo_endpoint")
if not endpoint:
raise OIDCError("No userinfo_endpoint in discovery document")
try:
response = requests.get(
endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=HTTP_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OIDCError(f"userinfo request failed: {exc}") from exc
if response.status_code != 200:
raise OIDCError(f"userinfo endpoint returned {response.status_code}")
return response.json()
+670
View File
@@ -0,0 +1,670 @@
"""Login, callback, session-token, logout, and refresh endpoints for AUTH_TYPE=oidc.
Flow: the backend redirects the browser to the IdP (Authorization Code +
PKCE), validates the ID token at the callback, mints a local HS256 session
JWT, and hands it to the SPA via a short-lived single-use code in the URL
fragment. Browser redirects only ever target the configured
``OIDC_FRONTEND_URL`` or IdP endpoints taken from discovery.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import secrets
import time
import uuid
from urllib.parse import quote, urlencode
from flask import Blueprint, Response, jsonify, make_response, redirect, request
from jose import jwt
from application.api.oidc import denylist, provider
from application.auth import handle_auth
from application.cache import get_redis_instance
from application.core.settings import settings
from application.storage.db.repositories.auth_events import AuthEventsRepository
from application.storage.db.repositories.user_roles import UserRolesRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600
HANDOFF_TTL_SECONDS = 60
LOGOUT_JTI_TTL_SECONDS = 600
MAX_PICTURE_CLAIM_CHARS = 2048
# Browser-bound CSRF guard for the login flow: the callback requires this
# cookie to echo the ``state`` it received. Scoped to the oidc paths so it is
# only ever sent on the callback.
STATE_COOKIE_NAME = "oidc_state"
STATE_COOKIE_PATH = "/api/auth/oidc/"
def _state_key(state: str) -> str:
return f"oidc:state:{state}"
def _handoff_key(code: str) -> str:
return f"oidc:handoff:{code}"
def _refresh_key(jti: str) -> str:
return f"oidc:refresh:{jti}"
def _logout_jti_key(jti: str) -> str:
return f"oidc:bcl:jti:{jti}"
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
def _redirect_uri() -> str:
return settings.OIDC_REDIRECT_URI or request.host_url.rstrip("/") + "/api/auth/oidc/callback"
def _frontend_url() -> str:
return (settings.OIDC_FRONTEND_URL or "").rstrip("/") or "/"
def _state_cookie_secure() -> bool:
"""Mark the state cookie ``Secure`` when the app is served over HTTPS."""
return (settings.OIDC_FRONTEND_URL or "").lower().startswith("https")
def _frontend_redirect(fragment: str):
base = (settings.OIDC_FRONTEND_URL or "").rstrip("/")
return redirect(f"{base}/#{fragment}", code=302)
def _no_store(payload, status: int = 200) -> Response:
"""Build a response marked non-cacheable (back-channel logout requirement)."""
response = make_response(payload, status)
response.headers["Cache-Control"] = "no-store"
return response
def _allowed_groups() -> list[str]:
"""Parse the comma-separated group allowlist; empty/unset means everyone."""
raw = settings.OIDC_ALLOWED_GROUPS or ""
return [group.strip() for group in raw.split(",") if group.strip()]
def _admin_groups() -> list[str]:
"""Parse the comma-separated admin-group list; empty/unset disables OIDC admin mapping."""
raw = settings.OIDC_ADMIN_GROUPS or ""
return [group.strip() for group in raw.split(",") if group.strip()]
def _reconcile_oidc_admin(user_id: str, groups: list[str] | None) -> None:
"""Sync the OIDC-group-derived admin grant for ``user_id``; best-effort, never raises.
Empty/unset ``OIDC_ADMIN_GROUPS`` disables the mapping entirely (no-op), so a
blank env var never mass-revokes. ``groups`` is ``None`` when the IdP asserted
no groups claim at all (absent from both id_token and userinfo); admin-ness is
then unknowable, so we skip rather than revoke — otherwise a silent refresh
whose token omits groups would demote a still-eligible admin. Only the
``oidc_group`` grant source is touched — manual grants are left intact. A
change is audited.
"""
admin_groups = _admin_groups()
if not admin_groups:
return
if groups is None:
return
is_admin = bool(set(groups) & set(admin_groups))
try:
with db_session() as conn:
change = UserRolesRepository(conn).reconcile_oidc_admin(user_id, is_admin)
if change:
AuthEventsRepository(conn).insert(
user_id,
"role_granted" if change == "granted" else "role_revoked",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={"role": "admin", "source": "oidc_group"},
)
except Exception:
logger.error("OIDC admin reconcile failed for %s", user_id, exc_info=True)
def _claim_groups(claims: dict) -> list[str]:
"""Read the groups claim as a list of strings; missing means no groups."""
value = claims.get(settings.OIDC_GROUPS_CLAIM)
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple)):
return [str(member) for member in value]
return [str(value)]
def _effective_claims(tokens: dict, claims: dict) -> dict:
"""Merge userinfo into the id_token claims when required claims are missing."""
effective = dict(claims)
need_user_id = not effective.get(settings.OIDC_USER_ID_CLAIM)
# Fetch userinfo to backfill groups when EITHER a login allowlist or an admin
# mapping is configured — many IdPs (Okta/Auth0/Azure AD) only expose groups
# via userinfo, and without this the admin reconcile would see no groups.
need_groups = (
bool(_allowed_groups()) or bool(_admin_groups())
) and settings.OIDC_GROUPS_CLAIM not in effective
if not (need_user_id or need_groups) or not tokens.get("access_token"):
return effective
try:
userinfo = provider.fetch_userinfo(tokens["access_token"])
except provider.OIDCError:
logger.warning("OIDC userinfo fetch failed; continuing with id_token claims", exc_info=True)
return effective
if userinfo.get("sub") != claims.get("sub"):
raise provider.OIDCError("userinfo sub does not match id_token sub")
for key, value in userinfo.items():
effective.setdefault(key, value)
return effective
def _mint_session_token(identity: dict) -> tuple[str, str]:
"""Mint the local HS256 session JWT for ``identity``; returns (token, jti)."""
now = int(time.time())
jti = str(uuid.uuid4())
payload = {
"sub": str(identity["sub"]),
"jti": jti,
"iat": now,
"exp": now + settings.OIDC_SESSION_LIFETIME_SECONDS,
}
if identity.get("oidc_sub"):
payload["oidc_sub"] = str(identity["oidc_sub"])
if identity.get("oidc_sid"):
payload["oidc_sid"] = str(identity["oidc_sid"])
for claim in ("email", "name"):
if identity.get(claim):
payload[claim] = identity[claim]
picture = identity.get("picture")
if picture and isinstance(picture, str) and len(picture) < MAX_PICTURE_CLAIM_CHARS:
payload["picture"] = picture
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256"), jti
def _record_login_denied(user_id: str, metadata: dict) -> None:
"""Best-effort audit of a denied login; never raises."""
try:
with db_session() as conn:
AuthEventsRepository(conn).insert(
user_id,
"oidc_login_denied",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata=metadata,
)
except Exception:
logger.warning("Failed to record oidc_login_denied for %s", user_id, exc_info=True)
def _gate_and_audit_login(user_id: str, effective: dict, groups: list[str]) -> bool:
"""Reject disabled users, provision new ones, audit the login.
Returns False only when the user row was readable and marked inactive;
a DB outage logs an error and lets the login proceed.
"""
disabled = False
try:
with db_session() as conn:
users = UsersRepository(conn)
row = users.get(user_id)
if row is not None and row.get("active") is False:
disabled = True
AuthEventsRepository(conn).insert(
user_id,
"oidc_login_denied",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={"reason": "account_disabled"},
)
else:
if row is None:
# New user: provision with the email claim so a team admin
# can later add them by email.
users.upsert(user_id, email=effective.get("email"))
else:
# Existing user: targeted email backfill only (don't re-run
# the full upsert — preserves the no-upsert-on-login path).
users.set_email(user_id, effective.get("email"))
AuthEventsRepository(conn).insert(
user_id,
"oidc_login",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={"email": effective.get("email"), "groups": groups or None},
)
except Exception:
logger.error(
"OIDC provisioning/audit failed for %s%s",
user_id,
"" if disabled else "; continuing login",
exc_info=True,
)
return not disabled
def oidc_login():
"""Start the Authorization Code + PKCE flow with a 302 to the IdP."""
redis = get_redis_instance()
if redis is None:
return make_response(jsonify({"error": "redis_unavailable"}), 503)
try:
authorization_endpoint = provider.get_discovery()["authorization_endpoint"]
except (provider.OIDCError, KeyError):
logger.error("OIDC discovery failed during login", exc_info=True)
return make_response(jsonify({"error": "idp_unavailable"}), 503)
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
redis.set(
_state_key(state),
json.dumps({"code_verifier": code_verifier, "nonce": nonce}),
ex=STATE_TTL_SECONDS,
nx=True,
)
params = {
"response_type": "code",
"client_id": settings.OIDC_CLIENT_ID,
"redirect_uri": _redirect_uri(),
"scope": settings.OIDC_SCOPES,
"state": state,
"nonce": nonce,
"code_challenge": _pkce_challenge(code_verifier),
"code_challenge_method": "S256",
}
response = redirect(f"{authorization_endpoint}?{urlencode(params)}", code=302)
# Bind this login to the browser: the callback rejects any state that
# isn't echoed by this cookie, so a code+state captured from another
# browser (login CSRF / session fixation) can't complete the flow.
response.set_cookie(
STATE_COOKIE_NAME,
state,
max_age=STATE_TTL_SECONDS,
httponly=True,
secure=_state_cookie_secure(),
samesite="Lax",
path=STATE_COOKIE_PATH,
)
return response
def oidc_callback():
"""Validate the IdP response, mint a session JWT, redirect with a handoff code."""
if request.args.get("error"):
return _frontend_redirect("oidc_error=" + quote(request.args["error"]))
state = request.args.get("state")
code = request.args.get("code")
if not state or not code:
return _frontend_redirect("oidc_error=invalid_state")
# Browser binding: the state must match the cookie set at login. Without
# this, an attacker could feed a victim a code+state from the attacker's
# own login and silently sign the victim into the attacker's account.
cookie_state = request.cookies.get(STATE_COOKIE_NAME)
if not cookie_state or not hmac.compare_digest(cookie_state, state):
logger.warning("OIDC callback rejected: state cookie missing or mismatched")
return _frontend_redirect("oidc_error=invalid_state")
redis = get_redis_instance()
if redis is None:
logger.error("Redis unavailable during OIDC callback")
return _frontend_redirect("oidc_error=auth_failed")
raw_state = redis.getdel(_state_key(state))
if raw_state is None:
return _frontend_redirect("oidc_error=invalid_state")
stored = json.loads(raw_state)
try:
tokens = provider.exchange_code(code, stored["code_verifier"], _redirect_uri())
claims = provider.validate_id_token(tokens["id_token"], stored["nonce"])
effective = _effective_claims(tokens, claims)
except (provider.OIDCError, KeyError):
logger.error("OIDC callback failed", exc_info=True)
return _frontend_redirect("oidc_error=auth_failed")
user_id = effective.get(settings.OIDC_USER_ID_CLAIM)
if not user_id:
logger.error("OIDC id_token missing user id claim %r", settings.OIDC_USER_ID_CLAIM)
return _frontend_redirect("oidc_error=missing_claim")
user_id = str(user_id)
allowed = _allowed_groups()
groups = _claim_groups(effective)
if allowed and not set(groups) & set(allowed):
logger.info("OIDC login denied for %s: groups %s not in allowlist", user_id, groups)
_record_login_denied(user_id, {"reason": "not_authorized", "groups": groups})
return _frontend_redirect("oidc_error=not_authorized")
if not _gate_and_audit_login(user_id, effective, groups):
return _frontend_redirect("oidc_error=account_disabled")
# Reconcile OIDC-group-derived admin against the fresh group claims so
# adds/removals take effect at login (manual grants are untouched). Pass
# None when the claim is absent entirely so a missing claim never revokes.
groups_present = settings.OIDC_GROUPS_CLAIM in effective
_reconcile_oidc_admin(user_id, groups if groups_present else None)
# No need to lift prior revocations here: the denylist keys on a revocation
# timestamp and the session minted below carries a newer ``iat``, so it is
# allowed automatically while still-live sessions revoked on other devices
# stay denied.
session_token, jti = _mint_session_token(
{
"sub": user_id,
"email": effective.get("email"),
"name": effective.get("name"),
"picture": effective.get("picture"),
"oidc_sub": claims["sub"],
"oidc_sid": claims.get("sid"),
}
)
refresh_token = tokens.get("refresh_token")
if refresh_token:
try:
redis.set(_refresh_key(jti), refresh_token, ex=settings.OIDC_SESSION_LIFETIME_SECONDS)
except Exception:
logger.warning("Failed to store OIDC refresh token", exc_info=True)
handoff = secrets.token_urlsafe(32)
redis.set(_handoff_key(handoff), session_token, ex=HANDOFF_TTL_SECONDS, nx=True)
return _frontend_redirect("oidc_code=" + handoff)
def oidc_token():
"""Redeem a single-use handoff code for the minted session JWT."""
body = request.get_json(silent=True) or {}
code = body.get("code")
if not code or not isinstance(code, str):
return make_response(jsonify({"error": "invalid_code"}), 401)
redis = get_redis_instance()
if redis is None:
return make_response(jsonify({"error": "redis_unavailable"}), 503)
raw = redis.getdel(_handoff_key(code))
if raw is None:
return make_response(jsonify({"error": "invalid_code"}), 401)
token = raw.decode("utf-8") if isinstance(raw, bytes) else raw
return jsonify({"token": token})
def _is_account_disabled(user_id: str) -> bool:
"""True only when a readable user row is marked inactive (DB outage fails open)."""
try:
with db_readonly() as conn:
row = UsersRepository(conn).get(user_id)
except Exception:
logger.error("User lookup failed during OIDC refresh", exc_info=True)
return False
return bool(row is not None and row.get("active") is False)
def oidc_refresh():
"""Rotate the stored IdP refresh token and mint a fresh session JWT."""
decoded = handle_auth(request)
if (
not isinstance(decoded, dict)
or "error" in decoded
or not decoded.get("sub")
or not decoded.get("jti")
):
error = "invalid_token"
if isinstance(decoded, dict) and decoded.get("error") == "token_expired":
error = "token_expired"
return make_response(jsonify({"error": error}), 401)
if denylist.is_denied(decoded):
return make_response(jsonify({"error": "token_revoked"}), 401)
# Gate the current session identity before spending the refresh token.
if _is_account_disabled(str(decoded["sub"])):
return make_response(jsonify({"error": "account_disabled"}), 401)
redis = get_redis_instance()
if redis is None:
return make_response(jsonify({"error": "redis_unavailable"}), 503)
raw = redis.getdel(_refresh_key(str(decoded["jti"])))
if raw is None:
return make_response(jsonify({"error": "no_refresh_token"}), 404)
refresh_token = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
try:
tokens = provider.refresh_grant(refresh_token)
except provider.OIDCTransientError:
# IdP unreachable / 5xx — the refresh token is still valid. Put it back
# and tell the client to retry instead of killing a live session over a
# transient blip (the frontend reschedules a renewal on 503).
try:
redis.set(
_refresh_key(str(decoded["jti"])),
refresh_token,
ex=settings.OIDC_SESSION_LIFETIME_SECONDS,
)
except Exception:
logger.warning("Failed to restore refresh token after transient error", exc_info=True)
logger.warning("OIDC refresh grant failed transiently", exc_info=True)
return make_response(jsonify({"error": "refresh_unavailable"}), 503)
except provider.OIDCError:
# invalid_grant / 4xx — the refresh token is spent or revoked; leave it
# consumed so the client falls back to a fresh login.
logger.warning("OIDC refresh grant rejected", exc_info=True)
return make_response(jsonify({"error": "refresh_failed"}), 401)
identity = {
"sub": str(decoded["sub"]),
"email": decoded.get("email"),
"name": decoded.get("name"),
"picture": decoded.get("picture"),
"oidc_sub": decoded.get("oidc_sub"),
"oidc_sid": decoded.get("oidc_sid"),
}
id_token = tokens.get("id_token")
if id_token:
try:
claims = provider.validate_id_token(id_token, nonce=None)
effective = _effective_claims(tokens, claims)
except provider.OIDCError:
logger.warning("Refresh-issued id_token failed validation", exc_info=True)
return make_response(jsonify({"error": "refresh_failed"}), 401)
# Re-gate group membership on every renewal that carries fresh
# claims — otherwise removal from the allowlist would never bite
# while silent renewal keeps extending the session.
allowed = _allowed_groups()
groups = _claim_groups(effective)
if allowed and not (set(groups) & set(allowed)):
denied_user = str(effective.get(settings.OIDC_USER_ID_CLAIM) or decoded["sub"])
logger.info("OIDC refresh denied for %s: groups %s not allowed", denied_user, groups)
_record_login_denied(
denied_user,
{"reason": "not_authorized", "via": "refresh", "groups": groups},
)
return make_response(jsonify({"error": "not_authorized"}), 401)
user_id = effective.get(settings.OIDC_USER_ID_CLAIM)
if user_id:
user_id = str(user_id)
# The pre-grant gate only saw the old sub. If the refreshed identity
# maps to a different user id, re-check that account is enabled
# before minting a session for it.
if user_id != str(decoded["sub"]) and _is_account_disabled(user_id):
return make_response(jsonify({"error": "account_disabled"}), 401)
identity["sub"] = user_id
for claim in ("email", "name", "picture"):
if effective.get(claim):
identity[claim] = effective[claim]
identity["oidc_sub"] = effective.get("sub") or identity["oidc_sub"]
if effective.get("sid"):
identity["oidc_sid"] = effective["sid"]
# Re-reconcile admin on every renewal carrying fresh group claims, so an
# OIDC group add/remove takes effect without waiting for a full re-login.
# None when the claim is absent so a missing claim never revokes admin.
groups_present = settings.OIDC_GROUPS_CLAIM in effective
_reconcile_oidc_admin(identity["sub"], groups if groups_present else None)
# Re-check revocation right before minting, against the (possibly remapped)
# identity but with the ORIGINAL session's iat: a back-channel logout or
# SCIM deny that landed during the IdP grant — or one targeting the
# refreshed sub/sid — sets a watermark newer than this iat and must block
# renewal. The renewed token's own (fresh) iat would post-date the
# watermark and slip past the per-request check, so anchor on the old iat.
if denylist.is_denied(
{
"sub": identity["sub"],
"oidc_sub": identity.get("oidc_sub"),
"oidc_sid": identity.get("oidc_sid"),
"iat": decoded.get("iat"),
}
):
return make_response(jsonify({"error": "token_revoked"}), 401)
new_token, new_jti = _mint_session_token(identity)
new_refresh = tokens.get("refresh_token") or refresh_token
try:
redis.set(_refresh_key(new_jti), new_refresh, ex=settings.OIDC_SESSION_LIFETIME_SECONDS)
except Exception:
logger.warning("Failed to store rotated OIDC refresh token", exc_info=True)
try:
with db_session() as conn:
AuthEventsRepository(conn).insert(
identity["sub"],
"oidc_refresh",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
except Exception:
logger.warning("Failed to record oidc_refresh for %s", identity["sub"], exc_info=True)
return jsonify({"token": new_token})
def oidc_backchannel_logout():
"""Revoke sessions named by a signed IdP back-channel logout token."""
logout_token = request.form.get("logout_token")
if not logout_token:
body = request.get_json(silent=True)
if isinstance(body, dict):
logout_token = body.get("logout_token")
if not logout_token or not isinstance(logout_token, str):
return _no_store(jsonify({"error": "missing_logout_token"}), 400)
try:
claims = provider.validate_logout_token(logout_token)
except provider.OIDCError:
logger.warning("Rejected OIDC back-channel logout token", exc_info=True)
return _no_store(jsonify({"error": "invalid_logout_token"}), 400)
# Reject stale tokens: past the jti replay-cache window we can no longer
# detect replays by jti, so bound acceptance to that window (logout tokens
# are short-lived). Combined with the always-on jti check below, a captured
# token can be replayed neither within the window (jti dedupe) nor after it
# (iat too old).
now = int(time.time())
iat = claims.get("iat")
if not isinstance(iat, (int, float)) or now - iat > LOGOUT_JTI_TTL_SECONDS:
logger.warning("Rejected stale OIDC back-channel logout token")
return _no_store(jsonify({"error": "invalid_logout_token"}), 400)
jti = claims.get("jti")
redis = get_redis_instance()
if redis is not None and jti:
try:
fresh = redis.set(_logout_jti_key(str(jti)), "1", ex=LOGOUT_JTI_TTL_SECONDS, nx=True)
except Exception:
logger.warning("Logout-token jti replay check failed; accepting token", exc_info=True)
fresh = True
if not fresh:
return _no_store(jsonify({"error": "invalid_logout_token"}), 400)
sub = claims.get("sub")
sid = claims.get("sid")
revoked = True
if sub:
revoked = bool(denylist.deny_idp_sub(str(sub))) and revoked
if sid:
revoked = bool(denylist.deny_sid(str(sid))) and revoked
if not revoked:
# The denylist write failed (Redis down). Report failure so the IdP
# retries rather than recording a logout that never took effect.
logger.error("Back-channel logout could not persist the revocation")
return _no_store(jsonify({"error": "revocation_unavailable"}), 502)
user_id = str(sub) if sub else f"sid:{sid}"
try:
with db_session() as conn:
AuthEventsRepository(conn).insert(
user_id,
"backchannel_logout",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={"sid": str(sid)} if sid else None,
)
except Exception:
logger.warning("Failed to record backchannel_logout for %s", user_id, exc_info=True)
return _no_store("", 200)
def oidc_logout():
"""Redirect to the IdP end-session endpoint, falling back to the frontend."""
frontend = _frontend_url()
try:
end_session = provider.get_discovery().get("end_session_endpoint")
except provider.OIDCError:
end_session = None
if not end_session:
return redirect(frontend, code=302)
params = {
"post_logout_redirect_uri": frontend,
"client_id": settings.OIDC_CLIENT_ID,
}
return redirect(f"{end_session}?{urlencode(params)}", code=302)
def _require_oidc_enabled() -> Response | None:
"""404 every oidc route unless OIDC is the active auth mode.
Registration is unconditional (so the import-time auth mode doesn't matter),
but the endpoints only work under ``AUTH_TYPE=oidc`` — otherwise OIDC_ISSUER
is unset and ``get_discovery`` would dereference ``None`` and 500. Mirrors
SCIM's ``SCIM_ENABLED`` gate.
"""
if settings.AUTH_TYPE != "oidc":
return make_response(jsonify({"error": "oidc_not_enabled"}), 404)
return None
def register(bp: Blueprint) -> None:
"""Attach the oidc auth routes to ``bp``."""
bp.before_request(_require_oidc_enabled)
bp.add_url_rule(
"/api/auth/oidc/login", view_func=oidc_login, methods=["GET"], endpoint="login"
)
bp.add_url_rule(
"/api/auth/oidc/callback", view_func=oidc_callback, methods=["GET"], endpoint="callback"
)
bp.add_url_rule(
"/api/auth/oidc/token", view_func=oidc_token, methods=["POST"], endpoint="token"
)
bp.add_url_rule(
"/api/auth/oidc/refresh", view_func=oidc_refresh, methods=["POST"], endpoint="refresh"
)
bp.add_url_rule(
"/api/auth/oidc/backchannel-logout",
view_func=oidc_backchannel_logout,
methods=["POST"],
endpoint="backchannel_logout",
)
bp.add_url_rule(
"/api/auth/oidc/logout", view_func=oidc_logout, methods=["GET"], endpoint="logout"
)