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
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:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user