Skip to content

feat(auth): verify-only inbound JWT credentials - #351

Draft
amondnet wants to merge 4 commits into
mainfrom
feat/inbound-jwt-auth
Draft

feat(auth): verify-only inbound JWT credentials#351
amondnet wants to merge 4 commits into
mainfrom
feat/inbound-jwt-auth

Conversation

@amondnet

@amondnet amondnet commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 firstParty
provider mode, which is the motivation (see #344 for the full comparison against gateway login).

What landed:

  • New [[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.
  • Verification core in src/auth/inbound_jwt.rs: collects every entry matching the token's
    issuer, pins algorithms from config so the token header's alg never selects, requires
    kid, per-issuer lazy JWKS with a 60s refetch floor, validates exp/nbf/aud/azp/
    exp - iat, and requires email_verified.
  • 401 for a token that verified against nothing; 503 for an unreachable key set. One
    issuer's outage does not deny the others.
  • Shared gate in src/auth/gate.rs so six routes don't repeat the precedence rules. Static
    token is checked first and short-circuits; a deployment with zero JWT entries never enters
    the async path.
  • JWKS cache lives on AppState (next to admin_stores/gateway_stores), so a config reload
    re-resolves entries without discarding fetched keys.
  • New dependency jsonwebtoken 11 with default-features = false, features = ["aws_lc_rs"]
    disabling use_pem drops pem/simple_asn1/num-*, and aws-lc-rs is already in the tree
    via the direct rustls dep, 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-token header and
leave 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.rs headers_for_route forwarded it on a gated mixed chain's
passthrough attempt, and discovery/upstream.rs computed bearer_is_consumed as "gateway JWT
or 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 controls
asserting the static-token path still forwards.

Milestone / spec

New docs/inbound-jwt-auth.md; pointer added from docs/m4-inbound-auth.md.

Checklist

  • cargo build passes
  • cargo test passes (new behavior is covered; tests run without network/loopback where possible)
  • cargo clippy --all-targets -- -D warnings clean
  • cargo fmt --all --check clean
  • Source files stay under 500 lines
  • English only; matches surrounding style
  • Frozen spec in docs/ updated if this change deviates from it
  • User-facing docs updated for behavior/config/endpoint/CLI/provider/model changes —
    configuration reference and shared-gateway guide in all 4 locales, README,
    reference/endpoints.md, shunt.toml.example
  • Any new GitHub Action is pinned to a full commit SHA — n/a, no workflow changes

Notes for reviewers

  • Mutation testing: 9 mutations run against the new tests; 8 caught. The one vacuous mutation
    was the standard alg-confusion test (HMAC header signed with the JWKS modulus) —
    DecodingKey::from_jwk hands back an RSA key that refuses HMAC regardless, so the test passed
    even 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.
  • Site builds and lints clean; the #serverauthjwt-optional anchor that README and
    endpoints.md link to was verified present in the built HTML.
  • Not in this PR (remains open on feat(auth): accept externally-issued JWTs as an inbound credential (verify-only, multi-issuer) #344): require, subject_prefix, per-entry
    email_verified, identity_claim for non-email entries, bounded identity labels, hot reload
    add/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 by iss.

  • Verifies JWTs against issuer JWKS with a pinned algorithm list, required kid, aud match, optional azp/authorized_parties, bounded exp - iat, and email_verified plus allowlists (email_domains, allowed_emails). JWTs are accepted only in Authorization: Bearer.
  • Never forwards a verified inbound JWT upstream. Fixes two leaks in proxy/failover.rs (mixed-chain passthrough attempt) and discovery/upstream.rs (bearer consumption), with regression tests.
  • Returns 401 when no configured credential verifies; returns 503 when an issuer’s JWKS is unreachable. One issuer’s outage does not block the others.
  • Adds a shared gate in src/auth/gate.rs and a per-issuer JWKS cache on AppState (survives config reloads; refetch floor 60s).
  • Introduces jsonwebtoken as jsonwebtoken@11 with default-features = false, features = ["aws_lc_rs"]. Rejects symmetric algorithms at config validation.

Rollout and migration

  • No change for static-token setups. To enable JWT, add one or more [[server.auth.jwt]] entries (issuer, audience; optionally email_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.
  • You may leave server.auth.tokens_env empty once a JWT issuer is configured; with neither static tokens nor JWT, startup fails closed.
  • Expect 503 responses 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.

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
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​jsonwebtoken@​11.0.010010097100100

View full report

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/auth/inbound_jwt.rs
Comment on lines +251 to +255
let status = response.status();
if !status.is_success() {
return Err(format!("returned HTTP {status}"));
}
let body = read_bounded(response).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[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.

Suggested change
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
  1. 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

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.81779% with 64 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/config.rs 90.44% 30 Missing ⚠️
src/auth/inbound_jwt.rs 91.09% 17 Missing ⚠️
src/codex_endpoint.rs 28.57% 5 Missing ⚠️
src/proxy/failover.rs 93.33% 5 Missing ⚠️
src/oauth_usage.rs 76.92% 3 Missing ⚠️
src/codex_analytics.rs 81.81% 2 Missing ⚠️
src/usage.rs 80.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
E Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

let mut headers = HeaderMap::new();
headers.insert(
"authorization",
"Bearer header.payload.sig".parse().unwrap(),
@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 83 untouched benchmarks


Comparing feat/inbound-jwt-auth (f789a1d) with main (4c477db)

Open in CodSpeed

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.

2 participants