#![cfg(feature = "auth-jwt")]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use a2a_protocol_server::auth::jwt::{Jwks, JwtAuthInterceptor, JwtValidator};
use a2a_protocol_server::dispatch::JsonRpcDispatcher;
use a2a_protocol_server::{agent_executor, RequestHandlerBuilder};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
#[allow(dead_code)]
mod vectors {
include!("../src/auth/jwt_test_vectors.rs");
}
use vectors::{
RS256_E, RS256_EXPIRED, RS256_N, RS256_VALID, RS256_WRONG_AUD, RS256_WRONG_ISS, RS256_WRONG_KEY,
};
struct EchoExec;
agent_executor!(EchoExec, |_ctx, _q| async { Ok(()) });
fn send_message_body() -> String {
serde_json::json!({
"jsonrpc": "2.0",
"method": "SendMessage",
"id": "req-1",
"params": {
"message": {
"messageId": "m1",
"role": "ROLE_USER",
"parts": [{"text": "hi"}]
}
}
})
.to_string()
}
async fn spawn_guarded_server(interceptor: JwtAuthInterceptor) -> std::net::SocketAddr {
let handler = Arc::new(
RequestHandlerBuilder::new(EchoExec)
.with_interceptor(interceptor)
.build()
.expect("build handler"),
);
let dispatcher = Arc::new(JsonRpcDispatcher::new(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let d = Arc::clone(&dispatcher);
tokio::spawn(async move {
let io = hyper_util::rt::TokioIo::new(stream);
let svc = hyper::service::service_fn(move |req| {
let d = Arc::clone(&d);
async move { Ok::<_, std::convert::Infallible>(d.dispatch(req).await) }
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, svc)
.await;
});
}
});
addr
}
async fn post_send_message(addr: std::net::SocketAddr, token: Option<&str>) -> String {
let client = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build_http::<Full<Bytes>>();
let mut builder = hyper::Request::builder()
.method("POST")
.uri(format!("http://{addr}/"))
.header("content-type", "application/json");
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
let req = builder
.header("a2a-version", "1.0")
.body(Full::new(Bytes::from(send_message_body())))
.unwrap();
let resp = client.request(req).await.unwrap();
let body = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&body).into_owned()
}
fn validator() -> JwtValidator {
JwtValidator::new()
.with_issuer("https://issuer.test")
.with_audience("a2a-agent")
}
#[tokio::test]
async fn valid_rs256_passes_and_invalid_is_rejected_over_http() {
let jwks = Jwks::new().with_rsa("rk1", RS256_N, RS256_E).unwrap();
let addr = spawn_guarded_server(JwtAuthInterceptor::new(validator(), jwks)).await;
let ok = post_send_message(addr, Some(RS256_VALID)).await;
assert!(ok.contains("result"), "valid token should be served: {ok}");
let missing = post_send_message(addr, None).await;
assert!(
missing.contains("error"),
"no token must be rejected: {missing}"
);
assert!(missing.contains("authentication required"), "{missing}");
for bad in [
RS256_EXPIRED,
RS256_WRONG_KEY,
RS256_WRONG_ISS,
RS256_WRONG_AUD,
] {
let body = post_send_message(addr, Some(bad)).await;
assert!(body.contains("error"), "bad token must be rejected: {body}");
assert!(
body.contains("authentication required"),
"rejection must be generic: {body}"
);
}
}
async fn spawn_jwks_endpoint(
state: Arc<std::sync::Mutex<String>>,
hits: Arc<AtomicUsize>,
) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let state = Arc::clone(&state);
let hits = Arc::clone(&hits);
tokio::spawn(async move {
let io = hyper_util::rt::TokioIo::new(stream);
let svc = hyper::service::service_fn(move |_req| {
let state = Arc::clone(&state);
let hits = Arc::clone(&hits);
async move {
hits.fetch_add(1, Ordering::SeqCst);
let body = state.lock().unwrap().clone();
Ok::<_, std::convert::Infallible>(
hyper::Response::builder()
.status(200)
.header("content-type", "application/json")
.body(Full::new(Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(io, svc)
.await;
});
}
});
addr
}
fn jwks_json(kid: &str) -> String {
format!(
r#"{{"keys":[{{"kty":"RSA","kid":"{kid}","use":"sig","n":"{RS256_N}","e":"{RS256_E}"}}]}}"#
)
}
#[tokio::test]
async fn remote_jwks_fetches_once_then_caches() {
let hits = Arc::new(AtomicUsize::new(0));
let state = Arc::new(std::sync::Mutex::new(jwks_json("rk1")));
let jwks_addr = spawn_jwks_endpoint(Arc::clone(&state), Arc::clone(&hits)).await;
let interceptor =
JwtAuthInterceptor::from_jwks_url(validator(), format!("http://{jwks_addr}/jwks"));
let addr = spawn_guarded_server(interceptor).await;
for _ in 0..3 {
let body = post_send_message(addr, Some(RS256_VALID)).await;
assert!(body.contains("result"), "valid token served: {body}");
}
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"three requests must share a single cached JWKS fetch"
);
}
#[tokio::test]
async fn remote_jwks_refetches_on_key_rotation() {
let hits = Arc::new(AtomicUsize::new(0));
let state = Arc::new(std::sync::Mutex::new(jwks_json("rk-old")));
let jwks_addr = spawn_jwks_endpoint(Arc::clone(&state), Arc::clone(&hits)).await;
let interceptor =
JwtAuthInterceptor::from_jwks_url(validator(), format!("http://{jwks_addr}/jwks"));
let addr = spawn_guarded_server(interceptor).await;
let rejected = post_send_message(addr, Some(RS256_VALID)).await;
assert!(
rejected.contains("error"),
"kid miss must reject: {rejected}"
);
assert_eq!(
hits.load(Ordering::SeqCst),
2,
"initial fetch + one rotation retry"
);
*state.lock().unwrap() = jwks_json("rk1");
let ok = post_send_message(addr, Some(RS256_VALID)).await;
assert!(
ok.contains("result"),
"post-rotation token must be served: {ok}"
);
assert_eq!(
hits.load(Ordering::SeqCst),
3,
"rotation retry performs exactly one additional fetch"
);
}
async fn spawn_oidc_issuer(
discovery_body: Arc<std::sync::Mutex<String>>,
jwks_body: String,
discovery_status: u16,
) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let discovery_body = Arc::clone(&discovery_body);
let jwks_body = jwks_body.clone();
tokio::spawn(async move {
let io = hyper_util::rt::TokioIo::new(stream);
let svc = hyper::service::service_fn(move |req: hyper::Request<_>| {
let discovery_body = Arc::clone(&discovery_body);
let jwks_body = jwks_body.clone();
async move {
let (status, body) = match req.uri().path() {
"/.well-known/openid-configuration" => {
(discovery_status, discovery_body.lock().unwrap().clone())
}
"/jwks" => (200, jwks_body),
_ => (404, String::from("{}")),
};
Ok::<_, std::convert::Infallible>(
hyper::Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Full::new(Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(io, svc)
.await;
});
}
});
addr
}
#[tokio::test]
async fn oidc_discovery_end_to_end_validates_tokens() {
let discovery = Arc::new(std::sync::Mutex::new(String::new()));
let issuer_addr = spawn_oidc_issuer(Arc::clone(&discovery), jwks_json("rk1"), 200).await;
*discovery.lock().unwrap() =
format!(r#"{{"issuer":"http://{issuer_addr}","jwks_uri":"http://{issuer_addr}/jwks"}}"#);
let interceptor =
JwtAuthInterceptor::from_oidc_issuer(&format!("http://{issuer_addr}/"), validator())
.await
.expect("discovery against a live issuer must succeed");
let addr = spawn_guarded_server(interceptor).await;
let ok = post_send_message(addr, Some(RS256_VALID)).await;
assert!(
ok.contains("result"),
"valid token must be served after OIDC discovery: {ok}"
);
let rejected = post_send_message(addr, Some(RS256_WRONG_KEY)).await;
assert!(
rejected.contains("error"),
"wrong-key token must be rejected: {rejected}"
);
}
#[tokio::test]
async fn oidc_discovery_missing_jwks_uri_errors() {
let discovery = Arc::new(std::sync::Mutex::new(String::from(
r#"{"issuer":"http://example.invalid"}"#,
)));
let issuer_addr = spawn_oidc_issuer(discovery, jwks_json("rk1"), 200).await;
let err = JwtAuthInterceptor::from_oidc_issuer(&format!("http://{issuer_addr}"), validator())
.await
.expect_err("discovery without jwks_uri must fail");
assert!(
err.message.contains("jwks_uri"),
"error must name the missing field: {err}"
);
}
#[tokio::test]
async fn oidc_discovery_invalid_json_errors() {
let discovery = Arc::new(std::sync::Mutex::new(String::from("<html>not json</html>")));
let issuer_addr = spawn_oidc_issuer(discovery, jwks_json("rk1"), 200).await;
let err = JwtAuthInterceptor::from_oidc_issuer(&format!("http://{issuer_addr}"), validator())
.await
.expect_err("non-JSON discovery must fail");
assert!(
err.message.contains("invalid JSON"),
"error must describe the parse failure: {err}"
);
}
#[tokio::test]
async fn oidc_discovery_http_error_errors() {
let discovery = Arc::new(std::sync::Mutex::new(String::from("{}")));
let issuer_addr = spawn_oidc_issuer(discovery, jwks_json("rk1"), 500).await;
let err = JwtAuthInterceptor::from_oidc_issuer(&format!("http://{issuer_addr}"), validator())
.await
.expect_err("a 500 from discovery must fail");
assert!(
err.message.contains("OIDC discovery"),
"error must name the failing step: {err}"
);
}
#[tokio::test]
async fn oidc_discovery_unreachable_issuer_errors() {
let err = JwtAuthInterceptor::from_oidc_issuer("http://127.0.0.1:1", validator())
.await
.expect_err("unreachable issuer must fail");
assert!(
err.message.contains("OIDC discovery"),
"error must name the failing step: {err}"
);
}