fix: enable PKCE on the OIDC client and surface IdP errors gracefully#8
Merged
Conversation
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Enables PKCE (S256) on the Authlib OIDC client to satisfy the Keycloak client’s PKCE requirement, and improves the /auth/callback handler so upstream IdP errors don’t surface as a 500.
Changes:
- Opt in to PKCE by setting
code_challenge_method="S256"on the registered Keycloak OAuth client. - Catch
OAuthErrorin the OIDC callback, log the upstream error details at WARNING, and redirect users back to/auth/logininstead of returning a 500.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| app/init.py | Enables PKCE (S256) on the registered Keycloak Authlib client via client_kwargs. |
| app/auth/routes.py | Adds OAuthError handling in the callback to log IdP rejection details and redirect to login gracefully. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What broke
After PR #7 fixed the redirect_uri scheme, login made it past Keycloak's authorize step but 500'd on the callback:
```
authlib.integrations.base_client.errors.OAuthError: invalid_request: Missing parameter: code_challenge_method
GET /auth/callback?error=invalid_request&error_description=Missing+parameter%3A+code_challenge_method&state=...
```
Root cause
The Keycloak client is configured to require PKCE (S256). Authlib registers OIDC clients with PKCE off by default for backwards compatibility — server metadata says nothing about whether PKCE is required, so the SDK doesn't auto-enable it. Result: authorize redirect doesn't include `code_challenge` / `code_challenge_method`, Keycloak rejects the request, and the user lands on `/auth/callback?error=invalid_request` (with no authorization code). Authlib's `authorize_access_token()` then raises `OAuthError`, which we weren't catching, so Flask returned a 500.
Fix
Primary — opt into PKCE on the Authlib client (app/init.py):
```python
client_kwargs={
"scope": "openid email profile",
"code_challenge_method": "S256",
},
```
Authlib then generates the `code_verifier` per-request, hashes it (SHA-256), sends `code_challenge` + `code_challenge_method=S256` on the authorize redirect, and replays the verifier on the token exchange. All transparent — verifier is stashed in the Flask session.
Secondary — graceful error handling on the callback (app/auth/routes.py). Future IdP-side rejections (PKCE issue, user denying consent, scope mismatch) now log the upstream error+description at WARNING and redirect to `/auth/login` instead of returning 500. Cause shows up in CloudWatch, user gets a retry rather than a stack trace.
Why this wasn't caught earlier
My original prompt to the keycloak-config session said "PKCE Code Challenge Method: S256 (Advanced settings)" — they correctly set the server-side requirement, but the client-side opt-in in Authlib needs to be enabled separately. The two halves have to agree, and "the IdP requires it" doesn't auto-trigger the SDK to send it. Worth remembering for future OIDC integrations: PKCE is opt-in on the relying party.
Deploy
App-only change, no Terraform:
```bash
aws ecs update-service \
--region eu-west-1 \
--cluster librarian-services \
--service librarian-services-api \
--force-new-deployment
```
Test plan
🤖 Generated with Claude Code