Skip to content

feat(auth): inbound OIDC/JWT authentication — trust providers + jwt_subject key binding - #825

Merged
jarvis9443 merged 4 commits into
mainfrom
feat/jwt-oidc-auth
Jul 27, 2026
Merged

feat(auth): inbound OIDC/JWT authentication — trust providers + jwt_subject key binding#825
jarvis9443 merged 4 commits into
mainfrom
feat/jwt-oidc-auth

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Adds the data-plane half of AISIX-Cloud#1080 / AISIX-Cloud#1081: requests may now authenticate with an IdP-issued JWT (Keycloak or any OIDC-compliant provider) instead of a gateway API key. The paired control-plane PR (cp-admin.yaml CRUD + dashboard + CP↔DP e2e) follows in AISIX-Cloud.

What

New etcd kind oidc_providers — env-scoped trust rows:

{
  "name": "corp-keycloak",
  "issuer": "https://sso.example.com/realms/agents",   // exact-match allow-list
  "audiences": ["aisix-gateway"],                      // token aud must intersect
  "jwks_uri": "https://…/certs",                       // optional; OIDC discovery fallback
  "identity_claim": "sub",                             // dot-paths supported
  "required_scopes": ["ai.access"],
  "bound_claims": { "department": "ai-lab", "realm_access.roles": ["agent"] },
  "leeway_secs": 0,
  "enabled": true
}

JWT path at the single auth choke point (authenticate_token): inherited by every proxy surface — chat/completions/messages/responses/embeddings/rerank/audio/images/videos/files/batches/fine-tuning, /mcp, /a2a, realtime WebSocket subprotocol auth, and passthrough — with no fallback to key lookup once a real JWT enters the path. A bearer is treated as a JWT only when it has three segments and the first decodes to a JOSE header, so dotted custom-imported keys keep working.

Validation chain (RFC 7519 §4.1, all default-deny):

  • asymmetric algorithms only (RS*/PS*/ES256/384/EdDSA) — HMAC excluded so a public JWKS can never double as a shared signing secret
  • signature against the matched provider's JWKS
  • exp required and valid; nbf honored when present; per-provider leeway_secs
  • iss pinned to the matched provider; an issuer matching no enabled row is rejected (the provider list is an allow-list, no catch-all path)
  • aud required, matched against audiences
  • required_scopes (space-delimited string or array scope claim) and bound_claims (dot-paths traverse nested claims) → 403 jwt_claims_rejected

Identity mapping — external token → internal API key: the provider's identity_claim value selects the key whose new ApiKey.jwt_subject equals it. The request then proceeds as that key, so allowed_models, rate limits, budgets, usage attribution, and MCP/A2A ACLs all apply unchanged; an unmapped identity is 401 jwt_identity_unmapped, never an anonymous pass. One key per agent identity gives 110+ agents per-agent budget/limit/attribution with zero changes downstream.

JWKS lifecycle: per-URL process cache (10 min TTL); fetches refuse redirects, carry a 5 s timeout and a 512 KB cap; a failed re-fetch serves the stale set (network-partition tolerance); an unknown kid triggers a rate-limited (1/s) immediate re-fetch, so IdP key rotation is picked up within ~1 s with no gateway restart.

Auth decisions become observable (the audit half of AISIX-Cloud#1081): new aisix_auth_decisions_total{method, result, reason} metric + target="aisix::auth" decision logs (denials at WARN with reason class / issuer / kid / subject — never the token; API-key path included). Previously a 401 was invisible — the extractor short-circuits before every handler, so no counter or log ever fired. New stable error.code values follow the existing lifecycle-code convention: jwt_expired, jwt_invalid (signature/issuer/audience collapsed to avoid an oracle), jwt_claims_rejected (403), jwt_identity_unmapped, jwks_unavailable (503 — IdP unreachable is retryable, not a credential judgment). /v1/messages renders all of them in the Anthropic envelope via the existing status mapping.

Full config-surface wiring for the new kind: loader, watch supervisor (apply/delete/clone/counts merge loops — the initial gap here was caught by the e2e and is now pinned by the every-kind supervisor tests), standalone resources_file (with a duplicate-jwt_subject load error mirroring the duplicate-credential rule), snapshot export, schemas/resources/ via dump-schema. No Admin API CRUD surface for the kind in this PR (same posture as mcp_policies; the resources file covers standalone deployments).

Reference-implementation comparison

Mainstream LLM proxies implement this same overall shape: an external JWT verified against a cached JWKS, then mapped to an internal virtual key that carries budget/limit/model policy (claim-value → key mapping), with structural JWT/key discrimination on the bearer and no fallback after a failed JWT validation. Upstream specs: RFC 7519 (JWT), RFC 7517 (JWK), OIDC Discovery 1.0 §4.

Deliberate divergences, all tightening:

Area Common behavior Here
iss/aud/exp global path verifies iss/aud only when configured; exp-less tokens accepted all three mandatory
unlisted issuer falls back to a catch-all verification path rejected (allow-list)
unknown kid waits out the JWKS cache TTL (rotation outage up to TTL) rate-limited immediate re-fetch
unmapped identity falls back to group/team-based identity rejected (default deny)
JIT auto-registration proxy writes the key row itself out of scope — the DP is an etcd read-only consumer; fleets pre-create keys via the CP/Admin API

Tests

  • tests/e2e/src/cases/jwt-auth-e2e.test.ts (20 cases, real binary + etcd + mock IdP): happy path; bound-key allowed_models/rate_limit inheritance; expired / tampered / exp-less / wrong-aud / cross-issuer / unknown-issuer rejections with stable codes and zero upstream hits; scope / bound-claim 403s; unmapped identity; disabled key; discovery-resolved provider; key rotation without restart; provider deletion failing closed; Anthropic envelope; metric exposure; dotted custom keys unaffected; and zero JWKS fetches when no provider is configured.
  • Unit: validation matrix against a fixture RSA keypair (expired / missing-exp / missing-aud / wrong-iss / future-nbf / tampered / leeway), claim helpers, kid selection, provider/subject tie-breaks, schema/filesource/supervisor kind wiring.

Fixes api7/AISIX-Cloud#1080

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added oidc_providers support end-to-end (schema, loading, snapshots, and exports).
    • Enabled inbound OIDC/JWT authentication with issuer discovery, JWKS validation, claim/scope checks, and key rotation.
    • Added an aisix_auth_decisions_total metric for authentication outcomes.
  • Bug Fixes
    • Enforced API key JWT binding rules, including rejecting jwt_subject without jwt_provider and detecting duplicate (jwt_provider, jwt_subject) mappings.
  • Documentation
    • Added JSON Schemas for oidc_provider and JWT subject binding fields.
  • Tests
    • Added comprehensive JWT auth E2E coverage (including fail-closed behavior).

…ubject key binding

Adds the data-plane half of AISIX-Cloud#1080/#1081: requests may now
authenticate with an IdP-issued JWT (Keycloak or any OIDC provider)
instead of a gateway API key.

New etcd kind `oidc_providers` (env-scoped rows: issuer, audiences,
optional jwks_uri with OIDC-discovery fallback, identity_claim,
required_scopes, bound_claims, leeway_secs, enabled). When at least one
enabled provider exists, a JWT-shaped bearer (three segments whose first
decodes to a JOSE header) takes the JWT path at the single auth choke
point — inherited by every proxy surface including /mcp, /a2a, realtime
subprotocol auth, and passthrough — with no fallback to key lookup on
failure.

Validation chain (RFC 7519 §4.1): asymmetric algorithms only (RS*/PS*/
ES256/384/EdDSA — HMAC excluded to keep a public JWKS from doubling as a
shared secret), signature against the provider's JWKS, `exp` required,
`iss` pinned to the matched provider (unlisted issuers rejected — the
provider list is an allow-list), `aud` required and matched against the
provider's accepted audiences, `nbf` honored when present, then
required_scopes / bound_claims (dot-paths traverse nested claims, e.g.
realm_access.roles).

Identity mapping: the provider's identity_claim value selects the API
key whose new `jwt_subject` field equals it; the request then proceeds
as that key, so allowed_models, rate limits, budgets, usage attribution,
and MCP/A2A ACLs all apply unchanged. An unmapped identity is rejected
(jwt_identity_unmapped), never an anonymous pass.

JWKS handling: per-URL process cache (10 min TTL), redirect-refusing
fetch with timeout and size cap, serve-stale on re-fetch failure, and a
rate-limited immediate re-fetch on unknown `kid` so IdP key rotation is
picked up within ~1s with no restart.

Auth decisions become observable: new aisix_auth_decisions_total
{method, result, reason} metric plus target="aisix::auth" decision logs
(denials at WARN with reason class, issuer, kid, subject — never the
token). Previously a 401 was invisible: the extractor short-circuits
before every handler, so no counter or log ever fired. Client-visible
error codes extend the existing lifecycle-code convention: jwt_expired,
jwt_invalid (signature/issuer/audience collapsed to avoid an oracle),
jwt_claims_rejected (403), jwt_identity_unmapped, jwks_unavailable
(503, retryable — IdP unreachable is not a credential judgment).

The kind is wired through the full config surface: loader, watch
supervisor (apply/delete/clone/counts — the every-ResourceTable merge
loops), standalone resources_file (with a duplicate-jwt_subject load
error mirroring the duplicate-credential rule), snapshot export, and
schemas/resources via dump-schema.

Reference-implementation comparison: mainstream LLM proxies implement
this same shape — external JWT verified against a JWKS, then mapped to
an internal virtual key that carries the budget/limit/model policy; the
scheme-level JWT/key discrimination on the bearer is also the
established pattern. Deliberate divergences, all tightening: iss/aud/exp
are mandatory here while the common global path verifies iss/aud only
when configured and accepts exp-less tokens; unknown issuers hard-fail
instead of falling back to a catch-all verification path; unknown-kid
triggers a rate-limited refetch instead of waiting out the cache TTL
(rotation outage otherwise lasts up to the TTL); an unmapped identity is
rejected instead of falling back to a group-based identity; and JIT
identity auto-registration is out of scope (the DP is an etcd read-only
consumer — pre-created keys cover the fleet-onboarding case via the
Admin API).

E2E (tests/e2e/src/cases/jwt-auth-e2e.test.ts, 20 cases): happy path,
bound-key allowed_models/rate_limit inheritance, expired/tampered/
exp-less/wrong-aud/cross-issuer/unknown-issuer rejections with stable
codes and zero upstream hits, scope/bound-claim 403s, unmapped identity,
disabled key, discovery-resolved provider, key rotation without restart,
provider deletion failing closed, Anthropic envelope on /v1/messages,
metric exposure, dotted custom keys unaffected, and no-JWKS-fetch when
no provider is configured. Unit tests cover the validation matrix with a
fixture RSA keypair, claim helpers, tie-breaks, and the filesource/
supervisor kind wiring.

The paired control-plane exposure (cp-admin.yaml `oidc_providers` CRUD +
ApiKey.jwt_subject, dashboard, CP↔DP e2e) follows in AISIX-Cloud; this
PR intentionally does not add an Admin API CRUD surface for the kind
(same posture as mcp_policies — the resources file covers standalone).

Fixes api7/AISIX-Cloud#1080

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f624dfd2-1df4-4e29-812b-ed176c980fcc

📥 Commits

Reviewing files that changed from the base of the PR and between 47ba2a3 and b43a173.

📒 Files selected for processing (7)
  • crates/aisix-core/src/filesource/mod.rs
  • crates/aisix-core/src/filesource/tests.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-core/src/snapshot.rs
  • crates/aisix-proxy/src/jwt.rs
  • schemas/resources/oidc_provider.schema.json
  • tests/e2e/src/cases/jwt-auth-e2e.test.ts
📝 Walkthrough

Walkthrough

Adds configurable OIDC providers and JWT bearer authentication, including issuer and audience validation, JWKS discovery and rotation, claim-based API-key binding, structured errors and metrics, resource propagation, exports, and end-to-end coverage.

Changes

OIDC provider contracts and storage

Layer / File(s) Summary
Provider contracts and snapshot resources
crates/aisix-core/src/models/*, crates/aisix-core/src/filesource/*, schemas/resources/*
Adds OIDC provider and JWT subject models, schema validation, file loading, binding integrity checks, snapshot storage, and resource-table queries.
Resource loading and propagation
crates/aisix-etcd/src/*, crates/aisix-server/src/export/document.rs, tests/e2e/src/harness/seed.ts
Loads, updates, deletes, clones, counts, exports, and seeds oidc_providers resources.
JWT authentication and decision reporting
crates/aisix-proxy/src/*, crates/aisix-obs/src/metrics.rs
Adds JWT verification, provider selection, claim checks, JWKS and discovery caching, API-key binding, asynchronous authentication, structured errors, and authentication decision metrics.
JWT integration validation
tests/e2e/src/cases/jwt-auth-e2e.test.ts, tests/e2e/src/harness/*
Adds mock IdP and key-rotation helpers plus end-to-end coverage for valid tokens, rejection cases, authorization, rotation, fail-closed behavior, metrics, and API-key fallback.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProxyAuth
  participant JWTAuth
  participant OIDCProvider
  participant JWKS
  participant APIKey
  Client->>ProxyAuth: send bearer JWT
  ProxyAuth->>JWTAuth: authenticate token
  JWTAuth->>OIDCProvider: select issuer and provider
  JWTAuth->>JWKS: resolve and fetch signing keys
  JWKS-->>JWTAuth: return verification keys
  JWTAuth->>APIKey: map identity claim to jwt_subject
  APIKey-->>JWTAuth: return bound key
  JWTAuth-->>ProxyAuth: allow or return structured error
Loading

Suggested reviewers: moonming


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error JWKS fetch client silently falls back to Client::default() if builder init fails, dropping no-redirect/timeout protections for trust-material fetches. Do not substitute the default client; propagate/expect the build failure or build a client that still enforces timeout and no-redirect settings.
E2e Test Quality Review ⚠️ Warning The metrics test is order-dependent: it asserts jwt_expired is present in global metrics without generating that event itself. Make the metrics assertion self-contained by issuing the expired-token request inside the test, or compare a before/after delta for that case.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the new inbound OIDC/JWT auth feature and the jwt_subject/provider binding work.
Linked Issues check ✅ Passed The PR adds OIDC trust providers, JWT validation, JWKS/discovery/rotation, claim-based binding, and test coverage matching #1080.
Out of Scope Changes check ✅ Passed The changes are cohesive around inbound OIDC/JWT auth and supporting schemas, loader, proxy, metrics, and tests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/jwt-oidc-auth

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🧹 Nitpick comments (8)
crates/aisix-proxy/src/jwt.rs (2)

386-389: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Comment contradicts the code.

set_required_spec_claims(&["exp", "iss", "aud"]) makes those claims mandatory; the sentence reads as if they are optional-when-present. Suggest restating.

✏️ Wording
-    // `aud`/`iss` are only checked when present — requiring them makes
-    // absence a rejection (default deny), alongside the always-required
-    // `exp`.
+    // Default deny: `exp`, `iss`, and `aud` are all mandatory. Without
+    // listing them here, jsonwebtoken skips `iss`/`aud` when the claim
+    // is absent, which would let a token with no `aud` through.
     validation.set_required_spec_claims(&["exp", "iss", "aud"]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/jwt.rs` around lines 386 - 389, Update the comment
above set_required_spec_claims in the JWT validation setup to state that exp,
iss, and aud are always required and that omitting any of them causes rejection;
keep the validation code unchanged.

700-729: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Checked-in RSA private key will keep tripping secret scanners.

It is a genuine test fixture, not a leak — but betterleaks flags it, and every future scan will too. Either generate the keypair at test time (deriving the JWK from the public half, so TEST_JWKS stays in sync automatically) or add the scanner's inline allow annotation so the finding is explicitly triaged rather than re-litigated each run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/jwt.rs` around lines 700 - 729, Remove the checked-in
private key from the `TEST_RSA_PEM` fixture to prevent secret-scanner findings.
Generate the RSA keypair during test setup and derive the corresponding JWK used
by `TEST_JWKS` from its public key so both fixtures remain synchronized;
alternatively, add the repository-supported inline `betterleaks` allow
annotation directly for this intentionally triaged fixture.

Source: Linters/SAST tools

crates/aisix-proxy/src/error.rs (1)

284-284: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

JwksUnavailable is a retryable 503 with no Retry-After.

retry_after_secs() returns None for this variant, so clients get no backoff hint on the one JWT error that is explicitly documented as retryable. A constant matching JWKS_REFRESH_MIN_INTERVAL would be enough.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/error.rs` at line 284, Update retry_after_secs() for
ProxyError::JwksUnavailable to return a retry delay matching
JWKS_REFRESH_MIN_INTERVAL instead of None, while preserving the existing 503
status mapping.
tests/e2e/src/harness/jwks-mock.ts (2)

136-139: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Unnecessary PEM round-trip on every sign().

signer.privateKey is already a KeyObject; exporting it to PKCS#8 PEM and re-importing it per token is pure overhead in a helper the suite calls dozens of times.

♻️ Simplification
       const sig = createSign("RSA-SHA256")
         .update(signingInput)
-        .sign(createPrivateKey(signer.privateKey.export({ format: "pem", type: "pkcs8" })));
+        .sign(signer.privateKey);

(and drop the now-unused createPrivateKey import)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/harness/jwks-mock.ts` around lines 136 - 139, Update the
signing logic around signer.privateKey to pass the existing KeyObject directly
to sign(), removing the per-token PKCS#8 PEM export and re-import. Remove the
now-unused createPrivateKey import while preserving the generated token format.

168-170: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused re-export.

The comment describes a scenario no test in this PR exercises — the cross-issuer test at tests/e2e/src/cases/jwt-auth-e2e.test.ts Line 267 just signs with the second MockIdp. Drop it unless a follow-up needs it.

As per coding guidelines, "avoid speculative features, unnecessary abstractions, configurability".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/harness/jwks-mock.ts` around lines 168 - 170, Remove the unused
createPublicKey re-export from the JWKS mock module, along with its speculative
explanatory comment. Keep the underlying helper private and preserve the
existing MockIdp test behavior.

Source: Coding guidelines

crates/aisix-obs/src/metrics.rs (1)

383-397: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider &'static str for method/reason to type-enforce bounded cardinality.

The doc comment promises bounded labels, but the signature accepts any &str. record_usage_event_emit in this same file already uses &'static str for exactly this reason (audit MEDIUM-3). Every current call site passes a literal, so the change is mechanical.

♻️ Proposed tightening
-    pub fn record_auth_decision(&self, method: &str, allowed: bool, reason: &str) {
+    pub fn record_auth_decision(&self, method: &'static str, allowed: bool, reason: &'static str) {
         metrics::with_local_recorder(&self.inner.recorder, || {
             metrics::counter!(
                 M_AUTH_DECISIONS_TOTAL,
-                "method" => method.to_string(),
-                "result" => if allowed { "allowed" } else { "denied" }.to_string(),
-                "reason" => if reason.is_empty() { "none" } else { reason }.to_string(),
+                "method" => method,
+                "result" => if allowed { "allowed" } else { "denied" },
+                "reason" => if reason.is_empty() { "none" } else { reason },
             )
             .increment(1);
         });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-obs/src/metrics.rs` around lines 383 - 397, Update
record_auth_decision to accept &'static str for both method and reason, matching
the bounded-cardinality contract and the existing record_usage_event_emit
signature. Keep the label normalization and counter behavior unchanged, and
ensure all current call sites continue passing string literals.
tests/e2e/src/cases/jwt-auth-e2e.test.ts (2)

356-363: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the error code on the retired-key token.

A bare 401 passes for any credential failure. Pinning jwt_invalid is what actually proves the retired key stopped verifying rather than some unrelated rejection.

💚 Tighter assertion
     expect(stale.status).toBe(401);
-    await stale.text();
+    expect(await errorCode(stale)).toBe("jwt_invalid");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/jwt-auth-e2e.test.ts` around lines 356 - 363, Update the
retired-key token assertions in the JWT authentication test to verify the
response error code is exactly jwt_invalid, in addition to the existing 401
status check. Use the response from chat and parse its body as needed before
asserting the code, while preserving the stale response consumption.

392-401: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the metrics assertions self-contained.

reason="jwt_expired" and result="allowed" only appear because earlier tests in this file happened to produce them. Minting one valid and one expired token inside this test before scraping removes the ordering dependency and keeps the assertions meaningful if the file is ever reordered or run with --shard.

As per coding guidelines, "Avoid explicit dependencies between tests and hidden execution order assumptions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/jwt-auth-e2e.test.ts` around lines 392 - 401, The metrics
test `auth decisions surface on aisix_auth_decisions_total` currently depends on
earlier tests to emit the expected labels. Before scraping metrics, create and
exercise one valid JWT and one expired JWT within this test, then retain
assertions for `method="jwt"`, `result="allowed"`, and `reason="jwt_expired"` so
the test is self-contained and order-independent.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-core/src/models/oidc_provider.rs`:
- Around line 52-56: Enforce uniqueness of enabled issuer values during both
file and etcd provider ingestion, using the OIDC provider loading/validation
paths around the issuer field. Reject or fail closed when multiple enabled
providers share the same issuer instead of allowing lowest-ID selection;
preserve normal matching when issuers are unique.

In `@crates/aisix-etcd/src/loader.rs`:
- Around line 285-296: Enforce unique jwt_subject values for OIDC providers in
the etcd loader: add a snapshot-wide duplicate check during the “oidc_providers”
loading path, alongside validate_and_parse and snapshot.oidc_providers
insertion. Update apply_put to reject or otherwise fail closed when a new
provider conflicts with an existing subject, ensuring ambiguous subjects cannot
reach key_for_subject and select an arbitrary provider.

In `@crates/aisix-proxy/src/jwt.rs`:
- Around line 673-689: Update fetch_json to reject responses whose
Content-Length already exceeds JWKS_MAX_BYTES, then consume the response body as
a stream while tracking accumulated bytes and aborting as soon as the cap is
exceeded; only parse JSON after the bounded stream completes, preserving the
existing error context for request, body-read, oversize, and JSON failures.

In `@crates/aisix-server/src/export/document.rs`:
- Around line 306-319: Update OIDC provider ingestion to reject jwks_uri values
containing URL userinfo or credential-bearing query parameters, while preserving
valid public URLs. In the oidc_providers export block using emit_entries, redact
legacy credential-bearing jwks_uri values whenever reveal_secrets is false;
never serialize tokens or authentication credentials, and retain non-sensitive
provider fields.

In `@schemas/resources/oidc_provider.schema.json`:
- Around line 11-18: Add "minItems": 1 to the array schema for
BoundClaimExpect::Any in the generated OIDC provider schema, preserving its
existing string-item and array structure so empty bound_claims values are
rejected.

In `@tests/e2e/src/cases/jwt-auth-e2e.test.ts`:
- Around line 128-135: Separate the rate-limit test’s credentials from the
shared agent-1 setup: seed a dedicated agent-ratelimited JWT subject/API key
with rpm: 2, and remove the rate limit from agent-1’s key. Update the rate-limit
test to authenticate as agent-ratelimited, while leaving the other agent-1 tests
independent of request order.

---

Nitpick comments:
In `@crates/aisix-obs/src/metrics.rs`:
- Around line 383-397: Update record_auth_decision to accept &'static str for
both method and reason, matching the bounded-cardinality contract and the
existing record_usage_event_emit signature. Keep the label normalization and
counter behavior unchanged, and ensure all current call sites continue passing
string literals.

In `@crates/aisix-proxy/src/error.rs`:
- Line 284: Update retry_after_secs() for ProxyError::JwksUnavailable to return
a retry delay matching JWKS_REFRESH_MIN_INTERVAL instead of None, while
preserving the existing 503 status mapping.

In `@crates/aisix-proxy/src/jwt.rs`:
- Around line 386-389: Update the comment above set_required_spec_claims in the
JWT validation setup to state that exp, iss, and aud are always required and
that omitting any of them causes rejection; keep the validation code unchanged.
- Around line 700-729: Remove the checked-in private key from the `TEST_RSA_PEM`
fixture to prevent secret-scanner findings. Generate the RSA keypair during test
setup and derive the corresponding JWK used by `TEST_JWKS` from its public key
so both fixtures remain synchronized; alternatively, add the
repository-supported inline `betterleaks` allow annotation directly for this
intentionally triaged fixture.

In `@tests/e2e/src/cases/jwt-auth-e2e.test.ts`:
- Around line 356-363: Update the retired-key token assertions in the JWT
authentication test to verify the response error code is exactly jwt_invalid, in
addition to the existing 401 status check. Use the response from chat and parse
its body as needed before asserting the code, while preserving the stale
response consumption.
- Around line 392-401: The metrics test `auth decisions surface on
aisix_auth_decisions_total` currently depends on earlier tests to emit the
expected labels. Before scraping metrics, create and exercise one valid JWT and
one expired JWT within this test, then retain assertions for `method="jwt"`,
`result="allowed"`, and `reason="jwt_expired"` so the test is self-contained and
order-independent.

In `@tests/e2e/src/harness/jwks-mock.ts`:
- Around line 136-139: Update the signing logic around signer.privateKey to pass
the existing KeyObject directly to sign(), removing the per-token PKCS#8 PEM
export and re-import. Remove the now-unused createPrivateKey import while
preserving the generated token format.
- Around line 168-170: Remove the unused createPublicKey re-export from the JWKS
mock module, along with its speculative explanatory comment. Keep the underlying
helper private and preserve the existing MockIdp test behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93f4e01b-ea1e-424e-a59e-cd35c9ba60b0

📥 Commits

Reviewing files that changed from the base of the PR and between 741f7bc and 57c3190.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • crates/aisix-core/src/bin/dump-schema.rs
  • crates/aisix-core/src/filesource/mod.rs
  • crates/aisix-core/src/filesource/tests.rs
  • crates/aisix-core/src/models/apikey.rs
  • crates/aisix-core/src/models/mod.rs
  • crates/aisix-core/src/models/oidc_provider.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-core/src/models/snapshot.rs
  • crates/aisix-etcd/src/loader.rs
  • crates/aisix-etcd/src/supervisor.rs
  • crates/aisix-obs/src/metrics.rs
  • crates/aisix-proxy/Cargo.toml
  • crates/aisix-proxy/src/auth.rs
  • crates/aisix-proxy/src/error.rs
  • crates/aisix-proxy/src/jwt.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-server/src/export/document.rs
  • schemas/resources/api_key.schema.json
  • schemas/resources/oidc_provider.schema.json
  • tests/e2e/src/cases/jwt-auth-e2e.test.ts
  • tests/e2e/src/harness/index.ts
  • tests/e2e/src/harness/jwks-mock.ts
  • tests/e2e/src/harness/seed.ts

Comment thread crates/aisix-core/src/models/oidc_provider.rs
Comment thread crates/aisix-etcd/src/loader.rs
Comment thread crates/aisix-proxy/src/jwt.rs Outdated
Comment thread crates/aisix-server/src/export/document.rs
Comment thread schemas/resources/oidc_provider.schema.json
Comment thread tests/e2e/src/cases/jwt-auth-e2e.test.ts Outdated
…y, JWKS rate limiting, hardening

Independent audit findings on the inbound OIDC/JWT path:

H1 (auth bypass) — cross-provider identity impersonation. key_for_subject
resolved jwt_subject against the whole api_key table, ignoring which
trust provider asserted it. With two providers enabled, provider B could
mint a token carrying provider A's subject and inherit A's key. Fixed by
namespacing the binding: ApiKey gains `jwt_provider` (the oidc_providers
name allowed to assert its jwt_subject), the lookup now matches
(jwt_provider, jwt_subject), and the filesource rejects jwt_subject set
without jwt_provider and enforces per-pair (not per-subject) uniqueness.

H2 (availability + amplification) — JWKS/discovery were fetched on every
request once the cache went stale: the 1s rate limiter only covered the
unknown-kid path. A down IdP added a 5s timeout to every authenticated
request and one outbound fetch per inbound JWT. The primary get_jwks path
and OIDC discovery now share the rate limiter and serve-stale, and
discovery tracks last_attempt so a down issuer isn't re-probed per
request.

M3 — attacker-controlled `kid`/`iss` were logged with Display (raw,
unescaped, uncapped) on every deny before verification: log injection +
flooding. Now logged with Debug (escapes control bytes) and truncated;
pre-verification reason classes drop to debug.

M4 — JWKS_MAX_BYTES was checked after buffering the whole body (no real
bound). Now enforced while streaming, with an early content-length reject.

M5 — a discovery-advertised jwks_uri was trusted verbatim (SSRF / trust
relocation). Now the document's issuer must match the configured issuer
(OIDC Discovery §4.3) and jwks_uri must stay on the issuer's origin.

M6 — the use/alg JWK filter was applied only on the no-kid branch; a
`use:enc` or wrong-alg key whose kid was named was accepted for
verification. Now filtered on both branches (RFC 7517 §4.2/§4.4).

M8 — the auth path cloned the whole apikeys/oidc_providers tables per
request (three Vec allocations + per-row Arc clones). Added
non-materialising ResourceTable::any / find_min_by_id and use them.

M9 — the API-key path logged a WARN per 401 for unknown_key (the scanner
probe shape), an unbounded log-volume regression. Dropped to debug, same
as the extractor's missing_credentials.

M10 — e2e now pins the headline invariants: no-fallback (a JWT-shaped
bearer that is also a valid key stays rejected as a JWT), alg-confusion
(HS256 rejected), no-kid single-key verify, JWKS caching (a burst does
not refetch per request), and cross-provider impersonation blocked.

L12/L13/L14 — recover poisoned JWKS-cache locks instead of propagating
the panic process-wide; pass the already-loaded snapshot into
authenticate_jwt; cap bearer length before the base64/JSON work.

Verified-correct findings (alg:none impossible, HS-with-JWKS blocked at
two layers, exp/iss/aud presence genuinely enforced, no peek/validate
split-brain, byte-exact issuer match) needed no change.

The paired CP change adds `jwt_provider` to the api_key surface and its
projection.

Fixes api7/AISIX-Cloud#1080

🤖 Generated with [Claude Code](https://claude.com/claude-code)
The no-kid test signed as agent-1 (rpm=2) and expected 200, but earlier
successful auths can exhaust that key's rate-limit window, so CI saw a
429. Sign as the unrestricted agent-2 key: the case verifies the no-kid
JWKS fall-through, not rate limiting.
…l-free JWKS URLs, schema minItems, test isolation

- Ambiguous issuer / (jwt_provider, jwt_subject) now fail closed instead
  of picking the lowest-id match: two enabled providers on one issuer, or
  two keys on one binding, carry different policy and neither can be
  chosen safely. Added ResourceTable::find_unique_by; the file loader also
  rejects duplicate enabled issuers at load. The CP's per-env unique
  indexes make this a defense-in-depth guard for etcd races.
- Reject credential-bearing issuer/jwks_uri (userinfo or a token query
  param) at file-load and at DP resolve time — JWKS material is public, so
  embedded credentials would only leak (e.g. via a snapshot export). Never
  stored, so nothing to redact downstream.
- bound_claims: inject minItems:1 into the BoundClaimExpect array branch
  (schemars drops the length constraint on an untagged enum variant), so
  the schema matches the model's non-empty contract.
- e2e: give the rate-limit test a dedicated rpm=2 identity (agent-rl) so
  the shared agent-1 key is no longer exhaustible by earlier requests —
  removes the coupling behind the earlier no-kid flake.

The streaming JWKS size cap CodeRabbit flagged was already fixed in the
prior security-review commit.
@jarvis9443
jarvis9443 merged commit b50c662 into main Jul 27, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the feat/jwt-oidc-auth branch July 27, 2026 15:21
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