Admin auth: Authentik JWT validation #156

Member

Maestro task

e70e6bfb-085a-4781-a3f4-276355bbbd19 — Admin auth: Authentik JWT validation (Feature 16 — NestQuest API Service, admin plane D-012)

Done-condition

Admin routes validate an Authentik OIDC JWT: issuer, audience, JWKS signature and expiry are all enforced. A request with no token returns 401; a malformed, expired, wrong-issuer, wrong-audience or bad-signature token returns 401; a valid token returns 200 on a probe admin route. The JWKS is fetched from the configured Authentik provider and cached. Config (issuer, audience, JWKS URL) comes from api config. Tests use a locally generated key pair plus a stubbed JWKS so signature/expiry/audience are proven without network access.

Changes

  • api/auth.py (new) — the ONE reusable admin-plane auth dependency:
    • require_admin_jwt extracts the Bearer credential (missing/malformed header → 401) and returns the verified claims for the route to use; the nestquest-admins group check is the NEXT task (0f2afb29) and extends this dependency without a rewrite.
    • verify_admin_token enforces RS256-only alg, requires kid, selects the RSA key named by kid from the JWKS, and decodes with PyJWT enforcing issuer, audience, required exp, and nbf when present. Every rejection carries the SAME uniform 401 detail (WWW-Authenticate: Bearer); a JWKS fetch failure is deliberately not mapped to 401 (server-side fault propagates, cache never poisoned).
    • JwksCache — per-app cache on app.state.jwks_cache (installed by create_app): fetched via httpx, 5-minute TTL, asyncio-lock-guarded double-checked fetch, failed fetches never cached. Tests stub the fetcher — zero network access.
  • api/routes_admin.py (new) — thin admin router at /api/v1/admin with the probe route GET /api/v1/admin/ping → 200 {"status": "ok", "sub": <verified subject>}; the JWT check is attached at router level and the route reuses the per-request cached dependency result.
  • api/config.py — ApiConfig grows oidc_issuer / oidc_audience / oidc_jwks_url, read from NESTQUEST_OIDC_ISSUER / NESTQUEST_OIDC_AUDIENCE / NESTQUEST_OIDC_JWKS_URL with the same fail-fast discipline as NESTQUEST_DB_PATH/NESTQUEST_PANEL_TOKEN (unset/empty → ConfigError — a default issuer must not silently leave the admin plane open).
  • api/app.py — wires the admin router and the per-app JwksCache.
  • pyproject.toml / uv.lock — pyjwt[crypto]>=2.9 added to the dev group (2.14.0 locked); runtime deps move to the API deployable's own manifest in Feature 17, per the existing comment.
  • tests/test_api_admin_auth.py (new, 22 tests) — locally generated RSA key pair + JWKS built from its public key + counting stub fetcher: no token 401; malformed headers 401; garbage 401; expired 401; wrong issuer 401; wrong audience 401; future nbf 401; missing exp 401; unknown kid 401; bad signature (second local key pair) 401; valid token 200 with the subject; all rejections share one detail; JWKS fetched once across requests and refetched on TTL lapse; from_env requires each OIDC var (missing + empty).
  • Existing tests updated mechanically only: the four direct ApiConfig(...) constructions and three from_env dicts gained the new required fields (no assertion weakened, none skipped).

Test results (TZ=UTC)

TZ=UTC uv run pytest -q
1634 passed in 8.87s

TZ=UTC uv run pytest -q tests/test_api_admin_auth.py
22 passed in 0.38s

Baseline was 1612 passed; nothing skipped, weakened, or disabled (1612 + 22 new = 1634).

Scope notes

  • JWT validation only — group membership (nestquest-admins) is task 0f2afb29.
  • Not merged; awaiting independent review.
## Maestro task `e70e6bfb-085a-4781-a3f4-276355bbbd19` — Admin auth: Authentik JWT validation (Feature 16 — NestQuest API Service, admin plane D-012) ## Done-condition Admin routes validate an Authentik OIDC JWT: issuer, audience, JWKS signature and expiry are all enforced. A request with no token returns 401; a malformed, expired, wrong-issuer, wrong-audience or bad-signature token returns 401; a valid token returns 200 on a probe admin route. The JWKS is fetched from the configured Authentik provider and cached. Config (issuer, audience, JWKS URL) comes from api config. Tests use a locally generated key pair plus a stubbed JWKS so signature/expiry/audience are proven without network access. ## Changes - **api/auth.py (new)** — the ONE reusable admin-plane auth dependency: - `require_admin_jwt` extracts the Bearer credential (missing/malformed header → 401) and returns the **verified claims** for the route to use; the nestquest-admins group check is the NEXT task (0f2afb29) and extends this dependency without a rewrite. - `verify_admin_token` enforces RS256-only `alg`, requires `kid`, selects the RSA key named by `kid` from the JWKS, and decodes with PyJWT enforcing issuer, audience, required `exp`, and `nbf` when present. Every rejection carries the SAME uniform 401 detail (`WWW-Authenticate: Bearer`); a JWKS fetch failure is deliberately not mapped to 401 (server-side fault propagates, cache never poisoned). - `JwksCache` — per-app cache on `app.state.jwks_cache` (installed by `create_app`): fetched via httpx, 5-minute TTL, asyncio-lock-guarded double-checked fetch, failed fetches never cached. Tests stub the fetcher — zero network access. - **api/routes_admin.py (new)** — thin admin router at `/api/v1/admin` with the probe route `GET /api/v1/admin/ping` → `200 {"status": "ok", "sub": <verified subject>}`; the JWT check is attached at router level and the route reuses the per-request cached dependency result. - **api/config.py** — `ApiConfig` grows `oidc_issuer` / `oidc_audience` / `oidc_jwks_url`, read from `NESTQUEST_OIDC_ISSUER` / `NESTQUEST_OIDC_AUDIENCE` / `NESTQUEST_OIDC_JWKS_URL` with the same fail-fast discipline as `NESTQUEST_DB_PATH`/`NESTQUEST_PANEL_TOKEN` (unset/empty → `ConfigError` — a default issuer must not silently leave the admin plane open). - **api/app.py** — wires the admin router and the per-app `JwksCache`. - **pyproject.toml / uv.lock** — `pyjwt[crypto]>=2.9` added to the dev group (2.14.0 locked); runtime deps move to the API deployable's own manifest in Feature 17, per the existing comment. - **tests/test_api_admin_auth.py (new, 22 tests)** — locally generated RSA key pair + JWKS built from its public key + counting stub fetcher: no token 401; malformed headers 401; garbage 401; expired 401; wrong issuer 401; wrong audience 401; future `nbf` 401; missing `exp` 401; unknown `kid` 401; bad signature (second local key pair) 401; valid token 200 with the subject; all rejections share one detail; JWKS fetched once across requests and refetched on TTL lapse; `from_env` requires each OIDC var (missing + empty). - Existing tests updated mechanically only: the four direct `ApiConfig(...)` constructions and three `from_env` dicts gained the new required fields (no assertion weakened, none skipped). ## Test results (TZ=UTC) ``` TZ=UTC uv run pytest -q 1634 passed in 8.87s TZ=UTC uv run pytest -q tests/test_api_admin_auth.py 22 passed in 0.38s ``` Baseline was 1612 passed; nothing skipped, weakened, or disabled (1612 + 22 new = 1634). ## Scope notes - JWT validation only — group membership (`nestquest-admins`) is task 0f2afb29. - Not merged; awaiting independent review.
dev_agent added 1 commit 2026-09-22 23:56:35 -04:00
The admin plane had no authentication: any caller could reach its
routes once they existed. This adds the plane's ONE reusable FastAPI
dependency (require_admin_jwt in api/auth.py) that verifies an
Authentik OIDC JWT — Bearer extraction, RS256-only signature check
against the provider's JWKS, issuer, audience, required exp, and
enforced nbf — and raises the SAME 401 for every failure so the
response never leaks which check failed.

The JWKS is fetched over httpx once per app (JwksCache, 5-minute TTL,
asyncio-lock-guarded, failed fetches never cached) and tests stub the
fetcher, so the suite proves signature/expiry/audience enforcement
with locally generated RSA key pairs and zero network access.

ApiConfig grows the three required OIDC settings
(NESTQUEST_OIDC_ISSUER/AUDIENCE/JWKS_URL) under the existing fail-fast
discipline: an unconfigured issuer must not silently leave the admin
plane open, so from_env raises rather than inventing a default.

This is authentication only — the verified claims are the dependency's
return value, so the nestquest-admins group check is the next task's
addition, not a rewrite. PyJWT[crypto] joins the dev dependency group
(the API deployable's runtime manifest lands in Feature 17).

Maestro task: e70e6bfb-085a-4781-a3f4-276355bbbd19
overseer added 1 commit 2026-09-23 00:30:05 -04:00
- P2: verify_admin_token parses and enforces the JWT header (alg ==
  RS256, kid present) BEFORE consulting the JWKS cache, so a
  structurally invalid token costs no fetch, no lock, no provider load.
- P2: JwksCache gains a short negative-cache backoff window (5s,
  time.monotonic-recorded) after a failed fetch: within it, get()
  re-raises the recorded fault without touching the fetcher — and
  without holding or queueing on the lock — so an outage costs one
  fetch per window instead of one per request. A successful fetch
  clears the backoff; nothing is cached indefinitely (TTL unchanged).
- P3: _matching_key widens the from_jwk handling to
  (PyJWTError, binascii.Error, ValueError, TypeError, KeyError) so a
  JWKS entry matching the kid but carrying corrupt n/e maps to the
  SAME uniform 401 instead of surfacing a 500.
- P3: an unmatched kid now forces ONE TTL-bypassing refetch and
  retries the match once, so rotated keys verify without waiting out
  the TTL; the forced refetch is itself bounded (inside the backoff
  window or the 5s kid-refetch interval the cached document is served
  as-is), so unknown-kid floods cannot force a fetch per request.
- Tests: no-fetch proofs for garbage/non-RS256 tokens, corrupt-entry
  401, outage fail-fast (sequential + concurrent burst), backoff
  lapse/recovery, outage-as-server-fault, and the unknown-kid
  refetch: one forced, throttled, resume-after-interval, rotation
  picked up, failed forced refetch propagates then fails fast.
  Existing tests unchanged (uniform 401 contract intact).
review_agent left a comment
Member

Independent Claude Sonnet 5 review completed for this exact commit.

Independent Claude Sonnet 5 review completed for this exact commit.
dev_agent merged commit ca1165de3f into feature/cd03735b-2498-4b34-b84c-c1cb2b271ba7-nestquest-api-service 2026-09-23 00:37:31 -04:00
Sign in to join this conversation.
No Reviewers
No labels
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Maniacs_Mansion/NestQuest#156