This is an automated email from the ASF dual-hosted git repository.

numinnex pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 3c935261e feat(server-ng): verify trusted-issuer a2a JWT and expose 
SDK refresh (#3626)
3c935261e is described below

commit 3c935261ea71515ce68349e1c111fdd9a0876128
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Fri Jul 10 13:17:34 2026 +0200

    feat(server-ng): verify trusted-issuer a2a JWT and expose SDK refresh 
(#3626)
    
    The HTTP listener accepted only self-issued HS256 tokens and
    silently ignored [[http.jwt.trusted_issuers]]. Port the legacy
    JWKS client (RS256/EC, kid rotation) and verify external a2a
    tokens against trusted issuers, remapping sub to the configured
    user. Refresh of trusted-issuer tokens is rejected (they have
    their own lifecycle) and a trusted issuer mapped to root now
    fails at startup.
    
    Harden the legacy server's JWKS client with the same pre-auth
    amplification guard (per-issuer single-flight plus a 10s
    rate-limit / negative-cache window) so an unknown-key-id flood
    cannot fan out to one outbound fetch per request. Both shipped
    configs document the opt-in trusted_issuers block; legacy no
    longer ships an active example issuer.
    
    Also expose the SDK's dead HTTP refresh_access_token, fixing a
    read-guard self-deadlock in it, and cover both paths with
    integration tests.
---
 Cargo.lock                                         |   2 +
 .../src/harness/orchestrator/harness.rs            |  13 +
 core/integration/tests/sdk/http_refresh.rs         |  63 +++++
 core/integration/tests/sdk/mod.rs                  |   2 +
 core/integration/tests/server/a2a_jwt/jwt_tests.rs | 195 ++++++++++++-
 core/integration/tests/server/mod.rs               |   5 +-
 core/sdk/src/http/http_client.rs                   |  67 +++--
 core/sdk/src/http/http_transport.rs                |   6 +-
 core/server-ng/Cargo.toml                          |   1 +
 core/server-ng/LISTENER_SUPPORT_PLAN.md            | 286 -------------------
 core/server-ng/config.toml                         |  23 +-
 core/server-ng/src/http.rs                         |   9 +-
 core/server-ng/src/http/extractor.rs               |  44 +--
 core/server-ng/src/http/handlers.rs                |  16 +-
 .../src/http/jwt => server-ng/src/http}/jwks.rs    | 150 ++++++----
 core/server-ng/src/http/jwt.rs                     | 310 +++++++++++++++++++--
 core/server/Cargo.toml                             |   1 +
 core/server/config.toml                            |  23 +-
 core/server/src/http/jwt/jwks.rs                   |  86 ++++--
 19 files changed, 842 insertions(+), 460 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index d678d7145..e5ed364e9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11739,6 +11739,7 @@ dependencies = [
  "sysinfo 0.39.5",
  "tempfile",
  "thiserror 2.0.18",
+ "tokio",
  "toml 1.1.2+spec-1.1.0",
  "tower-http 0.7.0",
  "tracing",
@@ -11808,6 +11809,7 @@ dependencies = [
  "secrecy",
  "send_wrapper",
  "serde",
+ "serde_json",
  "server_common",
  "shard",
  "shard_allocator",
diff --git a/core/integration/src/harness/orchestrator/harness.rs 
b/core/integration/src/harness/orchestrator/harness.rs
index ece57d83c..31320235d 100644
--- a/core/integration/src/harness/orchestrator/harness.rs
+++ b/core/integration/src/harness/orchestrator/harness.rs
@@ -364,6 +364,19 @@ impl TestHarness {
         &self.servers
     }
 
+    /// Number of requests the trusted-issuer JWKS mock has served, or 0 when 
no
+    /// JWKS mock is configured. Lets a test bound the server's outbound JWKS
+    /// fetches (e.g. assert an unknown-`kid` flood does not amplify).
+    pub async fn jwks_request_count(&self) -> usize {
+        match &self.jwks_server {
+            Some(server) => server
+                .received_requests()
+                .await
+                .map_or(0, |reqs| reqs.len()),
+            None => 0,
+        }
+    }
+
     /// Get the number of server nodes (1 for single server, N for cluster).
     pub fn cluster_size(&self) -> usize {
         self.servers.len()
diff --git a/core/integration/tests/sdk/http_refresh.rs 
b/core/integration/tests/sdk/http_refresh.rs
new file mode 100644
index 000000000..98236fb38
--- /dev/null
+++ b/core/integration/tests/sdk/http_refresh.rs
@@ -0,0 +1,63 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! End-to-end coverage for the SDK HTTP client's `refresh_access_token`
+//! against a live server-ng listener: the reissued token must replace the one
+//! the client holds and keep it authenticated.
+
+use iggy::http::http_client::HttpClient;
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+#[iggy_harness]
+async fn 
given_logged_in_http_client_when_refreshing_should_swap_to_a_working_token(
+    harness: &TestHarness,
+) {
+    let addr = harness
+        .server()
+        .http_addr()
+        .expect("HTTP transport not configured on test server");
+    let client = HttpClient::new(&format!("http://{addr}";)).expect("build SDK 
HTTP client");
+
+    let logged_in = client
+        .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+        .await
+        .expect("login as root");
+    let login_token = logged_in.access_token.expect("login returns a token");
+
+    let refreshed = client
+        .refresh_access_token()
+        .await
+        .expect("refresh the access token");
+    let refreshed_token = refreshed.access_token.expect("refresh returns a 
token");
+
+    assert_eq!(
+        refreshed.user_id, logged_in.user_id,
+        "refresh must re-issue for the same user"
+    );
+    assert_ne!(
+        refreshed_token.token, login_token.token,
+        "refresh must mint a token distinct from the one it replaced"
+    );
+
+    // The client swapped its stored bearer to the reissued token; an authed
+    // call carries that stored token, so success proves the swap landed.
+    client
+        .get_stats()
+        .await
+        .expect("authed call after refresh must use the reissued token");
+}
diff --git a/core/integration/tests/sdk/mod.rs 
b/core/integration/tests/sdk/mod.rs
index 70b102c46..0cd484a18 100644
--- a/core/integration/tests/sdk/mod.rs
+++ b/core/integration/tests/sdk/mod.rs
@@ -17,6 +17,8 @@
 
 mod hello_world;
 #[cfg(feature = "vsr")]
+mod http_refresh;
+#[cfg(feature = "vsr")]
 mod mcp_parity;
 #[cfg(feature = "vsr")]
 mod messages;
diff --git a/core/integration/tests/server/a2a_jwt/jwt_tests.rs 
b/core/integration/tests/server/a2a_jwt/jwt_tests.rs
index 767372cb0..4d0b2b194 100644
--- a/core/integration/tests/server/a2a_jwt/jwt_tests.rs
+++ b/core/integration/tests/server/a2a_jwt/jwt_tests.rs
@@ -15,7 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use iggy::prelude::{GlobalPermissions, IggyClientBuilder, Permissions, 
UserStatus};
+use std::sync::Arc;
+
+use iggy::http::http_client::HttpClient;
+use iggy::prelude::{
+    GlobalPermissions, HttpClientConfig, IggyClientBuilder, Permissions, 
UserStatus,
+};
 use iggy_common::{StreamClient, UserClient};
 use integration::iggy_harness;
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
@@ -169,6 +174,52 @@ fn create_unknown_issuer_jwt() -> String {
     encode(&header, &claims, &encoding_key).unwrap()
 }
 
+/// Craft an algorithm-confusion token: it claims the trusted issuer and 
carries
+/// the real JWKS `kid`, but is HS256 (symmetric) and signed with an
+/// attacker-controlled secret the server never shares. The issuer publishes 
only
+/// an RSA key, so verifying an HMAC signature against it must fail closed 
rather
+/// than treat the RSA public-key bytes as an HMAC secret.
+fn create_algorithm_confusion_jwt() -> String {
+    let now = now_timestamp();
+    let claims = TestClaims {
+        jti: uuid::Uuid::now_v7().to_string(),
+        iss: TEST_ISSUER.to_string(),
+        aud: Audience::from(TEST_AUDIENCE),
+        sub: "external-a2a-user-123".to_string(),
+        exp: now + 3600,
+        iat: now,
+        nbf: now,
+    };
+
+    let mut header = Header::new(Algorithm::HS256);
+    header.kid = Some(TEST_KEY_ID.to_string());
+    let encoding_key = 
EncodingKey::from_secret(b"attacker-controlled-hmac-secret");
+
+    encode(&header, &claims, &encoding_key).unwrap()
+}
+
+/// Creates a valid-shape RS256 token stamped with an arbitrary `kid`. Used to
+/// drive the unknown-key-id path: the issuer is trusted but no JWKS entry
+/// matches, so verification reaches the fetch/rate-limit path and then fails.
+fn create_jwt_with_kid(kid: &str) -> String {
+    let now = now_timestamp();
+    let claims = TestClaims {
+        jti: uuid::Uuid::now_v7().to_string(),
+        iss: TEST_ISSUER.to_string(),
+        aud: Audience::from(TEST_AUDIENCE),
+        sub: "external-a2a-user-123".to_string(),
+        exp: now + 3600,
+        iat: now,
+        nbf: now,
+    };
+
+    let mut header = Header::new(Algorithm::RS256);
+    header.kid = Some(kid.to_string());
+    let encoding_key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY).unwrap();
+
+    encode(&header, &claims, &encoding_key).unwrap()
+}
+
 /// Create an IggyClient with the provided JWT token
 async fn create_client_with_jwt(http_addr: &str, token: String) -> 
iggy::prelude::IggyClient {
     IggyClientBuilder::new()
@@ -291,6 +342,107 @@ async fn test_a2a_jwt_unknown_issuer(harness: 
&TestHarness) {
     );
 }
 
+/// Test that an algorithm-confusion token is rejected: HS256-signed but 
claiming
+/// the trusted issuer and the real JWKS `kid`. The issuer's key set holds 
only an
+/// RSA key, so an HMAC signature must never verify against it - proving end 
to end
+/// that the RSA public key can never be misused as an HMAC secret.
+#[iggy_harness(
+    server(config_path = "tests/server/a2a_jwt/config.toml"),
+    jwks_server(store_path = 
"tests/server/a2a_jwt/wiremock/__files/jwks.json"),
+    seed = seed_a2a_user
+)]
+async fn test_a2a_jwt_algorithm_confusion(harness: &TestHarness) {
+    let server = harness
+        .all_servers()
+        .first()
+        .expect("server should be available");
+    let http_addr = server
+        .http_addr()
+        .expect("http address should be available");
+
+    // First prove the JWKS path resolves this issuer and kid: a valid RS256 
token
+    // carrying the same kid must authenticate, warming the RSA key into cache.
+    // Only then can the rejection below be attributed to the HMAC-vs-RSA
+    // algorithm check rather than an incidental JWKS-fetch failure, which 
would
+    // also surface as Unauthenticated and pass the assertion for the wrong 
reason.
+    let valid_client = create_client_with_jwt(&http_addr.to_string(), 
create_valid_jwt(3600)).await;
+    assert!(
+        valid_client.get_streams().await.is_ok(),
+        "valid RS256 token must authenticate (JWKS reachable, kid resolvable)"
+    );
+
+    let token = create_algorithm_confusion_jwt();
+    let client = create_client_with_jwt(&http_addr.to_string(), token).await;
+
+    // get_streams() should fail with Unauthenticated error
+    let result = client.get_streams().await;
+    assert!(
+        result.is_err(),
+        "algorithm-confusion token must be rejected, got {:?}",
+        result
+    );
+    let err = result.unwrap_err();
+    assert_eq!(
+        err.as_code(),
+        iggy::prelude::IggyError::Unauthenticated.as_code(),
+        "Expected Unauthenticated error, got {:?}",
+        err
+    );
+}
+
+/// Test that a valid trusted-issuer (A2A) token cannot be refreshed. The same
+/// token authenticates reads (see `test_a2a_jwt_valid_token`), but exchanging 
it
+/// for a self-issued token would mint a bearer outliving the external grant 
the
+/// issuer controls, so `POST /users/refresh-token` must reject it and issue
+/// nothing.
+#[iggy_harness(
+    server(config_path = "tests/server/a2a_jwt/config.toml"),
+    jwks_server(store_path = 
"tests/server/a2a_jwt/wiremock/__files/jwks.json"),
+    seed = seed_a2a_user
+)]
+async fn test_a2a_jwt_refresh_rejected(harness: &TestHarness) {
+    let server = harness
+        .all_servers()
+        .first()
+        .expect("server should be available");
+    let http_addr = server
+        .http_addr()
+        .expect("http address should be available");
+
+    // Pre-set the A2A token as the client's bearer. `HttpClient`'s retry
+    // middleware bridges the HTTP-listener warmup the readiness gate does not.
+    let client = HttpClient::create(Arc::new(HttpClientConfig {
+        api_url: format!("http://{}";, http_addr),
+        jwt: Some(create_valid_jwt(3600)),
+        ..HttpClientConfig::default()
+    }))
+    .expect("failed to build HTTP client");
+
+    // Prove the token authenticates a normal request first: a successful read
+    // runs the same trusted-issuer JWKS verify, so the refresh rejection 
below is
+    // the trusted-issuer refusal and not an incidental JWKS-fetch failure 
(both
+    // collapse to Unauthenticated on the wire).
+    assert!(
+        client.get_streams().await.is_ok(),
+        "A2A token must authenticate a read before refresh is attempted"
+    );
+
+    // An `Ok` would mean a self-issued token was handed back - the regression.
+    let result = client.refresh_access_token().await;
+    assert!(
+        result.is_err(),
+        "trusted-issuer token must not be refreshable, got {:?}",
+        result
+    );
+    let err = result.unwrap_err();
+    assert_eq!(
+        err.as_code(),
+        iggy::prelude::IggyError::Unauthenticated.as_code(),
+        "Expected Unauthenticated error, got {:?}",
+        err
+    );
+}
+
 /// Test that missing JWT token results in authentication failure
 #[iggy_harness(
     server(config_path = "tests/server/a2a_jwt/config.toml"),
@@ -327,3 +479,44 @@ async fn test_a2a_jwt_missing_token(harness: &TestHarness) 
{
         err
     );
 }
+
+/// A token that names the trusted issuer but carries an unknown `kid` reaches 
the
+/// JWKS fetch path pre-signature. Replaying it must not amplify into one 
upstream
+/// fetch per request: the per-issuer rate-limit / negative-cache window 
collapses
+/// an unknown-key-id flood to at most one outbound JWKS request.
+#[iggy_harness(
+    server(config_path = "tests/server/a2a_jwt/config.toml"),
+    jwks_server(store_path = "tests/server/a2a_jwt/wiremock/__files/jwks.json")
+)]
+async fn test_a2a_jwt_unknown_kid_does_not_amplify_jwks_fetch(harness: 
&TestHarness) {
+    let server = harness
+        .all_servers()
+        .first()
+        .expect("server should be available");
+    let http_addr = server
+        .http_addr()
+        .expect("http address should be available");
+
+    // The same unknown kid replayed: each request is a cache miss that 
classifies
+    // the trusted issuer and would fetch its JWKS on every request if 
unbounded.
+    let token = create_jwt_with_kid("novel-unknown-kid");
+    for _ in 0..8 {
+        let client = create_client_with_jwt(&http_addr.to_string(), 
token.clone()).await;
+        let result = client.get_streams().await;
+        assert!(
+            result.is_err(),
+            "unknown-kid token must be rejected, got {:?}",
+            result
+        );
+    }
+
+    // Exactly one fetch: the first miss reads the issuer's key set, and every
+    // replay inside the refresh window is then served as a known-absent kid
+    // without touching the network. Without the fix this would be one fetch 
per
+    // request. The `== 1` also proves the path was exercised (not a vacuous 
0).
+    let fetches = harness.jwks_request_count().await;
+    assert_eq!(
+        fetches, 1,
+        "unknown-kid replay must collapse to a single JWKS fetch, got 
{fetches}"
+    );
+}
diff --git a/core/integration/tests/server/mod.rs 
b/core/integration/tests/server/mod.rs
index 90e2e58bf..4e88fb969 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -15,9 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-// a2a_jwt exercises trusted-issuer (JWKS) tokens; server-ng's HTTP JWT
-// verifier has no trusted-issuer path.
-#[cfg(not(feature = "vsr"))]
+// a2a_jwt exercises trusted-issuer (JWKS) tokens; both the legacy verifier and
+// server-ng's ported trusted-issuer path verify them.
 mod a2a_jwt;
 mod cg;
 // Flush (FLUSH_UNSAVED_BUFFER) has no server-ng primitive; it must deny typed.
diff --git a/core/sdk/src/http/http_client.rs b/core/sdk/src/http/http_client.rs
index f04c355f0..5cd4bb413 100644
--- a/core/sdk/src/http/http_client.rs
+++ b/core/sdk/src/http/http_client.rs
@@ -233,30 +233,6 @@ impl HttpTransport for HttpClient {
         !token.is_empty()
     }
 
-    /// Refresh the access token using the current access token.
-    // TODO(hubcio): method `refresh_access_token` is never used
-    async fn _refresh_access_token(&self) -> Result<(), IggyError> {
-        let token = self.access_token.read().await;
-        if token.is_empty() {
-            return Err(IggyError::AccessTokenMissing);
-        }
-
-        let command = _RefreshToken {
-            token: token.to_owned(),
-        };
-        let response = self.post("/users/refresh-token", &command).await?;
-        let identity_info: IdentityInfo = response
-            .json()
-            .await
-            .map_err(|_| IggyError::InvalidJsonResponse)?;
-        if identity_info.access_token.is_none() {
-            return Err(IggyError::JwtMissing);
-        }
-
-        self.set_token_from_identity(&identity_info).await?;
-        Ok(())
-    }
-
     /// Set the access token.
     async fn set_access_token(&self, token: Option<String>) {
         let mut current_token = self.access_token.write().await;
@@ -321,6 +297,46 @@ impl HttpClient {
         ))
     }
 
+    /// Present the stored access token to `POST /users/refresh-token`, then
+    /// swap it for the reissued one. Returns the new identity so the caller 
can
+    /// schedule the next refresh from `IdentityInfo.access_token.expiry`
+    /// (unix seconds). Scheduling is the caller's job: no auto-refresh or
+    /// retry-on-401 happens anywhere in the request path.
+    ///
+    /// Server semantics differ and the caller must account for it:
+    /// - Legacy server: one-shot. The presented token is revoked as it is
+    ///   consumed, so a concurrent in-flight request still carrying the old
+    ///   token may fail with 401.
+    /// - server-ng: stateless. The old token stays valid until its natural
+    ///   expiry; refreshing never revokes it.
+    pub async fn refresh_access_token(&self) -> Result<IdentityInfo, 
IggyError> {
+        // Release the read guard before `set_token_from_identity` takes the
+        // write guard on the same lock, otherwise the reissue self-deadlocks.
+        let current_token = {
+            let token = self.access_token.read().await;
+            if token.is_empty() {
+                return Err(IggyError::AccessTokenMissing);
+            }
+            token.to_owned()
+        };
+
+        let response = self
+            .post(
+                "/users/refresh-token",
+                &RefreshToken {
+                    token: current_token,
+                },
+            )
+            .await?;
+        let identity_info: IdentityInfo = response
+            .json()
+            .await
+            .map_err(|_| IggyError::InvalidJsonResponse)?;
+
+        self.set_token_from_identity(&identity_info).await?;
+        Ok(identity_info)
+    }
+
     async fn handle_response(response: Response) -> Result<Response, 
IggyError> {
         let status = response.status();
         match status.is_success() {
@@ -357,12 +373,11 @@ impl HttpClient {
 }
 
 #[derive(Debug, Serialize)]
-struct _RefreshToken {
+struct RefreshToken {
     token: String,
 }
 
 /// Unit tests for HttpClient.
-/// Currently only tests for "from_connection_string()" are implemented.
 /// TODO: Add complete unit tests for HttpClient.
 #[cfg(test)]
 mod tests {
diff --git a/core/sdk/src/http/http_transport.rs 
b/core/sdk/src/http/http_transport.rs
index b651009d6..428acda38 100644
--- a/core/sdk/src/http/http_transport.rs
+++ b/core/sdk/src/http/http_transport.rs
@@ -72,13 +72,9 @@ pub trait HttpTransport {
     /// Returns true if the client is authenticated.
     async fn is_authenticated(&self) -> bool;
 
-    /// Refresh the access token using the provided refresh token.
-    //method `refresh_access_token` is never used
-    async fn _refresh_access_token(&self) -> Result<(), IggyError>;
-
     /// Set the access token.
     async fn set_access_token(&self, token: Option<String>);
 
-    /// Set the access token and refresh token from the provided identity.
+    /// Set the access token from the provided identity.
     async fn set_token_from_identity(&self, identity: &IdentityInfo) -> 
Result<(), IggyError>;
 }
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index 9869a1b96..26f397d00 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -146,6 +146,7 @@ rustls-pemfile = { workspace = true }
 secrecy = { workspace = true }
 send_wrapper = { workspace = true }
 serde = { workspace = true }
+serde_json = { workspace = true }
 server_common = { workspace = true }
 shard = { workspace = true }
 shard_allocator = { workspace = true }
diff --git a/core/server-ng/LISTENER_SUPPORT_PLAN.md 
b/core/server-ng/LISTENER_SUPPORT_PLAN.md
deleted file mode 100644
index e2c4210e0..000000000
--- a/core/server-ng/LISTENER_SUPPORT_PLAN.md
+++ /dev/null
@@ -1,286 +0,0 @@
-# `server-ng` Listener Support Plan
-
-## Scope
-
-Add `server-ng` bootstrap support for the `message_bus` client listener planes 
that were added upstream:
-
-- QUIC
-- WebSocket
-- TCP-TLS
-
-`HTTP` remains out of scope for this change.
-
-This plan is based on the current rebased tree, not on the intended end state.
-
-## What Exists Today
-
-### `server-ng` is still effectively TCP-only
-
-In [`src/bootstrap.rs`](./src/bootstrap.rs), `RunServerNg::run()` builds only:
-
-- `AcceptedReplicaFn`
-- `AcceptedClientFn`
-
-and then calls `start_tcp_runtime(...)`.
-
-The runtime split is:
-
-- `start_single_node_tcp_runtime(...)`
-- `start_cluster_tcp_runtime(...)`
-
-Both branches only wire plain TCP listeners today.
-
-### The new `message_bus` entry point already supports the extra planes
-
-[`core/message_bus/src/replica/io.rs`](../message_bus/src/replica/io.rs) 
exposes:
-
-- `replica_io::start_on_shard_zero(...)`
-
-That function can bind:
-
-- replica TCP
-- client TCP
-- WS
-- QUIC
-- TCP-TLS
-- WSS
-
-and returns `BoundPlanes { replica, client, ws, quic, tcp_tls, wss }`.
-
-`server-ng` is still calling `start_on_shard_zero_default(...)`, which 
hardcodes all optional listener planes to `None`.
-
-### `server-ng` is not yet using the `ServerNgConfig` / `message_bus` config 
path
-
-This is the most important bootstrap mismatch in the current tree:
-
-- `load_config()` still loads `configs::server::ServerConfig`
-- `bootstrap()` still constructs the bus with `IggyMessageBus::new(SHARD_ID)`
-
-That means:
-
-- the `[message_bus]` config section is not being consumed
-- bus WS tuning is ignored
-- bus reconnect / handshake / queue tunables are still defaulted
-- QUIC tuning from `ServerNgConfig` is not flowing into the bus constructor
-
-Because `IggyMessageBus::with_config(...)` expects 
`configs::server_ng::ServerNgConfig`, listener support should be implemented 
together with config wiring.
-
-## Recommended Implementation Shape
-
-### 1. Move `server-ng` bootstrap to `ServerNgConfig`
-
-Update `core/server-ng` to load and pass around 
`configs::server_ng::ServerNgConfig` instead of the legacy `ServerConfig`.
-
-Why this should happen first:
-
-- `message_bus` production constructor is 
`IggyMessageBus::with_config(shard_id, cfg)`
-- the QUIC listener needs bus-side QUIC tuning
-- the WS listener needs bus-side WS handshake config
-- the repo already has a dedicated `server_ng_config` schema for exactly this 
wiring
-
-Expected touch points:
-
-- [`src/bootstrap.rs`](./src/bootstrap.rs)
-- [`src/config_writer.rs`](./src/config_writer.rs)
-- any `server-ng` call sites currently typed to `ServerConfig`
-
-### 2. Replace `IggyMessageBus::new(...)` with 
`IggyMessageBus::with_config(...)`
-
-In `bootstrap()`, build the bus from the validated `ServerNgConfig`.
-
-This makes the listener work use the correct runtime tunables immediately:
-
-- `message_bus.handshake_grace`
-- `message_bus.close_grace`
-- `message_bus.close_peer_timeout`
-- `message_bus.reconnect_period`
-- `message_bus.ws_*`
-- `quic.*` transport tuning
-
-### 3. Generalize the TCP-only bootstrap helpers into transport bootstrap
-
-The current names and signatures are too narrow:
-
-- `resolve_tcp_topology(...)`
-- `start_tcp_runtime(...)`
-- `start_cluster_tcp_runtime(...)`
-- `start_single_node_tcp_runtime(...)`
-
-Recommended refactor:
-
-- keep `TcpTopology` for replica/client TCP addressing if convenient
-- add a listener-settings helper that derives all optional listen addresses 
from config
-- rename runtime helpers to reflect multi-transport startup rather than 
TCP-only startup
-
-At minimum, bootstrap needs to derive:
-
-- replica TCP address
-- client TCP address
-- optional WS listen address from `[websocket]`
-- optional QUIC listen address from `[quic]`
-- optional TCP-TLS listen address
-
-Important detail:
-
-- TCP-TLS does not have its own address section
-- it is derived from `[tcp].address` when `[tcp.tls].enabled = true`
-
-Same pattern exists for WSS via `[websocket]` + `[websocket.tls]`, even if WSS 
is not part of the first implementation slice.
-
-### 4. In cluster mode, switch from `start_on_shard_zero_default(...)` to 
`start_on_shard_zero(...)`
-
-This is the main listener wiring change.
-
-Instead of:
-
-- `replica_io::start_on_shard_zero_default(...)`
-
-call:
-
-- `replica_io::start_on_shard_zero(...)`
-
-and populate the optional planes based on config:
-
-- `ws_listen_addr`
-- `quic_listen_addr`
-- `tcp_tls_listen_addr`
-- optionally `wss_listen_addr`
-
-Also provide the matching accept callbacks:
-
-- `AcceptedWsClientFn`
-- `AcceptedQuicClientFn`
-- `AcceptedTlsClientFn`
-- optionally `AcceptedWssClientFn`
-
-### 5. Add transport-specific accepted-client closures in `server-ng`
-
-`server-ng` already has:
-
-- `make_local_client_accept_fn(...)`
-
-Add transport-specific variants that mint `client_id` the same way and then 
install through the correct `message_bus::installer` entry point.
-
-Expected closures:
-
-- plain TCP: `installer::install_client_tcp(...)` or existing wrapper path
-- WS: reuse the pre-upgrade local path, most likely by duping the accepted fd 
and calling `ConnectionInstaller::install_client_ws_fd(...)`
-- QUIC: `installer::install_client_quic(...)`
-- TCP-TLS: `installer::install_client_tcp_tls(...)`
-
-Important nuance for WS:
-
-- upstream `message_bus` docs describe WS as a pre-upgrade TCP accept path 
that can be fd-shipped to another shard
-- `server-ng` is single-shard today, so it can terminate WS locally on shard 0
-- the accepted callback receives raw `TcpStream`, so 
`installer::install_client_ws(...)` is not directly callable there because it 
expects a post-upgrade `WebSocketStream`
-- the clean reuse path is to keep using the existing pre-upgrade installer 
flow (`install_client_ws_fd(...)`) locally on shard 0 rather than inventing a 
second WS handshake path in `server-ng`
-
-Important nuance for QUIC / TCP-TLS:
-
-- these are shard-0 terminal by design in `message_bus`
-- do not try to thread them through a cross-shard setup path
-
-### 6. Add credential-loading helpers for TCP-TLS and QUIC
-
-Bootstrap needs to construct:
-
-- `message_bus::TlsServerCredentials`
-- `message_bus::replica_io::QuicServerCredentials`
-
-Reuse existing primitives where possible:
-
-- `message_bus::transports::tls::load_pem(...)`
-- `message_bus::transports::tls::self_signed_for_loopback()`
-- `message_bus::transports::tls::install_default_crypto_provider()`
-
-For QUIC, mirror the existing legacy-server behavior:
-
-- self-signed when configured and files are absent
-- otherwise load cert/key from configured paths
-
-This logic should live in `server-ng`, not be duplicated inside `message_bus`.
-
-### 7. Single-node mode should also use the same transport bootstrap path
-
-Right now single-node startup bypasses `replica_io` entirely and binds plain 
TCP directly via `client_listener::bind(...)`.
-
-That should be replaced with one transport-aware path so cluster and 
non-cluster mode do not diverge on feature support.
-
-Recommended shape:
-
-- cluster mode: bind replica + client planes through 
`replica_io::start_on_shard_zero(...)`
-- single-node mode: either
-  - reuse `start_on_shard_zero(...)` with a degenerate one-node topology, or
-  - add a small `client_planes_only(...)` helper if the replica-plane 
assumptions make that cleaner
-
-The important part is to avoid maintaining separate TCP-only and 
multi-transport startup logic.
-
-## `current_config.toml` Follow-Up
-
-[`src/config_writer.rs`](./src/config_writer.rs) currently writes only:
-
-- `tcp.address`
-- `cluster.nodes[*].ports.tcp`
-- `cluster.nodes[*].ports.tcp_replica`
-
-That is insufficient once new listeners are enabled.
-
-Extend it to write the effective bound addresses for:
-
-- `quic.address`
-- `websocket.address`
-
-and update cluster node ports when relevant:
-
-- `ports.quic`
-- `ports.websocket`
-
-For TCP-TLS there is no separate top-level address field today, so there may 
be nothing new to serialize beyond the shared TCP address unless the config 
model changes.
-
-## Error Handling Changes
-
-[`src/server_error.rs`](./src/server_error.rs) still exposes:
-
-- `StartTcpListeners`
-
-That name is too narrow after this change. Rename or replace it with something 
transport-neutral, for example:
-
-- `StartListeners`
-- `StartTransportListeners`
-
-Also expect new bootstrap errors for:
-
-- TLS credential loading
-- QUIC credential loading
-- invalid derived listener configuration
-
-## Proposed Execution Order
-
-1. Switch `server-ng` from `ServerConfig` to `ServerNgConfig`.
-2. Construct the bus with `IggyMessageBus::with_config(...)`.
-3. Add credential-loading helpers for TLS-family and QUIC listeners.
-4. Introduce transport-specific accepted-client closures.
-5. Replace `start_on_shard_zero_default(...)` with `start_on_shard_zero(...)` 
in cluster mode.
-6. Unify single-node startup with the same multi-transport bootstrap path.
-7. Extend `write_current_config(...)` to include QUIC / WS bound addresses.
-8. Rename the TCP-specific listener-startup error variant and add any new 
bootstrap errors.
-
-## Test Plan
-
-### Compile and unit-level checks
-
-- `cargo check -p server-ng`
-- `cargo check -p message_bus`
-- `cargo check -p integration`
-
-## First Implementation Slice
-
-To keep the change set controlled, the first slice should be:
-
-1. move to `ServerNgConfig`
-2. wire `IggyMessageBus::with_config(...)`
-3. add QUIC / WS / TCP-TLS listener startup in cluster mode
-4. mirror the same startup in single-node mode
-5. update `current_config.toml` for QUIC and WS
-
-WSS can be added immediately after if desired, because the bootstrap pattern 
is the same once the transport-aware startup path exists.
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index 92638aa18..bd54c661d 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -126,11 +126,24 @@ decoding_secret = ""
 # `false` means the secret is in plain text.
 use_base64_secret = false
 
-# Trusted issuers for A2A (Application-to-Application) authentication
-[[http.jwt.trusted_issuers]]
-issuer = "test-issuer"
-jwks_url = "http://127.0.0.1:8081/.well-known/jwks.json";
-audience = "iggy.apache.org"
+# Trusted issuers for A2A (Application-to-Application) authentication. Opt-in:
+# with none configured the listener accepts only self-issued HS256 tokens.
+# `issuer`, `audience` and `jwks_url` are required per entry; `user_id` is
+# optional but defaults to 0 (root), which is rejected - set it to the non-zero
+# iggy user every token from that issuer is remapped onto.
+#
+# Operational note: enabling an issuer opens an outbound JWKS fetch that is
+# reachable before a token's signature is verified - a token naming this issuer
+# with an unknown key id can trigger a fetch to `jwks_url`. The target is fixed
+# (not attacker-chosen); concurrent misses coalesce onto one fetch and repeats
+# are rate-limited to at most one outbound request per issuer per short window,
+# so an unknown-key-id flood cannot amplify. The same window bounds how 
quickly a
+# freshly rotated signing key is picked up.
+# [[http.jwt.trusted_issuers]]
+# issuer = "test-issuer"
+# jwks_url = "http://127.0.0.1:8081/.well-known/jwks.json";
+# audience = "iggy.apache.org"
+# user_id = 1
 
 # Metrics configuration for HTTP.
 [http.metrics]
diff --git a/core/server-ng/src/http.rs b/core/server-ng/src/http.rs
index 9ebace6e4..2593c4dad 100644
--- a/core/server-ng/src/http.rs
+++ b/core/server-ng/src/http.rs
@@ -17,15 +17,16 @@
 
 //! Shard-0 HTTP/REST listener. This root binds the listener and assembles the
 //! router; the rest is split across submodules: the `state` bridge and axum
-//! `State`, the bearer `extractor` and `jwt` issuer, the route `handlers`, the
-//! `reads` gates, the `submit` write paths, `wire` request mapping,
-//! partition-write `admission`, committed-reply `reply` decoding, the 
rejection
-//! `error` types, and per-credential `session` state.
+//! `State`, the bearer `extractor`, the `jwt` issuer and its `jwks` resolver,
+//! the route `handlers`, the `reads` gates, the `submit` write paths, `wire`
+//! request mapping, partition-write `admission`, committed-reply `reply`
+//! decoding, the rejection `error` types, and per-credential `session` state.
 
 mod admission;
 mod error;
 mod extractor;
 mod handlers;
+mod jwks;
 mod jwt;
 mod reads;
 mod reply;
diff --git a/core/server-ng/src/http/extractor.rs 
b/core/server-ng/src/http/extractor.rs
index 2287ef012..bcd523658 100644
--- a/core/server-ng/src/http/extractor.rs
+++ b/core/server-ng/src/http/extractor.rs
@@ -61,12 +61,16 @@ impl FromRequestParts<HttpState> for Authenticated {
     ) -> Result<Self, Self::Rejection> {
         let bearer = bearer_token(parts)?;
 
-        let (key, user_id, expiry) = resolve_credential(state, bearer)?;
-
-        // `resolve_session` is `Rc`-based and `!Send`, yet axum requires this
-        // extractor future to be `Send`. `SendWrapper` bridges the gap: sound
-        // because compio pins the future to shard 0's thread - the only thread
-        // that ever touches the session table (mirrors legacy 
`HttpSafeShard`).
+        // Both `resolve_credential` (its JWT verify may await a `!Send` JWKS
+        // fetch through cyper) and `resolve_session` (`Rc`-based, `!Send`) 
must
+        // run, yet axum requires this extractor future to be `Send`.
+        // `SendWrapper` bridges each: sound because compio pins the future to
+        // shard 0's thread - the only thread the JWKS client and session table
+        // ever run on (mirrors legacy `HttpSafeShard`). Neither future holds a
+        // `RefCell` borrow or `DashMap` guard across the `.await` (each such
+        // critical section is synchronous), so a cooperatively-scheduled 
sibling
+        // task on this thread never observes a borrowed session table.
+        let (key, user_id, expiry) = 
SendWrapper::new(resolve_credential(state, bearer)).await?;
         let session = SendWrapper::new(state.resolve_session(key, user_id, 
expiry)).await?;
         Ok(Self {
             session: SendWrapper::new(session),
@@ -74,8 +78,9 @@ impl FromRequestParts<HttpState> for Authenticated {
     }
 }
 
-/// Read-only caller identity for a protected read route: the authenticated
-/// user id and nothing else.
+/// Read-only caller identity for a protected read route: the authenticated 
user
+/// id plus the request path/query used for follower redirects, and no minted 
VSR
+/// session.
 ///
 /// Unlike [`Authenticated`], it verifies the bearer WITHOUT minting or
 /// Registering a VSR session. Reads are served from the local metadata STM and
@@ -103,10 +108,13 @@ impl FromRequestParts<HttpState> for Identity {
 
         // Verify only. The session key and expiry `resolve_credential` also
         // returns feed the write path's session table; a read discards them.
-        // No `.await` and no session borrow here, so the extractor future 
needs
-        // no `SendWrapper` bridge (contrast [`Authenticated`], which awaits 
the
-        // `!Send` `resolve_session`).
-        let (_key, user_id, _expiry) = resolve_credential(state, bearer)?;
+        // The verify is `!Send` (a trusted-issuer JWT may await a JWKS fetch),
+        // so bridge it with `SendWrapper` - sound only because compio pins 
this
+        // future to shard 0's single thread, the only thread the JWKS client
+        // ever runs on (mirrors legacy `HttpSafeShard`). It holds no `RefCell`
+        // borrow or `DashMap` guard across the `.await`, so a sibling task
+        // scheduled on this thread meanwhile never observes a borrowed cell.
+        let (_key, user_id, _expiry) = 
SendWrapper::new(resolve_credential(state, bearer)).await?;
         let path_and_query = parts
             .uri
             .path_and_query()
@@ -137,10 +145,14 @@ fn bearer_token(parts: &Parts) -> Result<&str, IggyError> 
{
 /// the bearer is the documented fallback, keyed by the same 256-bit BLAKE3
 /// digest (`PersonalAccessToken::hash_token`) Iggy already indexes PATs by, so
 /// the key is stable and collision-free while the raw secret never enters the
-/// table. Both checks are local and synchronous, so no borrow is held across 
an
-/// await here.
-fn resolve_credential(state: &HttpState, bearer: &str) -> Result<(String, u32, 
u64), AuthError> {
-    if let Ok(claims) = state.jwt.decode(bearer)
+/// table. The JWT verify is `async` and `!Send` (a trusted-issuer token may
+/// fetch the issuer's JWKS on a cache miss), so both call sites drive it 
inside
+/// a `SendWrapper`; the PAT check is local and synchronous.
+async fn resolve_credential(
+    state: &HttpState,
+    bearer: &str,
+) -> Result<(String, u32, u64), AuthError> {
+    if let Ok(claims) = state.jwt.decode(bearer).await
         && let Ok(user_id) = claims.sub.parse::<u32>()
     {
         return Ok((
diff --git a/core/server-ng/src/http/handlers.rs 
b/core/server-ng/src/http/handlers.rs
index 6b6a0f2b7..a796ff7c2 100644
--- a/core/server-ng/src/http/handlers.rs
+++ b/core/server-ng/src/http/handlers.rs
@@ -220,7 +220,10 @@ pub(in crate::http) async fn refresh_token(
     if command.token.is_empty() {
         return Err(IggyError::Unauthenticated.into());
     }
-    let claims = state.jwt.decode(&command.token)?;
+    // Refresh rejects trusted-issuer tokens (they keep their own lifecycle);
+    // `decode_for_refresh` is `!Send` (a trusted-issuer token may await a JWKS
+    // fetch), so bridge it like every other shard-0 path (see 
[`logout_user`]).
+    let claims = 
SendWrapper::new(state.jwt.decode_for_refresh(&command.token)).await?;
     let user_id = claims
         .sub
         .parse::<u32>()
@@ -945,7 +948,7 @@ pub(in crate::http) async fn delete_partitions(
 /// the path.
 ///
 /// `DeleteSegments` is not itself a consensus op: [`submit_write`] carries it
-/// through [`submit_gated`], which resolves it to the `TruncatePartition` that
+/// through `submit_gated`, which resolves it to the `TruncatePartition` that
 /// commits the trim. RBAC (`delete_segments`) is enforced in-apply on that
 /// truncate, like the sibling topic writes.
 pub(in crate::http) async fn delete_segments(
@@ -1316,7 +1319,8 @@ pub(in crate::http) async fn delete_cg(
 ///
 /// The plaintext password rides the JSON body; [`submit_write`] hashes it on
 /// shard 0 before the request enters consensus (see
-/// [`maybe_rewrite_user_password_request`]), so no plaintext is ever 
replicated.
+/// [`crate::users::maybe_rewrite_user_password_request`]), so no plaintext is
+/// ever replicated.
 pub(in crate::http) async fn create_user(
     State(state): State<HttpState>,
     identity: Authenticated,
@@ -1397,8 +1401,8 @@ pub(in crate::http) async fn delete_user(
 /// `PUT /users/{user_id}/password`: change a user's password. Returns 204.
 ///
 /// Both passwords ride the JSON body in plaintext. On shard 0, before the op
-/// enters consensus, [`maybe_rewrite_user_password_request`] hashes the new
-/// password and strips the current one (so neither plaintext is ever
+/// enters consensus, [`crate::users::maybe_rewrite_user_password_request`] 
hashes
+/// the new password and strips the current one (so neither plaintext is ever
 /// replicated), and verifies `current_password` against the target's stored
 /// hash. A wrong current password is not denied pre-consensus: the op still
 /// commits, carrying an empty new-password hash the replicated apply turns 
into
@@ -1483,7 +1487,7 @@ pub(in crate::http) async fn get_pats(
 /// legacy server returns, with HTTP 200.
 ///
 /// The raw token is non-deterministic and secret, so it must never enter
-/// consensus: [`rewrite_pat_request_for_user`] (invoked inside
+/// consensus: [`crate::pat::rewrite_pat_request_for_user`] (invoked inside
 /// [`submit_committed`]) mints it on shard 0 and replicates only its hash, so 
a
 /// successful committed reply body is empty. [`build_raw_pat_reply`] then 
splices
 /// the raw secret back into that reply locally, using the confirmed commit
diff --git a/core/server/src/http/jwt/jwks.rs b/core/server-ng/src/http/jwks.rs
similarity index 62%
copy from core/server/src/http/jwt/jwks.rs
copy to core/server-ng/src/http/jwks.rs
index 591d0a707..d3de9931f 100644
--- a/core/server/src/http/jwt/jwks.rs
+++ b/core/server-ng/src/http/jwks.rs
@@ -15,18 +15,37 @@
 // specific language governing permissions and limitations
 // under the License.
 
+//! JWKS key resolver for trusted-issuer (A2A) JWT verification.
+//!
+//! Ported from `server::http::jwt::jwks`. Keys are fetched lazily per
+//! `{issuer, kid}` and cached in a `DashMap`; a refresh re-reads the issuer's
+//! key set and evicts cached kids no longer present (rotation cleanup).
+
+use std::collections::HashSet;
+use std::hash::Hash;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
 use dashmap::DashMap;
 use iggy_common::IggyError;
 use jsonwebtoken::DecodingKey;
 use serde::Deserialize;
-use std::hash::Hash;
-use strum::{Display, EnumString};
+use strum::EnumString;
+use tokio::sync::Mutex;
+
+/// Minimum wall-clock gap between outbound JWKS fetches for one trusted 
issuer.
+/// Inside this window a cache miss is authoritative: the issuer's key set was
+/// just read, so an absent `kid` is genuinely absent and no fetch is issued.
+/// This bounds a pre-auth caller replaying unknown `kid`s to at most one
+/// outbound request per issuer per window; the trade is that a freshly rotated
+/// key is only discoverable once the window elapses.
+const JWKS_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(10);
 
 thread_local! {
-    // cyper 0.9's `Client` is `!Send`/`!Sync` (`Rc`-backed) and `new()`
-    // now returns a `Result`, so it can no longer be a global `OnceLock`.
-    // compio is thread-per-core; keep one client per thread. The `Rc`
-    // inner makes cloning cheap, so callers take an owned handle.
+    // cyper's `Client` is `!Send`/`!Sync` (`Rc`-backed) and `new()` returns a
+    // `Result`, so it cannot be a global `OnceLock`. compio is 
thread-per-core;
+    // keep one client per thread. The `Rc` inner makes cloning cheap, so 
callers
+    // take an owned handle.
     static HTTP_CLIENT: cyper::Client =
         cyper::Client::new().expect("failed to build cyper HTTP client for 
JWKS");
 }
@@ -36,22 +55,18 @@ fn get_http_client() -> cyper::Client {
 }
 
 /// JWK key type enumeration
-#[derive(Debug, Clone, Copy, Display, EnumString, Deserialize, PartialEq, Eq)]
-#[strum(serialize_all = "UPPERCASE")]
+#[derive(Debug, Clone, Copy, Deserialize)]
 #[serde(rename_all = "UPPERCASE")]
 enum JwkKeyType {
     /// RSA key type
-    #[strum(serialize = "RSA")]
     Rsa,
     /// EC (Elliptic Curve) key type
-    #[strum(serialize = "EC")]
     Ec,
 }
 
 /// EC curve type enumeration
-#[derive(Debug, Clone, Copy, Display, EnumString, Deserialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, EnumString)]
 #[strum(serialize_all = "UPPERCASE")]
-#[serde(rename_all = "UPPERCASE")]
 enum EcCurve {
     /// P-256 curve
     #[strum(serialize = "P-256")]
@@ -86,56 +101,75 @@ struct CacheKey {
     kid: String,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
 pub struct JwksClient {
     cache: DashMap<CacheKey, DecodingKey>,
-}
-
-impl Default for JwksClient {
-    fn default() -> Self {
-        Self {
-            cache: DashMap::new(),
-        }
-    }
+    /// Per-issuer single-flight and rate-limit guard. The async mutex 
serialises
+    /// refresh attempts for one issuer so a burst of concurrent misses 
collapses
+    /// onto a single fetch; the inner instant is when that issuer was last
+    /// fetched and gates [`JWKS_REFRESH_MIN_INTERVAL`]. Keyed only by issuer
+    /// (operator-configured, bounded), never by the attacker-controlled `kid`.
+    refresh_guards: DashMap<String, Arc<Mutex<Option<Instant>>>>,
 }
 
 impl JwksClient {
-    pub async fn get_key(&self, issuer: &str, jwks_url: &str, kid: &str) -> 
Option<DecodingKey> {
+    /// Resolve the decoding key for `{issuer, kid}`, fetching and caching the
+    /// issuer's JWKS on a cache miss. Returns the rich [`IggyError`] built 
during
+    /// the fetch so the caller can log why a trusted-issuer token could not be
+    /// verified instead of collapsing every failure into an opaque miss.
+    ///
+    /// A cache miss is served under a per-issuer guard: concurrent misses 
fetch
+    /// once, and within [`JWKS_REFRESH_MIN_INTERVAL`] of the last fetch a 
miss is
+    /// treated as a known-absent `kid` and rejected without an outbound 
request.
+    /// This keeps an unauthenticated caller replaying unknown `kid`s from
+    /// amplifying into unbounded fetches against the issuer's JWKS endpoint.
+    // The per-issuer guard is deliberately held across the JWKS fetch: that 
hold
+    // is what serialises concurrent misses onto a single outbound request. 
Drop-
+    // tightening would release it before the await and defeat the 
single-flight.
+    #[allow(clippy::significant_drop_tightening)]
+    pub async fn get_key(
+        &self,
+        issuer: &str,
+        jwks_url: &str,
+        kid: &str,
+    ) -> Result<DecodingKey, IggyError> {
         let cache_key = CacheKey {
             issuer: issuer.to_string(),
             kid: kid.to_string(),
         };
 
-        // try to get from cache first
+        // Positive-cache fast path: no lock, no fetch.
         if let Some(key) = self.cache.get(&cache_key) {
-            return Some(key.clone());
+            return Ok(key.clone());
         }
 
-        // fetch and cache if not found
-        if let Ok(key) = self.fetch_and_cache_key(issuer, jwks_url, kid).await 
{
-            return Some(key);
-        }
+        // Take the per-issuer guard so concurrent misses serialise onto one
+        // fetch. Clone the Arc out and drop the DashMap entry lock before the
+        // await, so no shard lock is held across the network I/O.
+        let entry = self.refresh_guards.entry(issuer.to_string()).or_default();
+        let guard = entry.value().clone();
+        drop(entry);
+        let mut last_fetch = guard.lock().await;
 
-        None
-    }
+        // A prior holder of the guard may have populated our kid while we 
waited.
+        if let Some(key) = self.cache.get(&cache_key) {
+            return Ok(key.clone());
+        }
 
-    async fn fetch_and_cache_key(
-        &self,
-        issuer: &str,
-        jwks_url: &str,
-        kid: &str,
-    ) -> Result<DecodingKey, IggyError> {
-        if let Err(e) = self.refresh_keys(issuer, jwks_url).await {
-            return Err(IggyError::CannotFetchJwks(format!(
-                "Failed to refresh keys: {}",
-                e
-            )));
+        // Inside the refresh window the last fetch's key set still stands, so 
a
+        // miss here means the kid is genuinely absent: reject without touching
+        // the network. One per-issuer timestamp both negative-caches unknown 
kids
+        // and rate-limits outbound fetches, with no attacker-keyed state.
+        if let Some(fetched_at) = *last_fetch
+            && fetched_at.elapsed() < JWKS_REFRESH_MIN_INTERVAL
+        {
+            return Err(IggyError::InvalidAccessToken);
         }
 
-        let cache_key = CacheKey {
-            issuer: issuer.to_string(),
-            kid: kid.to_string(),
-        };
+        // Stale or first contact: fetch. Record the attempt up front so a 
failing
+        // issuer is rate-limited too, not re-hit on every miss.
+        *last_fetch = Some(Instant::now());
+        self.refresh_keys(issuer, jwks_url).await?;
 
         self.cache
             .get(&cache_key)
@@ -144,29 +178,28 @@ impl JwksClient {
     }
 
     async fn refresh_keys(&self, issuer: &str, jwks_url: &str) -> Result<(), 
IggyError> {
-        // The cyper client is `!Send` since 0.9; callers reached from axum
-        // middleware wrap this future in `SendWrapper` (see
-        // `http::jwt::middleware::jwt_auth`), so it's free to await cyper
-        // directly here.
+        // The cyper client is `!Send`; callers reached from the axum extractor
+        // wrap this future in `SendWrapper` (see `http::extractor`), so it is
+        // free to await cyper directly here.
         let client = get_http_client();
         let request = client
             .get(jwks_url)
-            .map_err(|e| IggyError::CannotFetchJwks(format!("Failed to build 
request: {}", e)))?
+            .map_err(|e| IggyError::CannotFetchJwks(format!("Failed to build 
request: {e}")))?
             .build();
         let response = client
             .execute(request)
             .await
-            .map_err(|e| IggyError::CannotFetchJwks(format!("HTTP request 
failed: {}", e)))?;
+            .map_err(|e| IggyError::CannotFetchJwks(format!("HTTP request 
failed: {e}")))?;
 
         let body = response.text().await.map_err(|e| {
-            IggyError::CannotFetchJwks(format!("Failed to read response body: 
{}", e))
+            IggyError::CannotFetchJwks(format!("Failed to read response body: 
{e}"))
         })?;
 
         let jwks: JwkSet = serde_json::from_str(&body)
-            .map_err(|e| IggyError::CannotFetchJwks(format!("Failed to parse 
JWKS: {}", e)))?;
+            .map_err(|e| IggyError::CannotFetchJwks(format!("Failed to parse 
JWKS: {e}")))?;
 
         // Collect all current kids from the JWKS response
-        let current_kids: std::collections::HashSet<String> =
+        let current_kids: HashSet<String> =
             jwks.keys.iter().filter_map(|key| key.kid.clone()).collect();
 
         // Remove cached keys for this issuer that are no longer in the JWKS 
response
@@ -190,7 +223,7 @@ impl JwksClient {
                     JwkKeyType::Rsa => {
                         if let (Some(n), Some(e)) = (key.n.as_deref(), 
key.e.as_deref()) {
                             DecodingKey::from_rsa_components(n, e).map_err(|e| 
{
-                                IggyError::CannotFetchJwks(format!("Invalid 
RSA key: {}", e))
+                                IggyError::CannotFetchJwks(format!("Invalid 
RSA key: {e}"))
                             })?
                         } else {
                             continue;
@@ -202,7 +235,7 @@ impl JwksClient {
                         {
                             if let Ok(_curve) = crv_str.parse::<EcCurve>() {
                                 DecodingKey::from_ec_components(x, 
y).map_err(|e| {
-                                    
IggyError::CannotFetchJwks(format!("Invalid EC key: {}", e))
+                                    
IggyError::CannotFetchJwks(format!("Invalid EC key: {e}"))
                                 })?
                             } else {
                                 continue;
@@ -293,10 +326,9 @@ mod tests {
         };
         let decoding_key = create_test_decoding_key();
 
-        client.cache.insert(cache_key.clone(), decoding_key.clone());
+        client.cache.insert(cache_key.clone(), decoding_key);
 
-        let cached = client.cache.get(&cache_key);
-        assert!(cached.is_some());
+        assert!(client.cache.get(&cache_key).is_some());
     }
 
     #[test]
diff --git a/core/server-ng/src/http/jwt.rs b/core/server-ng/src/http/jwt.rs
index 5548963cf..91f1830bc 100644
--- a/core/server-ng/src/http/jwt.rs
+++ b/core/server-ng/src/http/jwt.rs
@@ -18,24 +18,31 @@
 //! Minimal JWT issuer/verifier for the shard-0 HTTP listener.
 //!
 //! Ported from the legacy `server::http::jwt::jwt_manager::JwtManager`, 
reduced
-//! to the issue + verify half: no revoked-token persistence, no JWKS /
-//! trusted-issuer path. The refresh-token route re-issues by composing 
`decode`
-//! with `generate` at the handler; without a revocation list that refresh is
-//! stateless, so the token it was minted from stays valid until its own `exp`.
-//! The claim set (`JwtClaims`, including the `jti`) and the `IggyError -> 
HTTP`
-//! grading are reused verbatim so issued tokens and error bodies stay 
identical
-//! to the legacy server.
+//! to the issue + verify half: no revoked-token persistence. Self-issued HS256
+//! is the common path; with `[[http.jwt.trusted_issuers]]` configured, 
`decode`
+//! also verifies external RS256/EC tokens against the issuer's JWKS
+//! ([`super::jwks`]) and remaps their subject onto the configured iggy user.
+//! The refresh-token route re-issues by composing `decode_for_refresh` (verify
+//! plus a refusal of trusted-issuer tokens) with `generate` at the handler;
+//! without a revocation list that refresh is stateless, so the token it was
+//! minted from stays valid until its own `exp`. The claim set
+//! (`JwtClaims`, including the `jti`) and the `IggyError -> HTTP` grading are
+//! reused verbatim so issued tokens and error bodies stay identical to the
+//! legacy server.
 
+use std::collections::HashMap;
 use std::ops::Range;
 
-use configs::http::HttpJwtConfig;
+use configs::http::{HttpJwtConfig, TrustedIssuerConfig};
 use iggy_common::{IggyDuration, IggyError, IggyExpiry, IggyTimestamp, UserId};
 use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, 
decode, encode};
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use server_common::crypto;
-use tracing::warn;
+use tracing::{debug, error, warn};
 use uuid::Uuid;
 
+use super::jwks::JwksClient;
+
 /// Length window for the random secret minted when no secret is configured.
 /// Matches the legacy server so both behave identically on an empty secret.
 const GENERATED_SECRET_LEN: Range<usize> = 32..64;
@@ -54,12 +61,18 @@ pub struct JwtManager {
     encoding_key: EncodingKey,
     decoding_key: DecodingKey,
     validation: Validation,
+    jwks_client: JwksClient,
+    /// Trusted external issuers keyed by normalized issuer URL. Empty unless
+    /// `[[http.jwt.trusted_issuers]]` is configured, in which case `decode`
+    /// takes the JWKS verification path for tokens from these issuers.
+    trusted_issuers: HashMap<String, TrustedIssuerConfig>,
 }
 
 impl JwtManager {
     /// Build the manager from the `[http.jwt]` config, normalizing an
     /// unconfigured secret the same way the legacy server does (mint a random
-    /// ephemeral secret, or mirror whichever half is set).
+    /// ephemeral secret, or mirror whichever half is set) and loading any
+    /// configured trusted issuers (keyed by normalized issuer URL).
     ///
     /// # Errors
     ///
@@ -79,6 +92,42 @@ impl JwtManager {
         // `jsonwebtoken` leaves `validate_nbf` off by default, so this is
         // explicit; the claim is always present, so it only ever tightens.
         validation.validate_nbf = true;
+        // Validate every trusted issuer at startup so a broken config fails 
loud
+        // instead of silently rejecting every A2A token at first use: an empty
+        // issuer or `jwks_url` can never verify, and mapping onto the root 
user
+        // (id 0) would resolve every external subject to root (`decode` guards
+        // the root case again at verify time as defense in depth).
+        for issuer_config in config.trusted_issuers.iter().flatten() {
+            if issuer_config.issuer.is_empty() {
+                error!("trusted issuer configured with an empty issuer URL");
+                return Err(IggyError::InvalidConfiguration);
+            }
+            if issuer_config.jwks_url.is_empty() {
+                error!(
+                    issuer = %issuer_config.issuer,
+                    "trusted issuer configured with an empty jwks_url"
+                );
+                return Err(IggyError::InvalidConfiguration);
+            }
+            if issuer_config.user_id == 0 {
+                error!(
+                    issuer = %issuer_config.issuer,
+                    "trusted issuer user_id is missing or 0 (must be a 
non-zero iggy user id)"
+                );
+                return Err(IggyError::InvalidConfiguration);
+            }
+        }
+        let trusted_issuers = config
+            .trusted_issuers
+            .iter()
+            .flatten()
+            .map(|issuer_config| {
+                (
+                    normalize_issuer_url(&issuer_config.issuer),
+                    issuer_config.clone(),
+                )
+            })
+            .collect();
         Ok(Self {
             algorithm,
             issuer: config.issuer,
@@ -88,6 +137,8 @@ impl JwtManager {
             encoding_key,
             decoding_key,
             validation,
+            jwks_client: JwksClient::default(),
+            trusted_issuers,
         })
     }
 
@@ -128,17 +179,154 @@ impl JwtManager {
     /// Verify a token's signature and registered claims, returning its claim
     /// set (the `jti` is what a session table keys on).
     ///
+    /// With no trusted issuers this is the self-issued HS256 verify and the
+    /// returned future resolves without awaiting. When the token's issuer
+    /// matches a configured trusted issuer, verification instead uses the
+    /// issuer's JWKS key (awaiting the fetch on a cache miss) and the returned
+    /// `sub` is the configured iggy user id, not the external subject.
+    ///
     /// # Errors
     ///
     /// Returns [`IggyError::Unauthenticated`] if the token is malformed,
     /// expired, or fails issuer/audience/signature validation.
-    pub fn decode(&self, token: &str) -> Result<JwtClaims, IggyError> {
+    pub async fn decode(&self, token: &str) -> Result<JwtClaims, IggyError> {
+        // With no trusted issuers this is the original self-issued verifier,
+        // resolving without ever awaiting.
+        if self.trusted_issuers.is_empty() {
+            return self.decode_self_issued(token);
+        }
+
+        // Try the self-issued verify first. A trusted-issuer token is signed 
by
+        // the issuer's key, which the self-issued verify rejects at the 
algorithm
+        // check (when self-issued is HS*, the shipped default) or at signature
+        // verification (if an operator configures self-issued RS*/ES*) - 
either
+        // way it can never accept an external token; a valid self-issued token
+        // returns here and the classification below only runs once it has 
failed.
+        if let Ok(claims) = self.decode_self_issued(token) {
+            return Ok(claims);
+        }
+
+        // Self-issued verify failed. Classify the token by its (untrusted) 
issuer
+        // claim; if it names a trusted issuer, verify it against that issuer's
+        // JWKS key. Everything read here is re-checked under that key below.
+        let Ok(insecure) = 
jsonwebtoken::dangerous::insecure_decode::<JwtClaims>(token) else {
+            debug!("token is not self-issued and cannot be parsed to classify 
its issuer");
+            return Err(IggyError::Unauthenticated);
+        };
+        let normalized_iss = normalize_issuer_url(&insecure.claims.iss);
+        let Some(config) = self.trusted_issuers.get(&normalized_iss) else {
+            debug!(
+                issuer = %insecure.claims.iss,
+                "token issuer matches no trusted issuer and failed self-issued 
verify"
+            );
+            return Err(IggyError::Unauthenticated);
+        };
+
+        // A trusted external subject must never resolve to the root user.
+        if config.user_id == 0 {
+            error!(
+                issuer = %config.issuer,
+                "trusted-issuer token cannot map to root user (user_id = 0)"
+            );
+            return Err(IggyError::Unauthenticated);
+        }
+
+        // `insecure.header` is parsed from the same header segment 
`decode_header`
+        // reads, so reuse it for `alg` and `kid` instead of parsing twice.
+        let Some(kid) = insecure.header.kid.as_deref() else {
+            debug!(
+                issuer = %config.issuer,
+                "trusted-issuer token has no `kid` header for JWKS key lookup"
+            );
+            return Err(IggyError::Unauthenticated);
+        };
+        let decoding_key = match self
+            .jwks_client
+            .get_key(&config.issuer, &config.jwks_url, kid)
+            .await
+        {
+            Ok(key) => key,
+            Err(error) => {
+                // Fail closed but loud: a broken `jwks_url` or a rotated-away
+                // `kid` otherwise surfaces only as an opaque 401.
+                warn!(
+                    %error,
+                    issuer = %config.issuer,
+                    kid,
+                    "cannot resolve JWKS key for trusted-issuer token"
+                );
+                return Err(IggyError::Unauthenticated);
+            }
+        };
+
+        // The algorithm is taken from the token header (e.g. RS256), not the
+        // self-issued HS256; issuer and audience are pinned to the config, and
+        // the timing checks mirror the self-issued path (enforce `nbf`, honor
+        // the configured clock skew).
+        let mut validation = Validation::new(insecure.header.alg);
+        validation.set_issuer(std::slice::from_ref(&config.issuer));
+        validation.set_audience(std::slice::from_ref(&config.audience));
+        validation.validate_nbf = true;
+        validation.leeway = self.validation.leeway;
+        let mut claims = decode::<JwtClaims>(token, &decoding_key, &validation)
+            .map(|data| data.claims)
+            .map_err(|error| {
+                error!(%error, "trusted-issuer JWT verification failed");
+                IggyError::Unauthenticated
+            })?;
+
+        // Remap the external subject onto the fixed configured iggy user; the
+        // raw external `sub` is discarded (no auto-provisioning).
+        claims.sub = config.user_id.to_string();
+        Ok(claims)
+    }
+
+    /// Verify a token for the refresh route, additionally refusing any token
+    /// minted by a trusted external issuer: those carry their own lifecycle 
(the
+    /// issuer controls their `exp`), so exchanging one for a self-issued token
+    /// would mint a bearer that outlives the external grant. Mirrors the 
legacy
+    /// `JwtManager::refresh_token` reject.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`IggyError::InvalidAccessToken`] for a trusted-issuer token, 
or
+    /// whatever [`Self::decode`] returns for an otherwise invalid one.
+    pub async fn decode_for_refresh(&self, token: &str) -> Result<JwtClaims, 
IggyError> {
+        let claims = self.decode(token).await?;
+        if self
+            .trusted_issuers
+            .contains_key(&normalize_issuer_url(&claims.iss))
+        {
+            error!(issuer = %claims.iss, "refusing to refresh a trusted-issuer 
token");
+            return Err(IggyError::InvalidAccessToken);
+        }
+        Ok(claims)
+    }
+
+    /// Self-issued HS256 verify against the configured secret and validation.
+    /// The synchronous common path shared by the fast path and by every
+    /// trusted-issuer classification miss.
+    fn decode_self_issued(&self, token: &str) -> Result<JwtClaims, IggyError> {
         decode::<JwtClaims>(token, &self.decoding_key, &self.validation)
             .map(|data| data.claims)
             .map_err(|_| IggyError::Unauthenticated)
     }
 }
 
+/// Normalize an issuer URL by lowercasing scheme and host while preserving 
path
+/// case, so trusted-issuer lookup is case-insensitive on the parts that are
+/// (`https://Example.COM/PATH` -> `https://example.com/PATH`).
+fn normalize_issuer_url(url: &str) -> String {
+    match url.split_once("://") {
+        Some((scheme, rest)) => {
+            let scheme = scheme.to_lowercase();
+            let (host, path) = rest.find('/').map_or((rest, ""), |idx| 
rest.split_at(idx));
+            format!("{}://{}{}", scheme, host.to_lowercase(), path)
+        }
+        None => url.trim_end_matches('/').to_lowercase(),
+    }
+}
+
 /// Fill in an unconfigured JWT secret exactly like the legacy HTTP server:
 /// both empty -> one random ephemeral secret; one empty -> mirror the other;
 /// both set but different under an HMAC algorithm -> warn (they must match).
@@ -283,25 +471,113 @@ mod tests {
         }
     }
 
-    #[test]
-    fn token_presented_before_its_nbf_is_rejected() {
+    // The default config has no trusted issuers, so `decode` takes the
+    // synchronous self-issued path; any executor can drive the ready future.
+    #[tokio::test]
+    async fn token_presented_before_its_nbf_is_rejected() {
         // not_before well past the leeway window, so the freshly issued token
         // is not yet valid and decode must reject it.
         let manager = JwtManager::build(&config("3600 s", "5 
s")).expect("builds");
         let token = manager.generate(7).expect("issues");
         assert!(
-            manager.decode(&token.access_token).is_err(),
+            manager.decode(&token.access_token).await.is_err(),
             "a token presented before its nbf must be rejected"
         );
     }
 
-    #[test]
-    fn token_at_its_nbf_is_accepted() {
+    #[tokio::test]
+    async fn token_at_its_nbf_is_accepted() {
         // Default not_before (0s) => nbf == iat, so the token is valid now and
         // enabling nbf validation does not reject a normally issued token.
         let manager = JwtManager::build(&config("0 s", "5 
s")).expect("builds");
         let token = manager.generate(7).expect("issues");
-        let claims = manager.decode(&token.access_token).expect("valid now");
+        let claims = manager
+            .decode(&token.access_token)
+            .await
+            .expect("valid now");
         assert_eq!(claims.sub, "7");
     }
+
+    #[test]
+    fn build_rejects_trusted_issuer_mapping_to_root() {
+        let jwt = HttpJwtConfig {
+            trusted_issuers: Some(vec![TrustedIssuerConfig {
+                issuer: "https://external.example".to_string(),
+                audience: "iggy".to_string(),
+                jwks_url: 
"https://external.example/.well-known/jwks.json".to_string(),
+                user_id: 0,
+            }]),
+            ..HttpJwtConfig::default()
+        };
+        match JwtManager::build(&jwt) {
+            Err(IggyError::InvalidConfiguration) => {}
+            Err(other) => panic!("expected InvalidConfiguration, got 
{other:?}"),
+            Ok(_) => panic!("build must reject a trusted issuer mapping to the 
root user"),
+        }
+    }
+
+    #[test]
+    fn build_rejects_trusted_issuer_with_empty_issuer() {
+        let jwt = HttpJwtConfig {
+            trusted_issuers: Some(vec![TrustedIssuerConfig {
+                issuer: String::new(),
+                audience: "iggy".to_string(),
+                jwks_url: 
"https://external.example/.well-known/jwks.json".to_string(),
+                user_id: 1,
+            }]),
+            ..HttpJwtConfig::default()
+        };
+        match JwtManager::build(&jwt) {
+            Err(IggyError::InvalidConfiguration) => {}
+            Err(other) => panic!("expected InvalidConfiguration, got 
{other:?}"),
+            Ok(_) => panic!("build must reject a trusted issuer with an empty 
issuer"),
+        }
+    }
+
+    #[test]
+    fn build_rejects_trusted_issuer_with_empty_jwks_url() {
+        let jwt = HttpJwtConfig {
+            trusted_issuers: Some(vec![TrustedIssuerConfig {
+                issuer: "https://external.example".to_string(),
+                audience: "iggy".to_string(),
+                jwks_url: String::new(),
+                user_id: 1,
+            }]),
+            ..HttpJwtConfig::default()
+        };
+        match JwtManager::build(&jwt) {
+            Err(IggyError::InvalidConfiguration) => {}
+            Err(other) => panic!("expected InvalidConfiguration, got 
{other:?}"),
+            Ok(_) => panic!("build must reject a trusted issuer with an empty 
jwks_url"),
+        }
+    }
+
+    #[test]
+    fn normalize_issuer_url_lowercases_scheme_and_host_preserving_path() {
+        assert_eq!(
+            normalize_issuer_url("HTTPS://Example.COM/PATH"),
+            "https://example.com/PATH";
+        );
+    }
+
+    #[test]
+    fn normalize_issuer_url_no_path() {
+        assert_eq!(
+            normalize_issuer_url("HTTPS://Example.COM"),
+            "https://example.com";
+        );
+    }
+
+    #[test]
+    fn normalize_issuer_url_no_scheme_trims_trailing_slash() {
+        assert_eq!(normalize_issuer_url("Example.COM/"), "example.com");
+    }
+
+    #[test]
+    fn normalize_issuer_url_already_normalized_is_stable() {
+        assert_eq!(
+            normalize_issuer_url("https://example.com/path";),
+            "https://example.com/path";
+        );
+    }
 }
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index cb8f78640..037bd30b9 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -89,6 +89,7 @@ strum = { workspace = true }
 sysinfo = { workspace = true }
 tempfile = { workspace = true }
 thiserror = { workspace = true }
+tokio = { workspace = true }
 toml = { workspace = true }
 tower-http = { workspace = true }
 tracing = { workspace = true }
diff --git a/core/server/config.toml b/core/server/config.toml
index 2add7ca24..b1609e7a3 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -126,11 +126,24 @@ decoding_secret = ""
 # `false` means the secret is in plain text.
 use_base64_secret = false
 
-# Trusted issuers for A2A (Application-to-Application) authentication
-[[http.jwt.trusted_issuers]]
-issuer = "test-issuer"
-jwks_url = "http://127.0.0.1:8081/.well-known/jwks.json";
-audience = "iggy.apache.org"
+# Trusted issuers for A2A (Application-to-Application) authentication. Opt-in:
+# with none configured the listener accepts only self-issued HS256 tokens.
+# `issuer`, `audience` and `jwks_url` are required per entry; `user_id` is
+# optional but defaults to 0 (root), which is rejected - set it to the non-zero
+# iggy user every token from that issuer is remapped onto.
+#
+# Operational note: enabling an issuer opens an outbound JWKS fetch that is
+# reachable before a token's signature is verified - a token naming this issuer
+# with an unknown key id can trigger a fetch to `jwks_url`. The target is fixed
+# (not attacker-chosen); concurrent misses coalesce onto one fetch and repeats
+# are rate-limited to at most one outbound request per issuer per short window,
+# so an unknown-key-id flood cannot amplify. The same window bounds how 
quickly a
+# freshly rotated signing key is picked up.
+# [[http.jwt.trusted_issuers]]
+# issuer = "test-issuer"
+# jwks_url = "http://127.0.0.1:8081/.well-known/jwks.json";
+# audience = "iggy.apache.org"
+# user_id = 1
 
 # Metrics configuration for HTTP.
 [http.metrics]
diff --git a/core/server/src/http/jwt/jwks.rs b/core/server/src/http/jwt/jwks.rs
index 591d0a707..0932e5ed4 100644
--- a/core/server/src/http/jwt/jwks.rs
+++ b/core/server/src/http/jwt/jwks.rs
@@ -15,12 +15,24 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::hash::Hash;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
 use dashmap::DashMap;
 use iggy_common::IggyError;
 use jsonwebtoken::DecodingKey;
 use serde::Deserialize;
-use std::hash::Hash;
 use strum::{Display, EnumString};
+use tokio::sync::Mutex;
+
+/// Minimum wall-clock gap between outbound JWKS fetches for one trusted 
issuer.
+/// Inside this window a cache miss is authoritative: the issuer's key set was
+/// just read, so an absent `kid` is genuinely absent and no fetch is issued.
+/// This bounds a pre-auth caller replaying unknown `kid`s to at most one
+/// outbound request per issuer per window; the trade is that a freshly rotated
+/// key is only discoverable once the window elapses.
+const JWKS_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(10);
 
 thread_local! {
     // cyper 0.9's `Client` is `!Send`/`!Sync` (`Rc`-backed) and `new()`
@@ -89,58 +101,78 @@ struct CacheKey {
 #[derive(Debug, Clone)]
 pub struct JwksClient {
     cache: DashMap<CacheKey, DecodingKey>,
+    /// Per-issuer single-flight and rate-limit guard. The async mutex 
serialises
+    /// refresh attempts for one issuer so a burst of concurrent misses 
collapses
+    /// onto a single fetch; the inner instant is when that issuer was last
+    /// fetched and gates [`JWKS_REFRESH_MIN_INTERVAL`]. Keyed only by issuer
+    /// (operator-configured, bounded), never by the attacker-controlled `kid`.
+    refresh_guards: DashMap<String, Arc<Mutex<Option<Instant>>>>,
 }
 
 impl Default for JwksClient {
     fn default() -> Self {
         Self {
             cache: DashMap::new(),
+            refresh_guards: DashMap::new(),
         }
     }
 }
 
 impl JwksClient {
+    /// Resolve the decoding key for `{issuer, kid}`, fetching and caching the
+    /// issuer's JWKS on a cache miss.
+    ///
+    /// A cache miss is served under a per-issuer guard: concurrent misses 
fetch
+    /// once, and within [`JWKS_REFRESH_MIN_INTERVAL`] of the last fetch a 
miss is
+    /// treated as a known-absent `kid` and rejected without an outbound 
request.
+    /// This keeps an unauthenticated caller replaying unknown `kid`s from
+    /// amplifying into unbounded fetches against the issuer's JWKS endpoint.
+    // The per-issuer guard is deliberately held across the JWKS fetch: that 
hold
+    // is what serialises concurrent misses onto a single outbound request. 
Drop-
+    // tightening would release it before the await and defeat the 
single-flight.
+    #[allow(clippy::significant_drop_tightening)]
     pub async fn get_key(&self, issuer: &str, jwks_url: &str, kid: &str) -> 
Option<DecodingKey> {
         let cache_key = CacheKey {
             issuer: issuer.to_string(),
             kid: kid.to_string(),
         };
 
-        // try to get from cache first
+        // Positive-cache fast path: no lock, no fetch.
         if let Some(key) = self.cache.get(&cache_key) {
             return Some(key.clone());
         }
 
-        // fetch and cache if not found
-        if let Ok(key) = self.fetch_and_cache_key(issuer, jwks_url, kid).await 
{
-            return Some(key);
-        }
+        // Take the per-issuer guard so concurrent misses serialise onto one
+        // fetch. Clone the Arc out and drop the DashMap entry lock before the
+        // await, so no shard lock is held across the network I/O.
+        let entry = self.refresh_guards.entry(issuer.to_string()).or_default();
+        let guard = entry.value().clone();
+        drop(entry);
+        let mut last_fetch = guard.lock().await;
 
-        None
-    }
+        // A prior holder of the guard may have populated our kid while we 
waited.
+        if let Some(key) = self.cache.get(&cache_key) {
+            return Some(key.clone());
+        }
 
-    async fn fetch_and_cache_key(
-        &self,
-        issuer: &str,
-        jwks_url: &str,
-        kid: &str,
-    ) -> Result<DecodingKey, IggyError> {
-        if let Err(e) = self.refresh_keys(issuer, jwks_url).await {
-            return Err(IggyError::CannotFetchJwks(format!(
-                "Failed to refresh keys: {}",
-                e
-            )));
+        // Inside the refresh window the last fetch's key set still stands, so 
a
+        // miss here means the kid is genuinely absent: reject without touching
+        // the network. One per-issuer timestamp both negative-caches unknown 
kids
+        // and rate-limits outbound fetches, with no attacker-keyed state.
+        if let Some(fetched_at) = *last_fetch
+            && fetched_at.elapsed() < JWKS_REFRESH_MIN_INTERVAL
+        {
+            return None;
         }
 
-        let cache_key = CacheKey {
-            issuer: issuer.to_string(),
-            kid: kid.to_string(),
-        };
+        // Stale or first contact: fetch. Record the attempt up front so a 
failing
+        // issuer is rate-limited too, not re-hit on every miss.
+        *last_fetch = Some(Instant::now());
+        if self.refresh_keys(issuer, jwks_url).await.is_err() {
+            return None;
+        }
 
-        self.cache
-            .get(&cache_key)
-            .map(|entry| entry.clone())
-            .ok_or(IggyError::InvalidAccessToken)
+        self.cache.get(&cache_key).map(|entry| entry.clone())
     }
 
     async fn refresh_keys(&self, issuer: &str, jwks_url: &str) -> Result<(), 
IggyError> {

Reply via email to