Files
ping-mee-self-service-platform/portal/adapters/idp/oidc.py
T
ebbe.bassandClaude Sonnet 5.5 700295cb72 refactor: sweep the platform-and-sign-in epic
Split load_config into per-section readers, use contextlib.suppress and a single condition where ruff asked, remove stale noqa comments, add the SIM and RUF100 rules, share the sign_in test helper, align AD-6 with the code and record the remaining deferred findings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
2026-10-18 09:29:59 +02:00

177 lines
7.4 KiB
Python

"""OpenID Connect authorization-code flow with PKCE. The only module that talks OIDC (AD-1)."""
from __future__ import annotations
import hmac
import logging
import secrets
import threading
import time
from collections.abc import Callable, Mapping
from typing import Any
import requests
from authlib.common.errors import AuthlibBaseError
from authlib.integrations.requests_client import OAuth2Session
from joserfc import jwt
from joserfc.errors import InvalidKeyIdError, JoseError
from joserfc.jwk import KeySet
from portal.config.env import OidcConfig
from .base import Flow, Identity, IdpUnavailable, LoginRejected
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
CACHE_SECONDS = 60 * 60
CLOCK_LEEWAY_SECONDS = 30
SCOPE = "openid profile email"
# Asymmetric algorithms only: a token signed with a shared secret is never accepted.
ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
SessionFactory = Callable[[], OAuth2Session]
class OidcIdp:
def __init__(
self,
config: OidcConfig,
session_factory: SessionFactory | None = None,
clock: Callable[[], float] = time.time,
) -> None:
self._config = config
self._clock = clock
self._factory = session_factory or self._default_session
self._lock = threading.Lock()
self._metadata: dict[str, Any] | None = None
self._metadata_at = 0.0
self._keys: KeySet | None = None
self._keys_at = 0.0
def _default_session(self) -> OAuth2Session:
return OAuth2Session(
client_id=self._config.client_id,
client_secret=self._config.client_secret,
scope=SCOPE,
redirect_uri=self._config.redirect_uri,
code_challenge_method="S256",
)
# -- provider documents ----------------------------------------------------------
def _get_json(self, session: OAuth2Session, url: str) -> dict[str, Any]:
try:
response = session.get(url, timeout=TIMEOUT_SECONDS, withhold_token=True)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError) as exc:
logger.warning("Identity provider request failed: %s", type(exc).__name__)
raise IdpUnavailable("The identity provider is not available") from exc
if not isinstance(data, dict):
raise IdpUnavailable("The identity provider sent an unusable document")
return data
def _discovery(self, session: OAuth2Session) -> dict[str, Any]:
with self._lock:
if self._metadata is not None and self._clock() - self._metadata_at < CACHE_SECONDS:
return self._metadata
url = self._config.issuer.rstrip("/") + "/.well-known/openid-configuration"
data = self._get_json(session, url)
if data.get("issuer") != self._config.issuer:
raise IdpUnavailable("The identity provider reports a different issuer")
for name in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
if not isinstance(data.get(name), str) or not data[name].startswith("https://"):
raise IdpUnavailable(f"The identity provider document lacks a usable {name}")
with self._lock:
self._metadata, self._metadata_at = data, self._clock()
return data
def _keyset(self, session: OAuth2Session, jwks_uri: str, *, refresh: bool) -> KeySet:
with self._lock:
fresh = self._clock() - self._keys_at < CACHE_SECONDS
if self._keys is not None and not refresh and fresh:
return self._keys
try:
keys = KeySet.import_key_set(self._get_json(session, jwks_uri))
except (JoseError, ValueError, KeyError) as exc:
raise IdpUnavailable("The identity provider sent unusable keys") from exc
with self._lock:
self._keys, self._keys_at = keys, self._clock()
return keys
# -- interface ---------------------------------------------------------------------
def begin(self) -> tuple[str, Flow]:
session = self._factory()
metadata = self._discovery(session)
flow = Flow(
state=secrets.token_urlsafe(32),
nonce=secrets.token_urlsafe(32),
code_verifier=secrets.token_urlsafe(64),
)
url, _ = session.create_authorization_url(
metadata["authorization_endpoint"],
state=flow.state,
nonce=flow.nonce,
code_verifier=flow.code_verifier,
)
return url, flow
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
if "error" in params:
raise LoginRejected("The identity provider refused the sign-in")
state, code = params.get("state", ""), params.get("code", "")
if not code or not hmac.compare_digest(state.encode(), flow.state.encode()):
raise LoginRejected("The sign-in response does not match the request")
session = self._factory()
metadata = self._discovery(session)
try:
token = session.fetch_token(
metadata["token_endpoint"],
grant_type="authorization_code",
code=code,
code_verifier=flow.code_verifier,
timeout=TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
logger.warning("Identity provider request failed: %s", type(exc).__name__)
raise IdpUnavailable("The identity provider is not available") from exc
except AuthlibBaseError as exc:
raise LoginRejected("The identity provider refused the code") from exc
id_token = token.get("id_token") if isinstance(token, dict) else None
if not isinstance(id_token, str):
raise LoginRejected("The identity provider sent no ID token")
return self._identity(session, metadata["jwks_uri"], id_token, flow)
def _identity(self, session: OAuth2Session, jwks_uri: str, id_token: str, flow: Flow):
decoded = None
for refresh in (False, True): # the second try covers a key rotation
keys = self._keyset(session, jwks_uri, refresh=refresh)
try:
decoded = jwt.decode(id_token, keys, algorithms=ALGORITHMS)
break
except InvalidKeyIdError as exc:
if refresh:
raise LoginRejected("The ID token is not valid") from exc
except JoseError as exc:
raise LoginRejected("The ID token is not valid") from exc
registry = jwt.JWTClaimsRegistry(
leeway=CLOCK_LEEWAY_SECONDS,
now=int(self._clock()),
iss={"essential": True, "value": self._config.issuer},
aud={"essential": True, "value": self._config.client_id},
exp={"essential": True},
sub={"essential": True},
nonce={"essential": True, "value": flow.nonce},
)
try:
registry.validate(decoded.claims)
except JoseError as exc:
raise LoginRejected("The ID token claims are not valid") from exc
username = decoded.claims.get("preferred_username")
if not isinstance(username, str) or not username.strip():
raise LoginRejected("The ID token has no usable user name")
return Identity(username=username.strip(), subject=str(decoded.claims["sub"]))