Plan: craig-web BFF JWT-Trust Hardening (#813 + #814 + #812)
On this page
- Status
- Context
- Step 1 — craig-auth (shared crate, additive)
- Step 2 — craig-web
auth_verify.rs(new module) - Step 3 — verifiers on
AppState+ boot - Step 4 — login nonce + callback verification (
auth.rs) - Step 6 — #812 global write-authz layer
- Test plan (axis-tagged; no live Keycloak)
- Rollout (review-first)
- Follow-ups (filed as issues)
Status
| Step | Description | Status |
|---|---|---|
0 |
Committed plan + nav entry |
Done (2026-07-07) |
1 |
craig-auth: |
Done (2026-07-07) |
2 |
craig-web |
Done (2026-07-07) |
3 |
Two verifiers on |
Done (2026-07-07) |
4 |
|
Done (2026-07-07) |
5 |
craig-web |
Done (2026-07-07) |
6 |
#812 global |
Done (2026-07-07) |
7 |
Docs: |
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 theid_tokenat login, then discard it (verified-not-persisted); verify theaccess_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-
readonlyprincipal 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>onClaims(standard OIDC id-token claims; backends ignore them).azpalready existed. -
jwks.rs:expected_typ: Option<String>(defaultSome("Bearer")in both constructors)
with_token_typebuilder; thetypcheck compares againstexpected_typ;WrongTokenTypegains anexpectedfield so an ID-verifier’s rejection message is truthful. -
Additivity: default
"Bearer"preserves every backend; theinto_claimsprojection sets the two new fieldsNone(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 namesexpected;nonce/at_hashround-trip. -
auth_verify (10 tests): happy pair; happy
typ:"ID"; happyat_hashmatch + absent; evil nonce/subject/at_hash mismatch; evil wrong-aud; sad expired; evil tampered signature; themutation_matrixcorpus (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
readonlyKeycloak seed user + storageState +readonly.spec.ts.