feat: dual auth — accept Keycloak JWT alongside API key #269
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/pal-e-api!269
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "267-dual-auth-middleware"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add JWT validation via PyJWT + JWKS cache so the backend can identify WHO is making a request (Keycloak user) while preserving the existing API key auth for MCP agents. Adds
GET /meendpoint andget_current_user()dependency for downstream identity features.Changes
pyproject.toml— addedPyJWT[crypto]>=2.8dependencysrc/pal_e_docs/config.py— addedkeycloak_urlandkeycloak_realmsettings (env:PALDOCS_KEYCLOAK_URL,PALDOCS_KEYCLOAK_REALM)src/pal_e_docs/auth.py— addedUserContextdataclass,_JWKSCache(lazy init, cached keys),_decode_jwt(),_extract_user_context(),get_current_user()FastAPI dependency.get_is_authenticated()is untouchedsrc/pal_e_docs/routes/users.py(new) —GET /mereturns{sub, email, name, roles}from JWT or 401src/pal_e_docs/main.py— registeredusers_routertests/test_dual_auth.py(new) — 15 tests covering unit + integration: valid JWT, expired JWT, missing auth, JWKS failure, API key regressionTest Plan
pytest tests/ -v— 700 passed (all 144 existing + 15 new dual-auth tests)ruff checkandruff formatcleancurl -H "Authorization: Bearer {jwt}" /mereturns claimscurl -H "X-PALEDocs-Token: {key}" /notesstill worksReview Checklist
get_is_authenticated()unchanged — 20 endpoints unaffectedcache_keys=True)Related Notes
None — standalone feature ticket.
Related
Closes #267
PR #269 Review
DOMAIN REVIEW
Stack: Python / FastAPI / PyJWT / SQLAlchemy (pal-e-docs API)
auth.py additions (lines +1 to +87 in diff)
verify_aud: False-- Audience validation is disabled injwt.decode(). This is acceptable when the API is not registered as a specific OIDC client and accepts tokens issued to any Keycloak client in the realm. However, this means ANY valid Keycloak JWT for thepal-erealm will authenticate, regardless of which client it was issued to. Document this decision if it is intentional. If this API should only accept tokens from specific clients, addaudiencevalidation.Overly broad exception catch --
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, Exception)is redundant. TheExceptionbase class subsumes both PyJWT exceptions. This reads as if specific exceptions matter, but they do not in this catch. Simplify toexcept Exceptionor keep the specific ones and drop the base class (logging at DEBUG level could differentiate them):Empty
subsilently allowed --_extract_user_contextusesclaims.get("sub", ""). A JWT without asubclaim is malformed per OIDC spec. Returning aUserContext(sub="")could cause downstream bugs when identity features consume this. Consider raising or returning None for missingsub.JWKS cache has no TTL --
PyJWKClient(jwks_url, cache_keys=True)caches keys indefinitely. If Keycloak rotates signing keys, the cache will hold stale keys until the process restarts or_jwks_cache.reset()is called. PyJWT'scache_jwk_set+lifespanparameters (available in PyJWT >=2.8) offer TTL-based cache expiry. Worth adding for production resilience.config.py additions
keycloak_url: str = "https://keycloak.tail5b443a.ts.net"is a production infrastructure URL baked as a default. This works becauseenv_prefixallows override, but it means running tests or local dev without setting the env var will attempt to reach production Keycloak during JWKS fetch. The lazy init in_JWKSCachemitigates this (no fetch until first JWT arrives), so it is not a runtime bug, but a""default with validation would be more explicit.routes/users.py
GET /mereturns a raw dict. Adding aresponse_model(or returning a Pydantic model) gives OpenAPI schema generation, response validation, and prevents accidental data leakage ifUserContextgrows fields later.tests/test_dual_auth.py
Test coverage is solid -- 15 tests covering: valid JWT decode, expired JWT, JWKS failure,
UserContextextraction (full claims, missing name fallback, missing realm_access),get_current_user(valid, none, non-bearer, expired),GET /meintegration (valid, no auth, invalid token, expired), and API key regression. This meets the coverage bar.clientfixture dependency is correct -- Integration tests use theclientfixture fromconftest.py, which providesTestClient(app)with in-memory SQLite. The fixture is not imported in the test file because pytest discovers it automatically.get_is_authenticatedpreservation -- Verified against currentmain. The function at lines 13-24 of the existingauth.pyis completely unchanged in the diff. The 20 existing endpoints are unaffected.BLOCKERS
None.
get_is_authenticated(API key) andget_current_user(JWT) are separate, independent auth dependencies with different responsibilities.NITS
_decode_jwt-- theExceptionbase class makesExpiredSignatureErrorandInvalidTokenErrorredundant in the tuple.UserResponsemodel forGET /meresponse -- improves OpenAPI docs and prevents accidental field leakage.subis non-empty in_extract_user_contextrather than defaulting to empty string.lifespanparameter toPyJWKClientfor JWKS cache TTL (key rotation resilience).keycloak_urldefault to""with a runtime check, rather than hardcoding the production Tailscale URL.SOP COMPLIANCE
267-dual-auth-middlewarematches issue #267)Closes #267)PROCESS OBSERVATIONS
get_is_authenticated) is satisfied -- that function is untouched. The newget_current_userdependency is opt-in (onlyGET /meuses it). No existing behavior changes.verify_aud: Falsedecision should be documented somewhere (inline comment or ADR) so future reviewers understand it is intentional, not an oversight.VERDICT: APPROVED