Files
ScarriffleandClaude Opus 5 b560ee260c fix(oidc): Nonce-Pruefung selbst kontrollieren statt Authlib zu ueberlassen
Authlib wertet "Aufrufer hat eine Nonce uebergeben, das Token traegt keine"
als Mismatch. Das ist fuer den erneuerten Token der mobilen Clients falsch:
ein per Refresh-Token geholtes ID-Token laesst die nonce-Claim regulaer weg,
womit der Hintergrund-Refresh immer an einem 401 gescheitert waere.

Die Nonce wird daher nicht mehr an Authlib durchgereicht, sondern hier
geprueft: vorhandene Nonce muss passen, und der Browser-Flow verlangt sie
zusaetzlich zwingend (require_nonce) — dort erzeugen wir sie selbst, also
muss der Provider sie zurueckspiegeln. Signatur und uebrige Claims bleiben
unveraendert bei Authlib.

Zwei Tests dazu: erneuertes Token ohne Nonce wird angenommen, Browser-Flow
ohne Nonce abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:19 +02:00

292 lines
11 KiB
Python

"""OpenID Connect protocol client: discovery, JWKS, token exchange, validation.
HTTP goes through ``requests`` (already a dependency, and what the Google and
Home Assistant routers use); everything cryptographic goes through Authlib.
We deliberately use only ``authlib.jose`` and not
``authlib.integrations.starlette_client``, which would drag in
``SessionMiddleware``/``itsdangerous`` and a second authentication surface
alongside the existing bearer JWT.
Unlike ``homeassistant_router``, every request here verifies TLS.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
from typing import Optional
import requests as http_requests
from authlib.jose import JsonWebKey, JsonWebToken
from authlib.oidc.core import CodeIDToken
from oidc_config import OIDCProvider, accepted_issuers
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 10
DISCOVERY_TTL = 3600
JWKS_TTL = 3600
# Never refetch the JWKS more than once a minute, so an unknown `kid` cannot be
# used to hammer the identity provider.
JWKS_MIN_REFETCH = 60
# Signature algorithms we accept. This allow-list is the single most important
# line in this module: the module-level ``authlib.jose.jwt`` helper honours
# whatever `alg` the token header declares, which is the classic "alg: none" /
# algorithm-confusion hole. Asymmetric only — an HMAC algorithm here would let
# anyone who can read the (public) JWKS mint their own tokens.
SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
"ES256", "ES384", "ES512"]
_JWT = JsonWebToken(SIGNING_ALGORITHMS)
_AT_HASH_DIGESTS = {"256": hashlib.sha256, "384": hashlib.sha384, "512": hashlib.sha512}
class OIDCError(Exception):
"""Protocol-level failure. ``slug`` is a stable, user-safe error code."""
def __init__(self, slug: str, detail: str = ""):
super().__init__(detail or slug)
self.slug = slug
self.detail = detail or slug
# ── caches ───────────────────────────────────────────────────────────────────
_discovery_cache: dict = {} # provider key -> (fetched_at, document)
_jwks_cache: dict = {} # provider key -> (fetched_at, key_set)
def reset_cache() -> None:
"""Drop discovery and JWKS caches — used by tests."""
_discovery_cache.clear()
_jwks_cache.clear()
def _http_get(url: str, timeout: int = HTTP_TIMEOUT) -> dict:
"""The single network seam for GETs, so tests can stub one function.
Transport failures (DNS, timeout, TLS) must surface as OIDCError like any
other protocol failure — otherwise an unreachable provider turns into an
HTTP 500 instead of a clean "try again" on the login screen.
"""
try:
resp = http_requests.get(url, timeout=timeout)
except http_requests.RequestException as e:
raise OIDCError("oidc_provider_unreachable", f"GET {url}: {e}")
if resp.status_code != 200:
raise OIDCError("oidc_discovery_failed", f"GET {url} -> {resp.status_code}")
try:
return resp.json()
except ValueError:
raise OIDCError("oidc_discovery_failed", f"GET {url}: response is not JSON")
def discovery_url(provider: OIDCProvider) -> str:
return provider.issuer.rstrip("/") + "/.well-known/openid-configuration"
def discover(provider: OIDCProvider, force: bool = False) -> dict:
"""Fetch and cache the provider's OpenID configuration document."""
cached = _discovery_cache.get(provider.key)
if cached and not force and (time.time() - cached[0]) < DISCOVERY_TTL:
return cached[1]
doc = _http_get(discovery_url(provider))
# Mix-up defence: the document must claim the issuer we configured.
doc_issuer = (doc.get("issuer") or "").rstrip("/")
if doc_issuer and doc_issuer != provider.issuer.rstrip("/"):
raise OIDCError(
"oidc_issuer_mismatch",
f"discovery issuer {doc_issuer!r} != configured {provider.issuer!r}",
)
_discovery_cache[provider.key] = (time.time(), doc)
return doc
def _endpoint(provider: OIDCProvider, name: str) -> str:
url = discover(provider).get(name)
if not url:
raise OIDCError("oidc_discovery_incomplete", f"{name} missing from discovery")
return url
def authorization_endpoint(provider: OIDCProvider) -> str:
return _endpoint(provider, "authorization_endpoint")
def token_endpoint(provider: OIDCProvider) -> str:
return _endpoint(provider, "token_endpoint")
def jwks_key_set(provider: OIDCProvider, force: bool = False):
"""Fetch and cache the provider's JWKS as an Authlib key set."""
cached = _jwks_cache.get(provider.key)
now = time.time()
if cached and not force and (now - cached[0]) < JWKS_TTL:
return cached[1]
if cached and force and (now - cached[0]) < JWKS_MIN_REFETCH:
# Rate-limit forced refetches so unknown kids can't be used as a DoS.
return cached[1]
jwks = _http_get(_endpoint(provider, "jwks_uri"))
key_set = JsonWebKey.import_key_set(jwks)
_jwks_cache[provider.key] = (now, key_set)
return key_set
def exchange_code(provider: OIDCProvider, code: str, redirect_uri: str,
code_verifier: str) -> dict:
"""Trade an authorization code for tokens (always with PKCE)."""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": provider.client_id,
"code_verifier": code_verifier,
}
# Confidential client -> client_secret_post. Public client -> PKCE only.
if provider.client_secret:
data["client_secret"] = provider.client_secret
try:
resp = http_requests.post(
token_endpoint(provider), data=data, timeout=HTTP_TIMEOUT,
headers={"Accept": "application/json"},
)
except http_requests.RequestException as e:
raise OIDCError("oidc_provider_unreachable", f"token endpoint: {e}")
if resp.status_code != 200:
logger.warning("OIDC token exchange failed (%s): %s",
resp.status_code, resp.text[:300])
raise OIDCError("oidc_token_exchange_failed")
try:
tokens = resp.json()
except ValueError:
raise OIDCError("oidc_token_exchange_failed", "token response is not JSON")
if not tokens.get("id_token"):
raise OIDCError("oidc_no_id_token")
return tokens
def fetch_userinfo(provider: OIDCProvider, access_token: str) -> dict:
"""Userinfo lookup — only used when the ID token carries no email."""
try:
url = _endpoint(provider, "userinfo_endpoint")
resp = http_requests.get(
url, timeout=HTTP_TIMEOUT,
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return {}
return resp.json()
except Exception:
return {}
def _verify_at_hash(claims, access_token: str) -> None:
"""Bind the ID token to its access token (OIDC core 3.1.3.6)."""
at_hash = claims.get("at_hash")
if not at_hash or not access_token:
return
alg = (claims.header or {}).get("alg", "")
digest_fn = _AT_HASH_DIGESTS.get(alg[-3:])
if digest_fn is None:
return
digest = digest_fn(access_token.encode("ascii")).digest()
expected = base64.urlsafe_b64encode(digest[: len(digest) // 2]).rstrip(b"=").decode()
if not hmac.compare_digest(expected, at_hash):
raise OIDCError("oidc_at_hash_mismatch")
def validate_id_token(provider: OIDCProvider, id_token: str, *,
expected_audience: str,
nonce: Optional[str] = None,
require_nonce: bool = False,
access_token: Optional[str] = None) -> dict:
"""Verify an ID token's signature and claims. Returns the claim dict.
``nonce`` is the value the client bound into the flow. When
``require_nonce`` is set the token must carry a matching one — that is the
browser flow, where we generated the nonce ourselves and the provider is
obliged to echo it. Native clients also pass a nonce, but a token they
renewed with a refresh token legitimately no longer carries the claim, so
there the check applies only if the claim is present.
Raises :class:`OIDCError` with a stable slug on any failure.
"""
issuers = accepted_issuers(provider)
def _decode(force_jwks: bool):
key_set = jwks_key_set(provider, force=force_jwks)
return _JWT.decode(
id_token, key_set, claims_cls=CodeIDToken,
claims_options={
"iss": {"essential": True, "values": sorted(issuers)},
"aud": {"essential": True, "values": [expected_audience]},
"exp": {"essential": True},
"sub": {"essential": True},
},
# Nonce deliberately omitted: Authlib treats "caller supplied a
# nonce but the token has none" as a mismatch, which breaks
# refreshed tokens. The policy is enforced explicitly below.
claims_params={"client_id": expected_audience,
"access_token": access_token},
)
try:
claims = _decode(force_jwks=False)
except OIDCError:
raise
except Exception as first_error:
# The provider may have rotated its signing key since we cached the
# JWKS. One rate-limited refetch avoids needing a restart.
try:
claims = _decode(force_jwks=True)
except Exception:
logger.info("OIDC id_token signature/decode failed: %s", first_error)
raise OIDCError("oidc_invalid_token")
try:
claims.validate(leeway=60)
except Exception as e:
logger.info("OIDC id_token claim validation failed: %s", e)
raise OIDCError("oidc_invalid_token")
# Explicit re-checks. Authlib already covers these, but stating them here
# makes the security contract readable and testable without depending on
# Authlib's internal claim-validation details.
if (claims.get("iss") or "").rstrip("/") not in {i.rstrip("/") for i in issuers}:
raise OIDCError("oidc_bad_issuer")
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if expected_audience not in aud_list:
raise OIDCError("oidc_bad_audience")
# With a multi-valued `aud`, `azp` must name the client the token is for.
if len(aud_list) > 1:
azp = claims.get("azp")
if azp and azp != expected_audience:
raise OIDCError("oidc_bad_azp")
if not claims.get("sub"):
raise OIDCError("oidc_no_subject")
token_nonce = claims.get("nonce")
if require_nonce and not token_nonce:
raise OIDCError("oidc_nonce_missing")
if nonce is not None and token_nonce is not None and token_nonce != nonce:
raise OIDCError("oidc_nonce_mismatch")
if access_token:
_verify_at_hash(claims, access_token)
return dict(claims)