feat: add Keycloak JWT auth and role-based access control #5
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "4-add-keycloak-realm-jwt-auth-and-role-bas"
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
mcd-trackerrealm with roles (user,admin), two OIDC clients (mcd-tracker-appconfidential,mcd-tracker-iospublic PKCE), and test users (testuserwith user role,testadminwith admin role)get_current_userdependency, andrequire_rolefactoryChanges
src/mcd_tracker_api/auth.py-- new module: Keycloak OIDC JWT validation, User dataclass, get_current_user, require_rolesrc/mcd_tracker_api/config.py-- addkeycloak_realm_urlsetting (external URL)pyproject.toml-- addpython-jose[cryptography]andhttpxdependenciestests/test_auth.py-- 15 tests with mocked JWKS (no live Keycloak in CI): token validation, missing/invalid tokens, wrong kid/issuer, role enforcement, case insensitivityTest Plan
ruff checkandruff format --checkpassuserrole, issuer is external URLReview Checklist
Related
plan-pal-e-platformCloses #4
Self-Review
Code Quality: Clean. auth.py is a 1:1 match of the basketball-api pattern adapted for mcd-tracker-api.
Tests: 15 tests covering valid tokens, missing/invalid tokens, wrong kid, wrong issuer, multiple roles, role enforcement (403), case insensitivity, and User dataclass defaults. All use mocked RSA keys and JWKS -- no Keycloak dependency in CI.
Findings: None. No secrets in code, no unnecessary changes, ruff passes clean.
Keycloak realm verified live:
mcd-trackerwith rolesuser,adminmcd-tracker-app(confidential),mcd-tracker-ios(public PKCE)testuser(user role),testadmin(admin role)https://keycloak.tail5b443a.ts.net/realms/mcd-tracker(external URL)Full test suite: 24/24 passing (15 auth + 9 existing).
PR #5 Review
DOMAIN REVIEW
Tech stack: Python 3.12+ / FastAPI / SQLAlchemy / Pydantic / python-jose / httpx
This PR adds a Keycloak OIDC JWT validation module (
auth.py) and akeycloak_realm_urlconfig setting. No routes are wired to auth yet -- this is dependency-only groundwork. The auth module follows the established basketball-api pattern: JWKS-based RS256 validation,Userdataclass,get_current_userdependency,require_rolefactory.Python/FastAPI findings:
Callablereturn type onrequire_role,Userdataclass fields typed,dict | Noneunion syntax used correctly.get_current_userandrequire_rolefollow the standardDepends()pattern correctly.keycloak_realm_urlcorrectly added toSettingswithenv_prefixinheritance fromMCD_TRACKER_. Will read fromMCD_TRACKER_KEYCLOAK_REALM_URLenv var.verify_aud: Falseis documented with rationale, JWKS cache-bust-and-retry handles key rotation.WWW-Authenticate: Bearerheader included on 401s per RFC 6750._fetch_jwks()uses synchronoushttpx.get()inside anasync defdependency chain. This blocks the event loop during the first request (and any cache-bust). The comment justifies it ("cached after the first call, in-cluster round-trip is sub-millisecond") -- acceptable for now but will become a problem under concurrent load if Keycloak is slow.BLOCKERS
1. DRY violation in auth/security path (BLOCKER)
src/mcd_tracker_api/auth.py(176 lines) is a verbatim copy ofbasketball-api/src/basketball_api/auth.py(177 lines, the extra line being a trailing newline). The only differences are:"mcd-tracker-api"vs"basketball-api"in the module docstringfrom mcd_tracker_api.config import settingsvsfrom basketball_api.config import settingsEverything else -- the
Userdataclass,_fetch_jwks,_extract_roles,get_current_user,require_role, the JWKS cache-bust logic, error messages, HTTP status codes, logging -- is identical.Per the BLOCKER criteria: "DRY violation in auth/security paths (duplicated auth logic = divergence risk)". If a security fix is needed (e.g., adding
expvalidation, fixing a JWT parsing edge case), it must be applied to every copy independently. This is divergence risk.Recommended fix: Extract the shared auth module into a reusable package (e.g.,
pal-e-author a sharedkeycloak-authlibrary) that bothbasketball-apiandmcd-tracker-apidepend on. The only per-app configuration is thekeycloak_realm_urlsetting, which can be injected. Alternatively, if a shared package is premature, acknowledge this as tech debt and file a tracking issue -- but the code must not ship without a plan to converge.Note for the author: I recognize this is a deliberate pattern-follow from basketball-api, and the copy is clean. The blocker is about the organizational risk of maintaining identical security logic in two places, not about the quality of the code itself.
NITS
Hardcoded default URL in config.py (line 8):
keycloak_realm_urldefaults to"https://keycloak.tail5b443a.ts.net/realms/mcd-tracker". While not a secret, hardcoding the Tailscale hostname means the default only works in one environment. Consider making this field required (no default) so missing config fails fast, or use a sentinel that clearly signals "not configured."test_wrong_issuer_returns_401is fragile (test_auth.py lines 149-167): This test re-patches_fetch_jwksandsettingsinside anautousefixture that already patches those same things. The nestedwithblock creates a double-mock situation. The test works because the inner patch wins, but the comments inside the test reveal confusion about the mock layering ("We need to let the real jwt.decode run, so unpatch _fetch_jwks briefly / Actually the mock is already in place from the fixture"). Consider restructuring: either skip the autouse fixture for this test (using a marker) or simplify the mock setup.noqa: E402imports in test_auth.py (lines 42-46, 56): Thecryptographyandbase64imports are placed after the test app definition, requiringnoqasuppressions. Moving these imports to the top of the file (standard position) would eliminate the suppressions. The test app definition does not depend on them.Module-level
globalkeyword usage: Theglobal _jwks_cachein_fetch_jwksandget_current_userworks but is a code smell in larger codebases. A class-based cache (e.g.,JWKSCachesingleton) would be more testable and avoid the global state. Non-blocking -- the current approach matches basketball-api.Missing
expclaim test: The tests do not verify that expired tokens are rejected.python-josevalidatesexpby default, so this works implicitly, but an explicit test for expired token rejection would document the expected behavior and catch regressions ifoptionsever changes.SOP COMPLIANCE
4-add-keycloak-realm-jwt-auth-and-role-bas-- references issue #4)plan-pal-e-platform, but the dispatch specifiedplan-mcd-tracker. Ifplan-mcd-trackerexists, the Related section should reference it instead.## Review Checklistvs expected## Related). Minor.PROCESS OBSERVATIONS
VERDICT: NOT APPROVED
The auth module is well-written and the test coverage is strong, but the verbatim copy of
basketball-api/auth.pyis a BLOCKER per DRY-in-security-paths criteria. The recommended path forward is one of:keycloak-authpackage (best)Either approach unblocks this PR. The code quality itself is not in question -- the organizational risk of maintaining identical security logic across repos is.