feat(auth): JWKS bearer validation, require_role, and GET /me #56

Merged
patrick merged 3 commits from feature/auth-keycloak into main 2026-09-11 11:12:40 +00:00
Owner

PH0.4 — Keycloak auth: JWKS bearer validation, require_role, GET /me, LDAP smoke test.

What this adds

  • backend/src/polaris/api/auth.py: verifies a Keycloak bearer token's RS256 signature against the realm's cached JWKS (jwt.PyJWKClient), checks aud/iss/exp, exposes get_current_user (-> CurrentUser: sub, preferred_username, roles, locale) and require_role(*roles) (403 ForbiddenError dependency factory). Every validation failure — missing header, unknown key, wrong audience, wrong issuer, expired, bad signature — comes back as the same 401 UnauthorizedError problem-details body.
  • backend/src/polaris/api/routers/me/profile.py: GET /api/v1/me.
  • get_current_user populates polaris.core.context's actor_sub/actor_role contextvars for the request's lifetime (the audit hook's actor fields). It's an async def generator dependency deliberately — a sync one intermittently fails contextvars.Token.reset() because FastAPI runs sync generator dependencies in a worker thread.

Tests

  • backend/tests/unit/api/auth_test.py — locally-signed-token unit tests (right audience passes; wrong audience/expired/bad signature/wrong issuer/missing header all 401; require_role 403s a caller lacking the role). No network; a fake SigningKeyResolver stands in for PyJWKClient.
  • backend/tests/integration/api/auth_smoke_test.py (new smoke pytest marker) — against the real compose stack (db, openldap, ldap-init, keycloak), logs in as each of the five LDAP users via polaris-web's Authorization Code + PKCE flow (it's deliberately direct-access-grants-disabled, so there's no password-grant client — this drives the actual browser login form headlessly with httpx) and asserts GET /me returns exactly that user's realm role. Ran this against a live docker compose up db openldap ldap-init keycloak in this session — all 5 users (admin, planner, technician, viewer, ingest) pass. Skips (not fails) when Keycloak isn't reachable, so a plain pytest run doesn't need the stack up.
    • Had to manually reassemble the session cookie from Set-Cookie rather than rely on httpx's jar: Keycloak marks AUTH_SESSION_ID/KC_RESTART Secure, and httpx (unlike a real browser, which treats localhost as a trustworthy origin even over plain HTTP) refuses to send a Secure cookie back over this un-TLS'd dev stack — which otherwise makes Keycloak reject the login POST with "Cookie not found."

Two deviations from the owned-paths list, both explained in the commit body and flagged on the issue as I started

  1. main was missing PH0.2 (PR #51 merged feature/api-skeleton into develop, not main, so backend/ didn't exist on main). This branch merges origin/develop in first (disjoint paths, clean merge) to get backend/ to build against. Flagged on the issue in case developmain needs a separate sync PR.
  2. backend/pyproject.toml/uv.lock: added pyjwt[crypto] — no JWT/JWKS library was declared by PH0.2 (only httpx, for outbound calls generally), since JWKS validation was this ticket's job. Also added a smoke pytest marker next to the existing integration one. Both are small, additive, low-conflict-risk changes with the reasoning in the commit body per CLAUDE.md's "don't add dependencies without saying why."

Verified locally

ruff check, ruff format --check, mypy --strict all clean; full pytest suite (46 tests: existing PH0.2 tests + new unit + smoke) green, smoke test run against a real docker compose stack.

Closes #7

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX

PH0.4 — Keycloak auth: JWKS bearer validation, `require_role`, `GET /me`, LDAP smoke test. ## What this adds - `backend/src/polaris/api/auth.py`: verifies a Keycloak bearer token's RS256 signature against the realm's cached JWKS (`jwt.PyJWKClient`), checks `aud`/`iss`/`exp`, exposes `get_current_user` (-> `CurrentUser`: `sub`, `preferred_username`, `roles`, `locale`) and `require_role(*roles)` (403 `ForbiddenError` dependency factory). Every validation failure — missing header, unknown key, wrong audience, wrong issuer, expired, bad signature — comes back as the same 401 `UnauthorizedError` problem-details body. - `backend/src/polaris/api/routers/me/profile.py`: `GET /api/v1/me`. - `get_current_user` populates `polaris.core.context`'s `actor_sub`/`actor_role` contextvars for the request's lifetime (the audit hook's actor fields). It's an `async def` generator dependency deliberately — a sync one intermittently fails `contextvars.Token.reset()` because FastAPI runs sync generator dependencies in a worker thread. ## Tests - `backend/tests/unit/api/auth_test.py` — locally-signed-token unit tests (right audience passes; wrong audience/expired/bad signature/wrong issuer/missing header all 401; `require_role` 403s a caller lacking the role). No network; a fake `SigningKeyResolver` stands in for `PyJWKClient`. - `backend/tests/integration/api/auth_smoke_test.py` (new `smoke` pytest marker) — against the real compose stack (`db`, `openldap`, `ldap-init`, `keycloak`), logs in as each of the five LDAP users via `polaris-web`'s Authorization Code + PKCE flow (it's deliberately direct-access-grants-disabled, so there's no password-grant client — this drives the actual browser login form headlessly with `httpx`) and asserts `GET /me` returns exactly that user's realm role. **Ran this against a live `docker compose up db openldap ldap-init keycloak` in this session — all 5 users (`admin`, `planner`, `technician`, `viewer`, `ingest`) pass.** Skips (not fails) when Keycloak isn't reachable, so a plain `pytest` run doesn't need the stack up. - Had to manually reassemble the session cookie from `Set-Cookie` rather than rely on httpx's jar: Keycloak marks `AUTH_SESSION_ID`/`KC_RESTART` `Secure`, and httpx (unlike a real browser, which treats `localhost` as a trustworthy origin even over plain HTTP) refuses to send a `Secure` cookie back over this un-TLS'd dev stack — which otherwise makes Keycloak reject the login POST with "Cookie not found." ## Two deviations from the owned-paths list, both explained in the commit body and flagged on the issue as I started 1. **`main` was missing PH0.2** (PR #51 merged `feature/api-skeleton` into `develop`, not `main`, so `backend/` didn't exist on `main`). This branch merges `origin/develop` in first (disjoint paths, clean merge) to get `backend/` to build against. Flagged on the issue in case `develop` → `main` needs a separate sync PR. 2. **`backend/pyproject.toml`/`uv.lock`**: added `pyjwt[crypto]` — no JWT/JWKS library was declared by PH0.2 (only `httpx`, for outbound calls generally), since JWKS validation was this ticket's job. Also added a `smoke` pytest marker next to the existing `integration` one. Both are small, additive, low-conflict-risk changes with the reasoning in the commit body per `CLAUDE.md`'s "don't add dependencies without saying why." ## Verified locally `ruff check`, `ruff format --check`, `mypy --strict` all clean; full `pytest` suite (46 tests: existing PH0.2 tests + new unit + smoke) green, smoke test run against a real `docker compose` stack. Closes #7 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
fastapi, sqlalchemy, psycopg, alembic, ortools, structlog, apscheduler,
pydantic-settings, httpx as runtime deps; pytest, testcontainers, ruff,
mypy as the dev group. ortools/apscheduler aren't used by this ticket
but are declared now (docs/12-implementation-plan.md PH0.2) so later
tracks never have to touch pyproject.toml. uvicorn arrives via the
fastapi[standard] extra rather than as a separate dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Settings (pydantic-settings, POLARIS_ env prefix), structlog config
that injects request_id/actor onto every log line, UTC/Europe-Amsterdam
time helpers, request-scoped contextvars (request_id, actor_sub,
actor_role), and the PolarisError hierarchy the RFC 9457 handler maps
to urn:polaris:error:<code>.

polaris.core only imports stdlib/pydantic/structlog per the import
rules table in docs/12-implementation-plan.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Base, engine/SessionLocal/session_scope, and typed mapped classes for
the three platform tables (task, audit_log, setting).

The Auditable mixin + before_flush listener is the mechanism hard rule
#3 (CLAUDE.md) depends on: any mapped class that inherits Auditable
gets its insert/update/delete mirrored into audit_log in the same
flush, reading actor/request_id from polaris.core.context. Setting
opts in (admin-editable, audited); Task opts out (internal queue
plumbing, not a domain object).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
- discover_routers() walks polaris.api.routers via pkgutil.walk_packages
  and mounts every module-level `router: APIRouter` under /api/v1 — a
  module dropped into routers/<aggregate>/<concern>.py is mounted with
  no shared file edited.
- platform/health.py: GET /api/v1/healthz, checking Postgres reachability.
- RequestIdMiddleware: mints/echoes X-Request-Id, available via
  request.state (survives an exception unwinding past this middleware)
  and polaris.core.context (for structlog/the audit hook).
- problem_details.py: PolarisError, StarletteHTTPException,
  RequestValidationError and any other unhandled Exception all become
  RFC 9457 application/problem+json bodies with a
  urn:polaris:error:<code> type and the request id.
- metrics.py: GET /metrics, a minimal hand-rolled Prometheus text
  exposition (no metrics client library is in this ticket's declared
  dependency list; swapping the internals for prometheus-client later
  is a one-file change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Same package-walk discovery pattern as router discovery: a module
dropped into polaris/cli/<command>.py exporting NAME and register()
becomes a `polaris <name>` subcommand, no shared file edited.

`polaris api` runs the FastAPI app with uvicorn (this is what the
compose api service, owned by feature/infra-compose-base, runs).
`polaris openapi` writes the spec to backend/openapi.json, committed
here as the frontend's contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
alembic.ini has no sqlalchemy.url; env.py sets it from
polaris.core.settings.get_settings().database_url (POLARIS_DATABASE_URL)
so there's exactly one place the DB connection string comes from.

0001 creates the three platform tables plus their indexes
(task(status, run_after), audit_log(entity, entity_id, at)) from
docs/08-data-model.md, and is fully reversible (downgrade drops
everything it created, including the task_status enum type).

Uses postgresql.ENUM(..., create_type=False) rather than the generic
sa.Enum for the status column: the generic type silently ignores
create_type (confirmed against the sqlalchemy version in uv.lock), so
create_table's own DDL event tried to CREATE TYPE a second time and
failed with "already exists".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
tests/conftest.py has db/session fixtures only (postgres_url, db_engine,
db_session, backed by testcontainers) per docs/12-implementation-plan.md
— sealed after this ticket; feature-specific fixtures belong in
tests/<level>/<package>/conftest.py, not here.

Uses plain postgres:16-alpine rather than postgis/postgis for the test
container: nothing before PH1.2 uses a PostGIS type, and the plain
image was already available locally so tests don't depend on pulling a
large image over the network.

Unit tests cover core (errors, time, context), router/CLI discovery
against synthetic packages, and the RFC 9457 handler end to end
(PolarisError, HTTPException, validation error, and a genuinely
unhandled exception, including the request-id-survives-the-unwind
case). Integration tests (testcontainers Postgres) cover healthz
against a real DB, the audit before_flush hook, and alembic upgrade
head / downgrade base.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
python:3.13-slim, uv for dependency install (two-layer: deps first from
pyproject.toml/uv.lock so it's cached independent of source changes,
then the project itself), entrypoint `polaris`, default command `api`.
Also runs the worker (`polaris worker`, PH0.3) and any CLI subcommand
(`polaris seed`, `polaris openapi`, …) by overriding CMD — one image,
role picked at container-run time, matching the compose services table
in docs/07-architecture.md.

Verified with `docker build backend/` standalone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Reviewed-on: #51
main was missing the api-skeleton merge (#51 merged to develop, not
main); PH0.4 needs backend/polaris.api and polaris.db to build auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
Adds polaris.api.auth: verifies a Keycloak bearer token's RS256 signature
against the realm's cached JWKS (jwt.PyJWKClient), checks aud/iss/exp, and
exposes get_current_user (-> CurrentUser: sub, preferred_username, roles,
locale) and require_role(*roles) (403 ForbiddenError dependency factory).
Every validation failure comes back as the same 401 UnauthorizedError so a
caller learns nothing about which check failed. get_current_user also
populates polaris.core.context's actor_sub/actor_role contextvars for the
request's lifetime (as an async generator dependency — a sync one
intermittently fails to reset() its contextvars.Token because FastAPI runs
sync generator dependencies in a worker thread, which can differ from the
Context the token was created in).

Adds GET /api/v1/me (backend/src/polaris/api/routers/me/profile.py)
returning the authenticated caller's own identity.

Settings (keycloak_url/realm/audience) are read directly from the
environment in auth.py rather than added to polaris.core.settings.Settings,
since that module is owned/sealed by PH0.2 and this ticket's owned paths
are auth.py / api/routers/me/** / tests/**/auth* only.

Tests:
- backend/tests/unit/api/auth_test.py: locally-signed-token unit tests
  (right audience passes; wrong audience/expired/bad signature/wrong
  issuer/missing header all 401; require_role 403s a caller lacking the
  role) — no network, a fake SigningKeyResolver stands in for PyJWKClient.
- backend/tests/integration/api/auth_smoke_test.py (new `smoke` pytest
  marker, pyproject.toml): against the real compose stack (db, openldap,
  ldap-init, keycloak), logs in as each of the five LDAP users via
  polaris-web's Authorization Code + PKCE flow (it's deliberately
  direct-access-grants-disabled, so there is no password-grant client to
  ask for a token directly — this drives the actual browser login form
  headlessly with httpx) and asserts GET /me returns exactly that user's
  realm role, proving the LDAP group -> Keycloak role mapping end to end.
  Manually reassembles the session cookie from Set-Cookie rather than using
  httpx's cookie jar: Keycloak marks it Secure, and httpx (unlike a real
  browser, which treats localhost as trustworthy even over plain HTTP)
  won't send a Secure cookie back over this un-TLS'd dev/test stack, which
  otherwise makes Keycloak reject the login POST with "Cookie not found."
  Skips (not fails) when Keycloak isn't reachable.

Dependency: adds pyjwt[crypto] (RS256 verify + JWKS fetch/cache) to
pyproject.toml/uv.lock — no JWT library was declared by PH0.2, since JWKS
validation was this ticket's job, not that one's.

main was missing the PH0.2 API skeleton (PR #51 merged feature/api-skeleton
into develop, not main), so this branch also merges origin/develop to get
backend/ to build against; see the merge commit and the issue #7 starting
comment for detail.

Closes #7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
patrick deleted branch feature/auth-keycloak 2026-09-11 11:12:40 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
patrick/Polaris!56
No description provided.