feat(auth): verify-only inbound JWT credentials - #351
Conversation
Add `[[server.auth.jwt]]`: shunt accepts a JWT minted by an external identity provider, validates it against that issuer's JWKS, and maps the verified claims to a caller identity. shunt issues nothing — no login flow, no session store, no signing secret — so a client keeps talking to shunt with a base URL plus a bearer token and never enters Claude Code's gateway provider mode. Phase 1 of #344: array config, verification core, `email_domains` / `allowed_emails` as the only authorization operators, `email_verified` required. Docs and tests follow in this PR. - `jsonwebtoken` with `default-features = false, features = ["aws_lc_rs"]`: only the JWKS path is used, so `use_pem` stays off, and the backend is the crypto provider the direct `rustls` dep already installs. - Algorithms are pinned from config and the token header's `alg` never selects one; symmetric algorithms are rejected at config validation. - `kid` is required, and an unknown one refetches at most once per window. - Per-issuer JWKS cache and failure domain, on `AppState` rather than the hot-reloaded `InboundAuth` so a reload does not discard keys. - An unreachable JWKS answers 503, not 401. - `tokens_env` may resolve empty once a JWT entry exists; with neither, `[server.auth]` still fails closed. Refs #344
38 tests signing real RS256 tokens against a loopback JWKS mock, plus config-validation coverage. Nine mutations were run against the implementation to check the assertions bite. Eight were caught. The ninth found that the alg-confusion test was vacuous: an HMAC-signed token is refused by the key type regardless of whether the algorithm pin holds, so the test could not fail if the pin broke. Added `a_header_algorithm_outside_the_configured_pin_rejects` (RS384 against an RS256-pinned entry, same key and kid) which isolates the pin, and a config test for the symmetric-algorithm refusal — the two layers that actually stop the attack. The end-to-end HMAC case stays, now documented as fixing the attack's outcome rather than the mechanism. Refs #344
Adds `docs/inbound-jwt-auth.md` (the behavior specification the code comments reference), a `[[server.auth.jwt]]` reference section on the configuration page in all four locales, a how-to section in the shared-gateway guide in all four locales, a README feature bullet, an endpoints.md note on the 401/503 split, a commented block in shunt.toml.example, and a pointer from the M4 static-token note. Verified rather than asserted: the example config block validates with `shunt check` once uncommented, a JWT-only deployment (no static tokens) boots while one with neither credential fails closed, the site builds and lints clean, and the `#serverauthjwt-optional` anchor the README and endpoints page link to exists in the built page. Refs #344
Self-review of the previous commits found a credential leak I introduced. `docs/m4-inbound-auth.md` §2 requires that a credential accepted as a gate token is never forwarded upstream, and resolves the mixed passthrough/mapped case by advice: hand out dedicated `x-shunt-token` values so the bearer slot stays free for each caller's real upstream credential. A JWT has no such alternative — it is only ever accepted in the bearer slot — so both bearer consumers treated it as the caller's own credential and relayed it to a third-party upstream: - `proxy/failover.rs`: the passthrough attempt of a gated (mixed) route chain forwarded the bearer verbatim. - `discovery/upstream.rs`: `bearer_is_consumed` was `gateway JWT || static token match`, and a verified inbound JWT is neither, so upstream model discovery relayed it to `api.anthropic.com`. Both now detect the JWT case explicitly. Each fix has a regression test plus a non-vacuity control asserting the static-token path still forwards the caller's bearer unchanged; reverting either fix fails its test. Refs #344
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request implements opt-in, verify-only inbound JWT authentication ([[server.auth.jwt]]) to validate tokens from external identity providers against their JWKS, mapping verified claims to caller identities. This stateless authentication mechanism is integrated across gated routes such as models, usage, analytics, and codex endpoints, and is accompanied by comprehensive tests and documentation. Feedback on the implementation identifies a potential connection leak in src/auth/inbound_jwt.rs where the response body is not consumed on non-2xx HTTP responses, which prevents TCP connection reuse.
| let status = response.status(); | ||
| if !status.is_success() { | ||
| return Err(format!("returned HTTP {status}")); | ||
| } | ||
| let body = read_bounded(response).await?; |
There was a problem hiding this comment.
[MEDIUM] Connection leak on non-2xx HTTP responses
Problem: The response body is not consumed or drained when the HTTP status is not successful, which prevents the underlying TCP connection from being returned to the connection pool for reuse.
Rationale: General Rules: "always consume or drain the response body on both success and error/non-2xx paths."
Suggestion: Read the bounded response body before checking the status code.
| let status = response.status(); | |
| if !status.is_success() { | |
| return Err(format!("returned HTTP {status}")); | |
| } | |
| let body = read_bounded(response).await?; | |
| let status = response.status(); | |
| let body = read_bounded(response).await?; | |
| if !status.is_success() { | |
| return Err(format!("returned HTTP {status}")); | |
| } |
References
- When making outbound HTTP requests (e.g., using reqwest in Rust), always consume or drain the response body on both success and error/non-2xx paths. This allows the underlying TCP connection to be returned to the connection pool for reuse. To prevent unbounded memory allocation, cap the read at a reasonable limit (e.g., 64 KiB) and abandon the connection if the limit is exceeded.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
| let mut headers = HeaderMap::new(); | ||
| headers.insert( | ||
| "authorization", | ||
| "Bearer header.payload.sig".parse().unwrap(), |




Summary
Phase 1 of #344 — verify-only inbound JWT authentication. shunt validates externally-minted
JWTs but issues nothing (no login flow, session store, refresh token, or signing secret), so it
stays stateless across replicas. A base-URL + bearer client keeps Claude Code in
firstPartyprovider mode, which is the motivation (see #344 for the full comparison against gateway login).
What landed:
[[server.auth.jwt]]array config:issuer,audience(string or array),email_domains,allowed_emails,algorithms,authorized_parties,clock_skew_seconds,max_token_age_seconds,jwks_url.src/auth/inbound_jwt.rs: collects every entry matching the token'sissuer, pins algorithms from config so the token header's
algnever selects, requireskid, per-issuer lazy JWKS with a 60s refetch floor, validatesexp/nbf/aud/azp/exp - iat, and requiresemail_verified.401for a token that verified against nothing;503for an unreachable key set. Oneissuer's outage does not deny the others.
src/auth/gate.rsso six routes don't repeat the precedence rules. Statictoken is checked first and short-circuits; a deployment with zero JWT entries never enters
the async path.
AppState(next toadmin_stores/gateway_stores), so a config reloadre-resolves entries without discarding fetched keys.
jsonwebtoken 11withdefault-features = false, features = ["aws_lc_rs"]—disabling
use_pemdropspem/simple_asn1/num-*, andaws-lc-rsis already in the treevia the direct
rustlsdep, so only two genuinely new crates enter.Self-review fix
Two credential leaks found in self-review (tests were green and clippy clean at the time) and
fixed separately:
docs/m4-inbound-auth.md§2's "a gate credential is never forwarded upstream"boundary was upheld for static tokens by advice — use the dedicated
x-shunt-tokenheader andleave Bearer for the caller's own credential. A JWT can only ever arrive in the Bearer slot, so
that advice does not apply, and two Bearer consumers still treated Bearer as the caller's own
credential:
proxy/failover.rsheaders_for_routeforwarded it on a gated mixed chain'spassthrough attempt, and
discovery/upstream.rscomputedbearer_is_consumedas "gateway JWTor matching static token", which a verified inbound JWT is neither — sending an IdP identity
token to
api.anthropic.com. Both fixed with regression tests plus non-vacuity controlsasserting the static-token path still forwards.
Milestone / spec
New
docs/inbound-jwt-auth.md; pointer added fromdocs/m4-inbound-auth.md.Checklist
cargo buildpassescargo testpasses (new behavior is covered; tests run without network/loopback where possible)cargo clippy --all-targets -- -D warningscleancargo fmt --all --checkcleandocs/updated if this change deviates from itconfiguration reference and shared-gateway guide in all 4 locales, README,
reference/endpoints.md,shunt.toml.exampleNotes for reviewers
was the standard alg-confusion test (HMAC header signed with the JWKS modulus) —
DecodingKey::from_jwkhands back an RSA key that refuses HMAC regardless, so the test passedeven with the algorithm pin broken. Added a test that isolates the pin (same key, same
kid,RS384 presented to an RS256-pinned entry) and a config test for symmetric-algorithm refusal;
the original HMAC test stays, re-commented to say it covers the attack's outcome rather than
its mechanism.
#serverauthjwt-optionalanchor that README andendpoints.mdlink to was verified present in the built HTML.require,subject_prefix, per-entryemail_verified,identity_claimfor non-email entries, bounded identity labels, hot reloadadd/remove without restart.
Related Issues
Part of #344 (verify-only inbound JWT — this PR covers the core verification path; remaining
scope tracked in the issue's Tests/Docs sections is not yet implemented).
Summary by cubic
Adds verify-only inbound JWT credentials via
[[server.auth.jwt]], so shunt can authenticate users with your IdP while staying stateless. Static tokens still work; the gate now checks static first, then JWT byiss.kid,audmatch, optionalazp/authorized_parties, boundedexp - iat, andemail_verifiedplus allowlists (email_domains,allowed_emails). JWTs are accepted only inAuthorization: Bearer.proxy/failover.rs(mixed-chain passthrough attempt) anddiscovery/upstream.rs(bearer consumption), with regression tests.401when no configured credential verifies; returns503when an issuer’s JWKS is unreachable. One issuer’s outage does not block the others.src/auth/gate.rsand a per-issuer JWKS cache onAppState(survives config reloads; refetch floor 60s).jsonwebtokenasjsonwebtoken@11withdefault-features = false, features = ["aws_lc_rs"]. Rejects symmetric algorithms at config validation.Rollout and migration
[[server.auth.jwt]]entries (issuer,audience; optionallyemail_domains/allowed_emails,algorithms,authorized_parties,clock_skew_seconds,max_token_age_seconds,jwks_url). Clients keep using a base URL + bearer; they send the IdP JWT as Bearer.server.auth.tokens_envempty once a JWT issuer is configured; with neither static tokens nor JWT, startup fails closed.503responses when an issuer’s JWKS is down; upstreams will not receive caller JWTs after this change.Written for commit f789a1d. Summary will update on new commits.