Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

271 lines
9.9 KiB
Python

"""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()