From acb8f3a556b4d937bd5bd2b3a94caa0cf693ff88 Mon Sep 17 00:00:00 2001 From: Tiago Correia Date: Sun, 10 May 2026 13:17:56 +0100 Subject: [PATCH] fix: enable PKCE on the OIDC client and surface IdP errors gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login was 500ing on the Keycloak callback with `Missing parameter: code_challenge_method`. The Keycloak client requires PKCE (S256), but Authlib doesn't auto-enable PKCE from server metadata for backwards compatibility — it has to be opted into per-client. Adding `code_challenge_method: "S256"` to client_kwargs makes Authlib generate the code_verifier, hash it, and send code_challenge / code_challenge_method on the authorize redirect, then replay the verifier on the token exchange. Also: when Keycloak redirects to /auth/callback with ?error=... (no authorization code), Authlib's authorize_access_token raises OAuthError. That used to fall through to a generic Flask 500. Catch it explicitly, log the upstream error/description, and bounce back to /auth/login — the next IdP-side rejection will be diagnosable from CloudWatch instead of opaque, and the user gets a fresh login attempt rather than an internal-server-error page. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/__init__.py | 9 ++++++++- app/auth/routes.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 01893e4..d23cc08 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -45,7 +45,14 @@ def create_app(config_class=Config): server_metadata_url=f"{app.config['OIDC_ISSUER_URL'].rstrip('/')}/.well-known/openid-configuration", client_id=app.config["OIDC_CLIENT_ID"], client_secret=app.config["OIDC_CLIENT_SECRET"], - client_kwargs={"scope": "openid email profile"}, + # PKCE is required by the Keycloak client config (S256). Authlib + # doesn't auto-enable it from server metadata; opting in here makes + # the SDK generate the code_verifier and send code_challenge / + # code_challenge_method on the authorize redirect. + client_kwargs={ + "scope": "openid email profile", + "code_challenge_method": "S256", + }, ) from app.auth import bp as auth_bp diff --git a/app/auth/routes.py b/app/auth/routes.py index f6af083..c614c67 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,6 +1,7 @@ import logging from urllib.parse import urlencode +from authlib.integrations.base_client.errors import OAuthError from flask import current_app, redirect, request, session, url_for from app.auth import bp @@ -20,7 +21,20 @@ def login(): @bp.route("/callback") def callback(): - token = oauth.keycloak.authorize_access_token() + # Keycloak surfaces auth-time errors by redirecting back here with + # ?error=...&error_description=... (no `code`), which makes Authlib's + # token exchange raise OAuthError. Logging the upstream description + # makes the cause obvious in CloudWatch instead of a generic 500. + try: + token = oauth.keycloak.authorize_access_token() + except OAuthError as exc: + logger.warning( + "OIDC callback rejected by Keycloak: %s — %s", + exc.error, + exc.description, + ) + return redirect(url_for("auth.login")) + claims = token.get("userinfo") or {} sub = claims.get("sub")