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:
1 parent
3187b8cf05
commit
27677215bd
3 files changed
+151
-7
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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("/")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user