Plan: craig-web BFF JWT-Trust Hardening (#813 + #814 + #812)

On this page

Status

Step Description Status

0

Committed plan + nav entry

Done (2026-07-07)

1

craig-auth: Claims.{nonce,at_hash}, JwksProvider configurable expected_typ (with_token_type), WrongTokenType{typ,expected}

Done (2026-07-07)

2

craig-web auth_verify module: verify_login_tokens (nonce + subject-bind + at_hash-when-present) + build_session_from_claims + LoginVerifyError

Done (2026-07-07)

3

Two verifiers on AppState (id: aud+azp+typ=ID; access: azp) built from the shared discovery + warmed at boot

Done (2026-07-07)

4

auth.rs: PkceState.nonce, login emits &nonce=, callback verifies both tokens, delete unverified decode

Done (2026-07-07)

5

craig-web test-fixtures dev-dep for the verification test harness

Done (2026-07-07)

6

#812 global require_write_access layer (readonly ✗ unsafe methods; /personalize/ exempt) + wiring

Done (2026-07-07)

7

Docs: shared-crates, security, CHANGELOG

Done (2026-07-07)

8

Verification battery + MRs (MR-A #813/#814, MR-B #812) — review-first

Done (2026-07-07) — MR-A !926 (merged); MR-B (this MR)

Issues: #813, #814 (MR-A, closed); #812 (MR-B)
Branches: feature/p3low-web-oidc-verify (A, merged), feature/p3low-web-write-guard (B)
MRs: !926 (A, merged cf4257ef); MR-B (this MR)

Context

An audit flagged three related weaknesses in the craig-web BFF’s OIDC/session trust model. All are P3-low with existing mitigations (AES-GCM session cookie + backends re-verify the forwarded bearer
backend fail-closed authz.check), but architecturally the BFF trusts token contents it never verifies and treats a role it never enforces as display-only. Pre-1.0 ⇒ no backwards-compat is owed.

Decisions:

  • #813 — Full OIDC RP: add a nonce; capture + verify the id_token at login, then discard it (verified-not-persisted); verify the access_token; and bind the two tokens to each other (subject + at_hash-when-present).

  • #814 — Verify at login; keep the cookie session: the ADR-013 stateless-cookie model stays; no per-request re-verification. Refresh-token rotation is a filed follow-up.

  • #812 — Global write-authorization layer: one coarse, fail-closed, non-duplicating invariant (a sole-readonly principal makes no domain writes), not forgettable per-route gates.

The typ blocker (baked into Step 1)

JwksProvider::validate_token rejected typ != "Bearer"; Keycloak id_tokens carry typ:"ID", so verifying an id_token with the stock validator would reject every login. Step 1 makes the expected typ configurable (default "Bearer" preserves all backends; the id-verifier expects "ID").

Step 1 — craig-auth (shared crate, additive)

  • claims.rs: #[serde(default)] pub nonce: Option<String> + pub at_hash: Option<String> on Claims (standard OIDC id-token claims; backends ignore them). azp already existed.

  • jwks.rs: expected_typ: Option<String> (default Some("Bearer") in both constructors)
    with_token_type builder; the typ check compares against expected_typ; WrongTokenType gains an expected field so an ID-verifier’s rejection message is truthful.

  • Additivity: default "Bearer" preserves every backend; the into_claims projection sets the two new fields None (RFC 7662 introspection has no id-token claims).

Step 2 — craig-web auth_verify.rs (new module)

verify_login_tokens(id_verifier, access_verifier, id_token, access_token, expected_nonce): verify id_token → nonce match → verify access_token → subject match → at_hash match (when present) → return the access-token claims. validate_with_refresh retries once on JwksNotLoaded / NoMatchingKey (boot-warm miss + key rotation). build_session_from_claims moves the verified claims into a WebSession. access_token_hash computes the OIDC at_hash (base64url of the left-most half of SHA-256(access_token)). Any failure is a hard login rejection.

Step 3 — verifiers on AppState + boot

Two craig_auth::jwks::JwksProvider fields, built from the already-warmed AppState.oidc via from_discovery: the id verifier binds aud+azp = the login client and typ:"ID"; the access verifier enforces azp only (craig-web is not in the access token’s aud). Both warmed best-effort at boot (mirrors build_oidc) + a background refresh task. JwksProvider is Clone (an Arc-backed key cache), so the per-request AppState::clone() stays a refcount bump.

Step 4 — login nonce + callback verification (auth.rs)

PkceState gains a nonce field (no serde(default) — a pre-deploy cookie lacking it fails deserialize → re-login, fail-closed). login mints a nonce, stores it in the signed PKCE cookie, and emits &nonce= in the authorize URL. callback extracts the id/access tokens from the exchange response (the serde_json::Value boundary stays in auth.rs), verifies them via auth_verify, and builds the session from the verified claims. The unverified build_session_from_token
decode_claims_unverified helpers are deleted; the id_token is verified then discarded.

Step 6 — #812 global write-authz layer

require_write_access middleware (mirrors verify_csrf’s method discrimination): pass safe methods; pass the `/personalize/ self-service subtree (own-user_sub-scoped, ADR-035 §3); else read the injected SessionUser (missing → 403 fail-closed) and 403 a sole-readonly principal. Wired inner to require_auth (it needs the injected SessionUser) and outer of the per-route role gates, so the per-request order is verify_csrf → require_auth → require_write_access → [role gate] → handler. /set-locale is on a separate router outside these layers, so locale stays available to readonly.

Test plan (axis-tagged; no live Keycloak)

Harness: craig_auth::jwks::test_fixtures::{test_jwk_set, TEST_ISSUER, TEST_KID}
JwksProvider::inject_keys (bypasses the network) + craig_test_lib::jwt_mutation::ClaimsBuilder (mints against the shared key).

  • craig-auth: default rejects typ:"ID"; with_token_type("ID") accepts it / rejects "Bearer" and the error names expected; nonce/at_hash round-trip.

  • auth_verify (10 tests): happy pair; happy typ:"ID"; happy at_hash match + absent; evil nonce/subject/at_hash mismatch; evil wrong-aud; sad expired; evil tampered signature; the mutation_matrix corpus (alg=none / HS256-confusion / unknown-kid / iss / aud / typ / exp / nbf / missing-sub) each rejected as an id_token failure.

  • require_write_access (MR-B): readonly+POST→403; readonly+GET→200; readonly+POST /personalize/→200; caseworker+POST→200; ["caseworker","readonly"] additive+POST→200; no-session+POST→403.

Battery per MR: cargo nextest run --workspace (shared-crate change ⇒ full run); clippy -D warnings; cargo xtask quality-budgets (B4/B3a/B5 unchanged vs lock); validate --skip-docker + check-docs + plan-lint; a fresh contextless J1–J8 subagent over each staged diff.

Manual devstack: log in as jane.doe; base64-decode the exchange id_token → typ:"ID", nonce matches the /login redirect, azp==craig-ui, at_hash present; access_token → typ:"Bearer", azp==craig-ui; mismatched issuer/aud → login redirects /welcome; a readonly scratch user is 403’d on POST /cases/ but 200 on GET /cases/ and POST /personalize/dashboard/reset.

Rollout (review-first)

Two MRs, sequential, each: implement on a branch, run the battery, present the diff, no autonomous merge. On MR-B completion this plan’s Status → all Done, nav entry → Archive.

Follow-ups (filed as issues)

  • #960 — RP-initiated logout with id_token_hint (needs persisting the id_token — currently discarded).

  • #961 — Refresh-token rotation for craig-web (bind BFF session to token lifetime; out of #814 scope).

  • #962 — craig-auth shared JWKS cache so the two verifiers share one key cache + refresh task.

  • #963 — #812 e2e: a readonly Keycloak seed user + storageState + readonly.spec.ts.

Edit this page · latest