Skip to content

feat(server): origin-side Cloudflare Access JWT verification (JEF-473)#118

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-473-origin-side-cloudflare-access-jwt-verification-across-the
Jul 22, 2026
Merged

feat(server): origin-side Cloudflare Access JWT verification (JEF-473)#118
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-473-origin-side-cloudflare-access-jwt-verification-across-the

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

What & why

watcher's read surface (UI shell + /api) was gated only by Cloudflare Access at the edge; the origin trusted that every request arrived through Access (ADR 0013). An Access-policy slip, a tunnel/ingress misconfig, or direct in-cluster access to the pod would then reach /api unauthenticated.

This adds origin-side defense-in-depth: the origin now verifies the edge-issued Access JWT itself (it never mints one).

Changes

  • New server/src/access_jwt.rs — a transport-agnostic Verifier: validates a raw JWT against the team's JWKS (RS256 signature, iss = team domain, aud = Access application AUD tag, expiry). verify(token) -> claims takes the token + configured iss/aud, so JEF-471/472's /mcp Bearer auth can reuse it. JWKS is cached and refreshed hourly.
  • lib.rsaccess_guard axum middleware + app_with_access(pool, Option<Arc<Verifier>>). The middleware wraps only the UI shell + /api. app(pool) now delegates to app_with_access(pool, None), so every existing call site is unchanged.
  • main.rs — builds the verifier from env (Verifier::from_env) and passes it in.
  • ADR 0013 amended — edge auth → edge auth + origin verification (kept 0013; did not take 0018, reserved for JEF-471).

Route policy (exact)

Surface Gated when configured?
UI shell + /api/* Yes — 401 on missing/invalid Cf-Access-Jwt-Assertion
/v1/* OTLP ingest Never (in-cluster collectors carry no token)
/healthz Never (kubelet probes carry no token)

Config + fail-open

Enforcement is wired in only when both WATCHER_ACCESS_TEAM_DOMAIN and WATCHER_ACCESS_AUD are set — unset means no enforcement, so local dev / non-Access deploys are unchanged. On a JWKS refresh failure with a warm cache, last-known keys are served; on a cold cache with an unreachable certs endpoint the middleware fails open with a loud warning (the edge remains the primary gate) rather than taking the whole read surface down on a transient Cloudflare blip. This availability-biased trade for a secondary layer is documented in ADR 0013.

Tests

  • 8 unit tests over the verifier: valid, wrong-aud, wrong-iss, expired, tampered-signature, unknown-kid, garbage token, cold-cache-unavailable — signing with a locally-generated test JWK (no network).
  • 2 smoke tests through the real middleware: configured → /api + UI shell reject missing/invalid/wrong-aud/expired tokens (401) and a valid token passes (200), while /v1 ingest + /healthz stay open; unconfigured → /api open.
  • cargo fmt --check, cargo clippy --all-targets, cargo test --locked all green (91 tests).

Notes for the architect

  • DECISION (JWKS fail-open): chose serve-last-known on warm-cache refresh failure, and fail-open-with-loud-warning on cold-cache unavailability. Rationale: this is a defense-in-depth layer behind the edge; hard-failing every request during a Cloudflare certs blip is a worse outage than briefly reverting to edge-only auth. Flagging for ratification.
  • ADR choice: amended 0013 in place (rather than a superseding ADR) since this evolves the same decision. Avoided ADR 0018 (reserved for JEF-471).
  • Parallel overlap (JEF-471): kept my app() change to wrapping the UI+/api routers, my main.rs change to the Access config block, and my Cargo.toml add to jsonwebtoken (separate from rmcp). Did not create mcp.rs or touch api.rs handlers. tests/smoke.rs changes are append-only.

🤖 Generated with Claude Code

watcher's read surface (UI shell + /api) was gated only by Cloudflare
Access at the edge, with the origin trusting that traffic arrived through
Access (ADR 0013). An Access-policy slip, a tunnel/ingress misconfig, or
direct in-cluster access to the pod would then reach /api unauthenticated.

Add origin-side defense-in-depth: a new `access_jwt` module validates the
edge-issued Access JWT against the team's JWKS — RS256 signature, `iss`
(team domain), `aud` (Access application AUD tag), and expiry — and an axum
middleware in `app_with_access` enforces it on the UI shell + /api. The
verifier is transport-agnostic (token + expected iss/aud in, claims out),
so JEF-471/472's /mcp Bearer auth can reuse it. The origin only validates,
never mints.

Route policy: /v1 OTLP ingest and /healthz are NEVER gated — in-cluster
collectors and kubelet probes carry no token, and gating them would break
ingest and readiness.

Config + fail-open: enforcement is wired in only when
WATCHER_ACCESS_TEAM_DOMAIN + WATCHER_ACCESS_AUD are set, so local dev and
non-Access deploys are unchanged. The JWKS is cached and refreshed hourly;
a refresh failure with a warm cache serves last-known keys, and a cold
cache with an unreachable certs endpoint fails OPEN with a loud warning
(the edge stays the primary gate) rather than taking the whole read surface
down on a transient Cloudflare blip. ADR 0013 is amended to record this.

Tests: 8 unit tests over the verifier (valid / wrong-aud / wrong-iss /
expired / tampered-sig / unknown-kid / garbage / cold-cache-unavailable,
using a locally-signed JWK) and 2 smoke tests proving /api + UI shell
reject a missing/invalid token when configured, a valid token passes, and
/v1 + /healthz stay open. cargo fmt --check, cargo clippy --all-targets,
and cargo test --locked all green (91 tests).

Closes JEF-473

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@thejefflarson
thejefflarson merged commit 5b761c1 into main Jul 22, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-473-origin-side-cloudflare-access-jwt-verification-across-the branch July 22, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant