From 27677215bde5230fdc5bf6cbd2de4bb0e57e249e Mon Sep 17 00:00:00 2001 From: Archibold Date: Thu, 5 Nov 2026 16:24:22 +1100 Subject: [PATCH] Handle encrypted and HS256 id_tokens in the OIDC callback Authlib's parse_id_token only reads a 3-segment JWS, so sign-in 500s against providers that encrypt the id_token (Authentik with an Encryption Key issues a 5-segment RSA-OAEP-256/A256CBC-HS512 JWE) or that sign it with the client secret (Authentik with no Signing Key issues HS256, which the RS256-only JWKS rejects). OIDCClient subclasses the starlette OAuth2 client and, when the standard parse fails on a format problem, falls back to an HS256 decode with the client secret, then to the issuer's userinfo_endpoint. Claim-level failures (bad nonce, expiry, audience, issuer) are raised as OAuthError so /auth/callback redirects to /auth/login?error=... instead of blowing up. /auth/authorize likewise catches provider/network errors and redirects to the login page. --- .gitignore | 4 ++ README.md | 8 +++ src/uwudash_mk3/auth.py | 146 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b53078d..1278e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ wheels/ # Runtime state (services store is created on first run) data/ + +# Secrets +.secrets +.oauth_creds diff --git a/README.md b/README.md index 21672b9..cb32854 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ Flow: `/` with no session redirects to `/auth/login` → `Sign in` → against the issuer's JWKS → a signed `session` cookie (`HttpOnly`, `SameSite=lax`, `max_age=UWUDASH_SESSION_TTL`) holds the user id → `/`. +`/auth/callback` also copes with providers whose `id_token` authlib cannot read: +an **encrypted** one (a 5-segment JWE, e.g. an Authentik provider with an +Encryption Key) is resolved through the issuer's `userinfo_endpoint`, and an +**HS256** one (what Authentik issues when no Signing Key is configured) is +verified with the client secret. Claims that fail validation - wrong `iss`, +`aud`, `exp` or `nonce` - are rejected outright: the login redirects to +`/auth/login?error=...` instead of returning a 500. + Once auth is on, every route except `/health`, `/auth/*`, `/static` and FastAPI's own `/docs` / `/openapi.json` requires a session: unauthenticated requests get `401` (API) or a redirect to `/auth/login` (the HTML page). diff --git a/src/uwudash_mk3/auth.py b/src/uwudash_mk3/auth.py index 590d89c..af64ae0 100644 --- a/src/uwudash_mk3/auth.py +++ b/src/uwudash_mk3/auth.py @@ -11,18 +11,141 @@ and no hand-rolled token. from __future__ import annotations +import json +import time from typing import Optional, Tuple from urllib.parse import quote +import httpx from authlib.integrations.starlette_client import OAuth, OAuthError +from authlib.integrations.starlette_client.apps import StarletteOAuth2App +from authlib.oidc.core import UserInfo from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates +from joserfc import jwt +from joserfc.errors import ClaimError, JoseError +from joserfc.jwk import OctKey from .config import Config from .models import User from .users import UserStore +ID_TOKEN_LEEWAY = 120 +# Browsers silently drop cookies whose value exceeds 4 KiB, so the signed +# session blob has to stay well under that. +SESSION_COOKIE_LIMIT = 2500 + + +class OIDCClient(StarletteOAuth2App): + """The starlette OIDC client, with id_token formats authlib rejects. + + authlib's ``parse_id_token`` only understands a signed (JWS) id_token + verified against the issuer's JWKS. Two provider configurations break that + assumption: + + * no signing key configured - the id_token is HS256 signed with the client + secret and the JWKS is empty (``KeyError: 'keys'``); + * an encryption key configured - the id_token is a JWE compact sequence + (5 segments) and the JWS decoder rejects it (``DecodeError``). + + Both used to surface as a bare 500 on ``/auth/callback``. The client now + decodes an HS256 id_token with its own client secret, and otherwise falls + back to the issuer's ``userinfo_endpoint`` - the only way to read an + encrypted id_token, since the private key never leaves the issuer. + """ + + async def parse_id_token( + self, token, nonce, claims_options=None, claims_cls=None, leeway=ID_TOKEN_LEEWAY + ) -> UserInfo: + id_token = token.get("id_token") + if not id_token: + return None + try: + return await super().parse_id_token( + token, nonce, claims_options, claims_cls, leeway + ) + except ClaimError as exc: + # The issuer signed a token that does not match this login (bad + # nonce, expired, wrong audience). No fallback: that is a real + # rejection, not a format problem. + raise OAuthError(description=f"invalid id_token: {exc}") from exc + except (JoseError, KeyError, ValueError) as exc: + # joserfc raises ValueError when the advertised signing key type + # does not match the token's algorithm (e.g. an HS256 id_token + # against an RS256-only JWKS). + claims = self._claims_from_id_token(id_token, nonce) + if claims is None: + claims = await self._claims_from_userinfo(token, nonce) + if claims is None: + raise OAuthError( + description=f"could not validate the id_token ({exc})" + ) from exc + return UserInfo(claims) + + def _claims_from_id_token(self, id_token: str, nonce: str) -> Optional[dict]: + """Claims from an id_token authlib could not verify. + + Only an HS256 id_token is recoverable locally - the RP's own client + secret is the key. An *encrypted* id_token is not: the issuer keeps the + private key and publishes only the public half in its JWKS, so the + dashboard can never read it. That case goes through + ``userinfo_endpoint`` instead. + """ + if not self.client_secret or id_token.count(".") != 2: + return None + try: + claims = dict(jwt.decode(id_token, OctKey.import_key(self.client_secret)).claims) + except JoseError: + return None + return claims if self._claims_are_valid(claims, nonce) else None + + async def _claims_from_userinfo(self, token: dict, nonce: str) -> Optional[dict]: + """Claims from the issuer's ``userinfo_endpoint``. + + A provider that encrypts id_tokens encrypts the access token too, and + only the issuer holds the private key, so the dashboard can never read + either one. ``userinfo`` is the OIDC-standard escape hatch: the issuer + decrypts the access token server-side and returns the same claims. The + access token can only be minted by exchanging an authorization code + with the client secret, so it still proves this login. + """ + endpoint = (await self.load_server_metadata()).get("userinfo_endpoint") + access_token = token.get("access_token") + if not endpoint or not access_token: + return None + try: + response = httpx.get( + endpoint, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10.0, + ) + response.raise_for_status() + claims = response.json() + except (httpx.HTTPError, ValueError): + return None + if not isinstance(claims, dict) or not claims.get("sub"): + return None + # Authentik echoes the authorize-request nonce here as well. + if nonce and claims.get("nonce") and claims["nonce"] != nonce: + return None + return claims + + def _claims_are_valid(self, claims: dict, nonce: str) -> bool: + issuer = (self.server_metadata or {}).get("issuer") + if issuer and claims.get("iss") != issuer: + return False + audience = claims.get("aud") + audience = [audience] if isinstance(audience, str) else audience or [] + if self.client_id and self.client_id not in audience: + return False + expiry = claims.get("exp") + if expiry is not None and expiry < int(time.time()) - ID_TOKEN_LEEWAY: + return False + if nonce and claims.get("nonce") != nonce: + return False + return bool(claims.get("sub")) + def create_oauth(config: Config) -> OAuth: oauth = OAuth() @@ -32,6 +155,7 @@ def create_oauth(config: Config) -> OAuth: client_secret=config.oidc_client_secret, server_metadata_url=config.oidc_issuer + "/.well-known/openid-configuration", client_kwargs={"scope": config.oidc_scope}, + client_cls=OIDCClient, ) return oauth @@ -98,7 +222,12 @@ def create_auth_router( @router.get("/auth/authorize") async def authorize(request: Request): redirect_uri = base_url(request, config) + "/auth/callback" - return await oauth.oidc.authorize_redirect(request, redirect_uri) + try: + return await oauth.oidc.authorize_redirect(request, redirect_uri) + except (OAuthError, httpx.HTTPError) as exc: + # Issuer unreachable or discovery failed: show it on the login page + # instead of a bare 500. + return RedirectResponse(f"/auth/login?error={quote(str(exc))}") @router.get("/auth/callback") async def callback(request: Request): @@ -126,12 +255,15 @@ def create_auth_router( ) request.session["user_id"] = user.id - # RP-initiated logout needs the id_token; only keep it when the issuer - # advertises an end_session_endpoint. - if token.get("id_token") and "end_session_endpoint" in ( - oauth.oidc.server_metadata or {} - ): - request.session["id_token"] = token["id_token"] + # RP-initiated logout needs the id_token, but browsers drop cookies + # over 4 KiB and an encrypted id_token can be that big on its own, + # which silently kills the session. Keep it only if the whole session + # still fits in a cookie. + id_token = token.get("id_token") + if id_token and "end_session_endpoint" in (oauth.oidc.server_metadata or {}): + request.session["id_token"] = id_token + if len(json.dumps(dict(request.session))) > SESSION_COOKIE_LIMIT: + request.session.pop("id_token") return RedirectResponse("/")