feat: validate Keycloak Bearer tokens via FastAPI dependency#285
Open
vredchenko wants to merge 1 commit into
Open
feat: validate Keycloak Bearer tokens via FastAPI dependency#285vredchenko wants to merge 1 commit into
vredchenko wants to merge 1 commit into
Conversation
Add `verify_token` as a global FastAPI dependency that validates incoming `Authorization: Bearer <jwt>` headers against the configured Keycloak realm's JWKS. Verification is offline - JWKS is fetched once and cached by PyJWT's PyJWKClient; the backend never calls Keycloak per-request. The dependency is gated by `KEYCLOAK_AUTH_REQUIRED` (default false) so the existing test suite, dev workflows, and any already-running deployment behave unchanged. When enabled, every request that isn't on a small exempt list (`/health`, `/status`, `/openapi.json`, `/docs`, `/redoc`) must carry a valid token; the rest receive 401 with a `WWW-Authenticate: Bearer` header. Config knobs (all env vars): - KEYCLOAK_AUTH_REQUIRED: master switch - KEYCLOAK_URL: base URL of the Keycloak server - KEYCLOAK_REALM: realm name (default `dls`) - KEYCLOAK_CLIENT_ID: client ID (reserved for future aud check) - KEYCLOAK_VERIFY_ISS: enforce iss claim (default true) The verify-iss escape hatch exists for the dev mock, where tokens are minted with the browser-facing URL (localhost:30090) while the pod fetches JWKS via in-cluster DNS (keycloak-service:8080) - signature is sound but issuer strings don't match. Staging and production point at the real DLS realm and leave iss verification on. Adds `pyjwt[crypto]` to the `backend` extra and a focused test module covering the gating, exempt-path, malformed-token, and config-helper paths. The remaining 191 backend tests are unaffected (auth defaults to off).
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.
Summary
Adds Keycloak Bearer-token validation to the FastAPI backend as a global dependency. Every non-exempt request must carry a valid JWT signed by the configured Keycloak realm; the signature is verified offline against the realm's JWKS (fetched once on first request, cached by
PyJWKClientfor 10 minutes).Companion PRs:
KEYCLOAK_*env varsWhat's in the change
src/smartem_backend/auth.py—verify_tokenFastAPI dependency that:None(no-op) whenKEYCLOAK_AUTH_REQUIRED != "true". Tests and existing dev workflows that don't pass tokens stay unaffected.Nonefor exempt paths:/health,/status,/openapi.json,/docs,/redoc.PyJWKClient.get_signing_key_from_jwt(token), callsjwt.decode(...)withalgorithms=["RS256"], returns claims.HTTPException(401)withWWW-Authenticate: Bearer.src/smartem_backend/api_server.py— wiresdependencies=[Depends(verify_token)]at theFastAPI()constructor so it runs ahead of every endpoint.pyproject.toml— addspyjwt[crypto]>=2.9.0,<3.0.0to thebackendextra.tests/smartem_backend/test_auth.py— 8 tests covering gated-off default, exempt paths, missing/malformed/empty/non-Bearer Authorization headers, and the config helpers.All 191 existing backend tests still pass (auth defaults to off, so the global dependency is a no-op under the current test fixtures).
Configuration
KEYCLOAK_AUTH_REQUIREDfalseKEYCLOAK_URL""KEYCLOAK_REALMdlsKEYCLOAK_CLIENT_IDaud/azpcheck""KEYCLOAK_VERIFY_ISSissclaim against${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}trueThe
issescape hatch exists for the local dev mock, where tokens are minted with the browser-facing URL (localhost:30090) while the pod fetches JWKS via in-cluster DNS (keycloak-service:8080). Signature is sound, but issuer strings don't match — so dev setsKEYCLOAK_VERIFY_ISS=false. Production points at the real DLS realm with a stable hostname and leaves verification on.ConfigMap defaults for both dev and staging environments live in DiamondLightSource/smartem-devtools#198.
Verified end-to-end
In a local single-node k3s with all three branches applied:
GET /status200GET /acquisitions401 Missing or malformed Authorization headerGET /acquisitions401 Invalid tokenGET /acquisitionsdevusertoken from local Keycloak mock200+ payloadPod-side JWKS reachability against the in-cluster Keycloak service also verified.
Breaking change
When
KEYCLOAK_AUTH_REQUIRED=true, every non-exempt endpoint requires a Bearer token. The library default isfalseso a purepip installupgrade with no config change is non-breaking, but the staging ConfigMap in DiamondLightSource/smartem-devtools#198 ships withKEYCLOAK_AUTH_REQUIRED=true— flipping the switch in any deployed environment will break any caller that doesn't carry a token.The agent is not yet token-aware. Issue #284 captures the open question of how SmartEM Agents authenticate against a JWT-protected API (recommended path: Keycloak client_credentials grant with a dedicated
SmartEM-agentservice-account client). Sequencing the rollout against #284 is a discussion the reviewer should weigh in on — see the release-procedure conversation.For the version bump on tag — MAJOR is the honest call given the practical impact, even though the library default is off.
Test plan
pytest tests/smartem_backend/test_auth.py— 8/8 passespytest tests/smartem_backend/— 192/192 passes (191 existing + 8 new)ruff check src/smartem_backend/auth.py src/smartem_backend/api_server.py— cleanpyright src/smartem_backend/auth.py— 0 errorsKEYCLOAK_AUTH_REQUIRED=true(table above)