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.
This commit is contained in:
Archibold committed 2026-11-05 16:24:22 +11:00
1 parent 3187b8cf05
commit 27677215bd
3 files changed
+151 -7

No files matched your search

+4
View File
@@ -11,3 +11,7 @@ wheels/
# Runtime state (services store is created on first run) # Runtime state (services store is created on first run)
data/ data/
# Secrets
.secrets
.oauth_creds
+8
View File
@@ -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`, against the issuer's JWKS → a signed `session` cookie (`HttpOnly`, `SameSite=lax`,
`max_age=UWUDASH_SESSION_TTL`) holds the user id → `/`. `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 Once auth is on, every route except `/health`, `/auth/*`, `/static` and FastAPI's
own `/docs` / `/openapi.json` requires a session: unauthenticated requests get own `/docs` / `/openapi.json` requires a session: unauthenticated requests get
`401` (API) or a redirect to `/auth/login` (the HTML page). `401` (API) or a redirect to `/auth/login` (the HTML page).
+139 -7
View File
@@ -11,18 +11,141 @@ and no hand-rolled token.
from __future__ import annotations from __future__ import annotations
import json
import time
from typing import Optional, Tuple from typing import Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
import httpx
from authlib.integrations.starlette_client import OAuth, OAuthError 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 import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates 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 .config import Config
from .models import User from .models import User
from .users import UserStore 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: def create_oauth(config: Config) -> OAuth:
oauth = OAuth() oauth = OAuth()
@@ -32,6 +155,7 @@ def create_oauth(config: Config) -> OAuth:
client_secret=config.oidc_client_secret, client_secret=config.oidc_client_secret,
server_metadata_url=config.oidc_issuer + "/.well-known/openid-configuration", server_metadata_url=config.oidc_issuer + "/.well-known/openid-configuration",
client_kwargs={"scope": config.oidc_scope}, client_kwargs={"scope": config.oidc_scope},
client_cls=OIDCClient,
) )
return oauth return oauth
@@ -98,7 +222,12 @@ def create_auth_router(
@router.get("/auth/authorize") @router.get("/auth/authorize")
async def authorize(request: Request): async def authorize(request: Request):
redirect_uri = base_url(request, config) + "/auth/callback" 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") @router.get("/auth/callback")
async def callback(request: Request): async def callback(request: Request):
@@ -126,12 +255,15 @@ def create_auth_router(
) )
request.session["user_id"] = user.id request.session["user_id"] = user.id
# RP-initiated logout needs the id_token; only keep it when the issuer # RP-initiated logout needs the id_token, but browsers drop cookies
# advertises an end_session_endpoint. # over 4 KiB and an encrypted id_token can be that big on its own,
if token.get("id_token") and "end_session_endpoint" in ( # which silently kills the session. Keep it only if the whole session
oauth.oidc.server_metadata or {} # still fits in a cookie.
): id_token = token.get("id_token")
request.session["id_token"] = token["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("/") return RedirectResponse("/")