use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ring::signature;
use a2a_protocol_types::error::{A2aError, A2aResult};
use super::{auth_rejected, extract_bearer, AuthenticatedPrincipal};
use crate::call_context::CallContext;
use crate::interceptor::ServerInterceptor;
const DEFAULT_LEEWAY: Duration = Duration::from_secs(60);
const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(3600);
const MAX_JWKS_RESPONSE_SIZE: usize = 256 * 1024;
const fn jwks_body_exceeds_limit(collected_len: usize, chunk_len: usize) -> bool {
collected_len + chunk_len > MAX_JWKS_RESPONSE_SIZE
}
fn cache_is_fresh(elapsed: Duration, ttl: Duration) -> bool {
elapsed < ttl
}
#[derive(Clone)]
struct VerifyKey {
kid: Option<String>,
material: KeyMaterial,
}
#[derive(Clone)]
enum KeyMaterial {
Rsa(Vec<u8>),
EcP256(Vec<u8>),
}
#[derive(Clone, Default)]
pub struct Jwks {
keys: Vec<VerifyKey>,
}
impl std::fmt::Debug for Jwks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Jwks")
.field("keys", &self.keys.len())
.finish()
}
}
impl Jwks {
#[must_use]
pub const fn new() -> Self {
Self { keys: Vec::new() }
}
pub fn from_json(json: &[u8]) -> A2aResult<Self> {
#[derive(serde::Deserialize)]
struct JwkSet {
#[serde(default)]
keys: Vec<Jwk>,
}
#[derive(serde::Deserialize)]
struct Jwk {
kty: String,
#[serde(default)]
crv: Option<String>,
#[serde(default)]
kid: Option<String>,
#[serde(rename = "use", default)]
use_: Option<String>,
#[serde(default)]
n: Option<String>,
#[serde(default)]
e: Option<String>,
#[serde(default)]
x: Option<String>,
#[serde(default)]
y: Option<String>,
}
let set: JwkSet = serde_json::from_slice(json)
.map_err(|e| A2aError::invalid_params(format!("invalid JWKS JSON: {e}")))?;
let mut jwks = Self::new();
for k in set.keys {
if k.use_.as_deref() == Some("enc") {
continue;
}
match k.kty.as_str() {
"RSA" => {
let (Some(n), Some(e)) = (k.n.as_deref(), k.e.as_deref()) else {
return Err(A2aError::invalid_params("RSA JWK missing n/e"));
};
jwks = jwks.with_rsa_opt_kid(k.kid, n, e)?;
}
"EC" if k.crv.as_deref() == Some("P-256") => {
let (Some(x), Some(y)) = (k.x.as_deref(), k.y.as_deref()) else {
return Err(A2aError::invalid_params("EC JWK missing x/y"));
};
jwks = jwks.with_ec_p256_opt_kid(k.kid, x, y)?;
}
_ => {}
}
}
Ok(jwks)
}
pub fn with_rsa(self, kid: impl Into<String>, n: &str, e: &str) -> A2aResult<Self> {
self.with_rsa_opt_kid(Some(kid.into()), n, e)
}
fn with_rsa_opt_kid(mut self, kid: Option<String>, n: &str, e: &str) -> A2aResult<Self> {
let n = b64url(n, "RSA modulus")?;
let e = b64url(e, "RSA exponent")?;
self.keys.push(VerifyKey {
kid,
material: KeyMaterial::Rsa(rsa_pkcs1_der(&n, &e)),
});
Ok(self)
}
pub fn with_ec_p256(self, kid: impl Into<String>, x: &str, y: &str) -> A2aResult<Self> {
self.with_ec_p256_opt_kid(Some(kid.into()), x, y)
}
fn with_ec_p256_opt_kid(mut self, kid: Option<String>, x: &str, y: &str) -> A2aResult<Self> {
let x = b64url(x, "EC x")?;
let y = b64url(y, "EC y")?;
if x.len() != 32 || y.len() != 32 {
return Err(A2aError::invalid_params(
"EC P-256 coordinates must be 32 bytes each",
));
}
let mut point = Vec::with_capacity(65);
point.push(0x04); point.extend_from_slice(&x);
point.extend_from_slice(&y);
self.keys.push(VerifyKey {
kid,
material: KeyMaterial::EcP256(point),
});
Ok(self)
}
fn candidates(&self, kid: Option<&str>) -> (Vec<&VerifyKey>, bool) {
if let Some(kid) = kid {
let exact: Vec<&VerifyKey> = self
.keys
.iter()
.filter(|k| k.kid.as_deref() == Some(kid))
.collect();
if !exact.is_empty() {
return (exact, true);
}
if self.keys.iter().any(|k| k.kid.is_some()) {
return (Vec::new(), false);
}
}
(self.keys.iter().collect(), false)
}
const fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
#[derive(Clone)]
pub struct JwtValidator {
issuers: HashSet<String>,
audiences: HashSet<String>,
leeway: Duration,
require_exp: bool,
hs256_secret: Option<Arc<Vec<u8>>>,
}
impl std::fmt::Debug for JwtValidator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtValidator")
.field("issuers", &self.issuers)
.field("audiences", &self.audiences)
.field("leeway", &self.leeway)
.field("require_exp", &self.require_exp)
.field(
"hs256_secret",
&self.hs256_secret.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
impl Default for JwtValidator {
fn default() -> Self {
Self::new()
}
}
impl JwtValidator {
#[must_use]
pub fn new() -> Self {
Self {
issuers: HashSet::new(),
audiences: HashSet::new(),
leeway: DEFAULT_LEEWAY,
require_exp: true,
hs256_secret: None,
}
}
#[must_use]
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuers.insert(issuer.into());
self
}
#[must_use]
pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
self.audiences.insert(audience.into());
self
}
#[must_use]
pub const fn with_leeway(mut self, leeway: Duration) -> Self {
self.leeway = leeway;
self
}
#[must_use]
pub const fn allow_missing_exp(mut self) -> Self {
self.require_exp = false;
self
}
#[must_use]
pub fn with_hs256_secret(mut self, secret: impl Into<Vec<u8>>) -> Self {
self.hs256_secret = Some(Arc::new(secret.into()));
self
}
fn validate(
&self,
token: &str,
jwks: &Jwks,
) -> Result<AuthenticatedPrincipal, ValidateOutcome> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(ValidateOutcome::Rejected);
}
let (header_b64, claims_b64, sig_b64) = (parts[0], parts[1], parts[2]);
let header: JwtHeader = decode_json(header_b64).map_err(|()| ValidateOutcome::Rejected)?;
let signature = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| ValidateOutcome::Rejected)?;
let signing_input = format!("{header_b64}.{claims_b64}");
let alg = header.alg.as_str();
let kid_matched = match alg {
"HS256" => {
let secret = self
.hs256_secret
.as_ref()
.ok_or(ValidateOutcome::Rejected)?;
let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret);
ring::hmac::verify(&key, signing_input.as_bytes(), &signature)
.map_err(|_| ValidateOutcome::Rejected)?;
true }
"RS256" | "ES256" => {
let (candidates, kid_matched) = jwks.candidates(header.kid.as_deref());
if candidates.is_empty() {
return Err(ValidateOutcome::KeyMiss);
}
let verified = candidates.iter().any(|key| {
verify_asymmetric(alg, &key.material, signing_input.as_bytes(), &signature)
});
if !verified {
return Err(if header.kid.is_some() && !kid_matched {
ValidateOutcome::KeyMiss
} else {
ValidateOutcome::Rejected
});
}
kid_matched
}
_ => return Err(ValidateOutcome::Rejected), };
let _ = kid_matched;
let claims: JwtClaims = decode_json(claims_b64).map_err(|()| ValidateOutcome::Rejected)?;
self.check_claims(&claims)
.map_err(|()| ValidateOutcome::Rejected)?;
Ok(AuthenticatedPrincipal {
subject: claims.sub,
issuer: claims.iss,
})
}
fn check_claims(&self, claims: &JwtClaims) -> Result<(), ()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ())?
.as_secs();
self.check_claims_at(claims, now)
}
fn check_claims_at(&self, claims: &JwtClaims, now: u64) -> Result<(), ()> {
let leeway = self.leeway.as_secs();
match claims.exp {
Some(exp) => {
if now >= exp.saturating_add(leeway) {
return Err(()); }
}
None if self.require_exp => return Err(()),
None => {}
}
if let Some(nbf) = claims.nbf {
if now.saturating_add(leeway) < nbf {
return Err(()); }
}
if !self.issuers.is_empty() {
match &claims.iss {
Some(iss) if self.issuers.contains(iss) => {}
_ => return Err(()),
}
}
if !self.audiences.is_empty() {
let ok = claims
.aud
.as_ref()
.is_some_and(|aud| aud.iter().any(|a| self.audiences.contains(a)));
if !ok {
return Err(());
}
}
Ok(())
}
}
#[cfg_attr(test, derive(Debug))]
enum ValidateOutcome {
Rejected,
KeyMiss,
}
#[derive(serde::Deserialize)]
struct JwtHeader {
alg: String,
#[serde(default)]
kid: Option<String>,
}
#[derive(serde::Deserialize)]
struct JwtClaims {
#[serde(default)]
iss: Option<String>,
#[serde(default)]
sub: Option<String>,
#[serde(default, deserialize_with = "de_aud")]
aud: Option<Vec<String>>,
#[serde(default)]
exp: Option<u64>,
#[serde(default)]
nbf: Option<u64>,
}
fn de_aud<'de, D>(de: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Aud {
One(String),
Many(Vec<String>),
}
Ok(
<Option<Aud> as serde::Deserialize>::deserialize(de)?.map(|a| match a {
Aud::One(s) => vec![s],
Aud::Many(v) => v,
}),
)
}
enum KeySource {
Static(Jwks),
Remote(Box<RemoteJwks>),
}
struct RemoteJwks {
url: String,
ttl: Duration,
cache: RwLock<Option<CachedJwks>>,
refresh_lock: tokio::sync::Mutex<()>,
client: JwksHttpClient,
}
struct CachedJwks {
jwks: Jwks,
fetched_at: std::time::Instant,
}
pub struct JwtAuthInterceptor {
validator: JwtValidator,
keys: KeySource,
}
impl std::fmt::Debug for JwtAuthInterceptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtAuthInterceptor")
.field("validator", &self.validator)
.field(
"keys",
&match &self.keys {
KeySource::Static(_) => "static",
KeySource::Remote(_) => "remote-jwks",
},
)
.finish()
}
}
impl JwtAuthInterceptor {
#[must_use]
pub const fn new(validator: JwtValidator, jwks: Jwks) -> Self {
Self {
validator,
keys: KeySource::Static(jwks),
}
}
#[must_use]
pub fn from_jwks_url(validator: JwtValidator, jwks_url: impl Into<String>) -> Self {
Self {
validator,
keys: KeySource::Remote(Box::new(RemoteJwks {
url: jwks_url.into(),
ttl: DEFAULT_JWKS_TTL,
cache: RwLock::new(None),
refresh_lock: tokio::sync::Mutex::new(()),
client: build_jwks_client(),
})),
}
}
#[cfg(feature = "tls-rustls")]
#[must_use]
pub fn from_jwks_url_with_tls_config(
validator: JwtValidator,
jwks_url: impl Into<String>,
tls_config: rustls::ClientConfig,
) -> Self {
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http1()
.enable_http2()
.build();
Self {
validator,
keys: KeySource::Remote(Box::new(RemoteJwks {
url: jwks_url.into(),
ttl: DEFAULT_JWKS_TTL,
cache: RwLock::new(None),
refresh_lock: tokio::sync::Mutex::new(()),
client: Client::builder(TokioExecutor::new()).build(https),
})),
}
}
pub async fn from_oidc_issuer(issuer: &str, validator: JwtValidator) -> A2aResult<Self> {
let jwks_url = discover_jwks_uri(issuer).await?;
Ok(Self::from_jwks_url(validator, jwks_url))
}
#[must_use]
pub fn with_jwks_ttl(mut self, ttl: Duration) -> Self {
if let KeySource::Remote(ref mut r) = self.keys {
r.ttl = ttl;
}
self
}
async fn authenticate(&self, ctx: &CallContext) -> A2aResult<AuthenticatedPrincipal> {
let header = ctx
.http_headers()
.get("authorization")
.ok_or_else(auth_rejected)?;
let token = extract_bearer(header).ok_or_else(auth_rejected)?;
match &self.keys {
KeySource::Static(jwks) => self
.validator
.validate(token, jwks)
.map_err(|_| auth_rejected()),
KeySource::Remote(remote) => {
let jwks = remote.get(false).await?;
match self.validator.validate(token, &jwks) {
Ok(principal) => Ok(principal),
Err(ValidateOutcome::KeyMiss) => {
let fresh = remote.get(true).await?;
self.validator
.validate(token, &fresh)
.map_err(|_| auth_rejected())
}
Err(ValidateOutcome::Rejected) => Err(auth_rejected()),
}
}
}
}
}
impl ServerInterceptor for JwtAuthInterceptor {
fn before<'a>(
&'a self,
ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
self.authenticate(ctx).await.map(|_principal| ())
})
}
fn after<'a>(
&'a self,
_ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move { Ok(()) })
}
fn authenticates(&self) -> bool {
true
}
}
impl RemoteJwks {
async fn get(&self, force: bool) -> A2aResult<Jwks> {
if !force {
if let Some(jwks) = self.cached_fresh() {
return Ok(jwks);
}
}
let _guard = self.refresh_lock.lock().await;
if !force {
if let Some(jwks) = self.cached_fresh() {
return Ok(jwks);
}
}
let jwks = self.fetch().await?;
*self
.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CachedJwks {
jwks: jwks.clone(),
fetched_at: std::time::Instant::now(),
});
Ok(jwks)
}
fn cached_fresh(&self) -> Option<Jwks> {
let guard = self
.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.as_ref().and_then(|c| {
if cache_is_fresh(c.fetched_at.elapsed(), self.ttl) {
Some(c.jwks.clone())
} else {
None
}
})
}
async fn fetch(&self) -> A2aResult<Jwks> {
let body = http_get_json(&self.client, &self.url, "JWKS").await?;
let jwks = Jwks::from_json(&body)?;
if jwks.is_empty() {
return Err(A2aError::internal("JWKS endpoint returned no usable keys"));
}
Ok(jwks)
}
}
use http_body_util::Full;
use hyper::body::Bytes;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
#[cfg(not(feature = "tls-rustls"))]
type JwksHttpClient = Client<HttpConnector, Full<Bytes>>;
#[cfg(feature = "tls-rustls")]
type JwksHttpClient = Client<hyper_rustls::HttpsConnector<HttpConnector>, Full<Bytes>>;
#[cfg(not(feature = "tls-rustls"))]
fn build_jwks_client() -> JwksHttpClient {
let mut connector = HttpConnector::new();
connector.set_connect_timeout(Some(Duration::from_secs(10)));
Client::builder(TokioExecutor::new()).build(connector)
}
#[cfg(feature = "tls-rustls")]
fn build_jwks_client() -> JwksHttpClient {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("ring provider supports the default protocol versions")
.with_root_certificates(roots)
.with_no_client_auth();
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_or_http()
.enable_http1()
.enable_http2()
.build();
Client::builder(TokioExecutor::new()).build(https)
}
async fn http_get_json(client: &JwksHttpClient, url: &str, what: &str) -> A2aResult<Vec<u8>> {
use http_body_util::BodyExt;
let req = hyper::Request::builder()
.method(hyper::Method::GET)
.uri(url)
.header("accept", "application/json")
.body(Full::new(Bytes::new()))
.map_err(|e| A2aError::internal(format!("{what} request build failed: {e}")))?;
let resp = tokio::time::timeout(Duration::from_secs(30), client.request(req))
.await
.map_err(|_| A2aError::internal(format!("{what} request timed out")))?
.map_err(|e| A2aError::internal(format!("{what} request failed: {e}")))?;
if !resp.status().is_success() {
return Err(A2aError::internal(format!(
"{what} endpoint returned HTTP {}",
resp.status()
)));
}
let mut collected: Vec<u8> = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame =
frame.map_err(|e| A2aError::internal(format!("{what} body read failed: {e}")))?;
if let Some(chunk) = frame.data_ref() {
if jwks_body_exceeds_limit(collected.len(), chunk.len()) {
return Err(A2aError::internal(format!("{what} response too large")));
}
collected.extend_from_slice(chunk);
}
}
Ok(collected)
}
async fn discover_jwks_uri(issuer: &str) -> A2aResult<String> {
#[derive(serde::Deserialize)]
struct Discovery {
jwks_uri: Option<String>,
}
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let client = build_jwks_client();
let body = http_get_json(&client, &url, "OIDC discovery").await?;
let doc: Discovery = serde_json::from_slice(&body)
.map_err(|e| A2aError::internal(format!("OIDC discovery returned invalid JSON: {e}")))?;
doc.jwks_uri
.ok_or_else(|| A2aError::internal("OIDC discovery document has no jwks_uri"))
}
fn verify_asymmetric(alg: &str, key: &KeyMaterial, msg: &[u8], sig: &[u8]) -> bool {
match (alg, key) {
("RS256", KeyMaterial::Rsa(der)) => signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
der.as_slice(),
)
.verify(msg, sig)
.is_ok(),
("ES256", KeyMaterial::EcP256(point)) => {
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, point.as_slice())
.verify(msg, sig)
.is_ok()
}
_ => false,
}
}
fn rsa_pkcs1_der(n: &[u8], e: &[u8]) -> Vec<u8> {
let mut body = der_uint(n);
body.extend(der_uint(e));
der_tlv(0x30, &body) }
fn der_uint(bytes: &[u8]) -> Vec<u8> {
let start = bytes.iter().position(|&b| b != 0).unwrap_or(bytes.len());
let trimmed = &bytes[start..];
let mut content = Vec::with_capacity(trimmed.len() + 1);
if trimmed.first().is_none_or(|&b| b & 0x80 != 0) {
content.push(0x00);
}
content.extend_from_slice(trimmed);
der_tlv(0x02, &content)
}
fn der_tlv(tag: u8, content: &[u8]) -> Vec<u8> {
let mut out = vec![tag];
let len = content.len();
if len < 0x80 {
#[allow(clippy::cast_possible_truncation)]
out.push(len as u8);
} else {
let len_bytes = len.to_be_bytes();
let first_nonzero = len_bytes
.iter()
.position(|&b| b != 0)
.expect("len >= 0x80 has a non-zero big-endian byte");
let significant = &len_bytes[first_nonzero..];
#[allow(clippy::cast_possible_truncation)]
out.push(0x80 + significant.len() as u8);
out.extend_from_slice(significant);
}
out.extend_from_slice(content);
out
}
fn b64url(s: &str, what: &str) -> A2aResult<Vec<u8>> {
URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| A2aError::invalid_params(format!("invalid base64url {what}: {e}")))
}
fn decode_json<T: serde::de::DeserializeOwned>(b64: &str) -> Result<T, ()> {
let bytes = URL_SAFE_NO_PAD.decode(b64).map_err(|_| ())?;
serde_json::from_slice(&bytes).map_err(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
include!("jwt_test_vectors.rs");
fn ctx_bearer(token: &str) -> CallContext {
CallContext::new("message/send")
.with_http_header("authorization", format!("Bearer {token}"))
}
fn base_validator() -> JwtValidator {
JwtValidator::new()
.with_issuer("https://issuer.test")
.with_audience("a2a-agent")
}
#[tokio::test]
async fn hs256_valid_and_rejections() {
let secret = URL_SAFE_NO_PAD.decode(HS256_SECRET_B64).unwrap();
let v = base_validator().with_hs256_secret(secret);
let i = JwtAuthInterceptor::new(v, Jwks::new());
assert!(i.before(&ctx_bearer(HS256_VALID)).await.is_ok());
assert!(i.before(&ctx_bearer(HS256_EXPIRED)).await.is_err());
assert!(i.before(&ctx_bearer(HS256_WRONG_SECRET)).await.is_err());
}
#[tokio::test]
async fn hs256_without_configured_secret_is_rejected() {
let i = JwtAuthInterceptor::new(base_validator(), Jwks::new());
assert!(i.before(&ctx_bearer(HS256_VALID)).await.is_err());
}
fn rsa_jwks() -> Jwks {
Jwks::new().with_rsa("rk1", RS256_N, RS256_E).unwrap()
}
#[tokio::test]
async fn rs256_valid_and_rejections() {
let i = JwtAuthInterceptor::new(base_validator(), rsa_jwks());
assert!(i.before(&ctx_bearer(RS256_VALID)).await.is_ok());
assert!(i.before(&ctx_bearer(RS256_EXPIRED)).await.is_err());
assert!(i.before(&ctx_bearer(RS256_WRONG_KEY)).await.is_err());
assert!(i.before(&ctx_bearer(RS256_WRONG_ISS)).await.is_err());
assert!(i.before(&ctx_bearer(RS256_WRONG_AUD)).await.is_err());
assert!(i.before(&ctx_bearer(RS256_UNKNOWN_KID)).await.is_err());
}
#[tokio::test]
async fn rs256_from_jwks_json_roundtrip() {
let jwks_json = format!(
r#"{{"keys":[{{"kty":"RSA","kid":"rk1","use":"sig","n":"{RS256_N}","e":"{RS256_E}"}}]}}"#
);
let jwks = Jwks::from_json(jwks_json.as_bytes()).unwrap();
let i = JwtAuthInterceptor::new(base_validator(), jwks);
assert!(i.before(&ctx_bearer(RS256_VALID)).await.is_ok());
}
#[tokio::test]
async fn algorithm_confusion_rejected() {
let i = JwtAuthInterceptor::new(base_validator(), rsa_jwks());
assert!(i.before(&ctx_bearer(HS256_VALID)).await.is_err());
}
#[test]
fn alg_none_is_rejected() {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let claims = URL_SAFE_NO_PAD
.encode(br#"{"iss":"https://issuer.test","aud":"a2a-agent","exp":253402300799}"#);
let token = format!("{header}.{claims}.");
let outcome = base_validator().validate(&token, &rsa_jwks());
assert!(matches!(outcome, Err(ValidateOutcome::Rejected)));
}
#[tokio::test]
async fn es256_valid_and_expired() {
let jwks = Jwks::new().with_ec_p256("ek1", ES256_X, ES256_Y).unwrap();
let i = JwtAuthInterceptor::new(base_validator(), jwks);
assert!(i.before(&ctx_bearer(ES256_VALID)).await.is_ok());
assert!(i.before(&ctx_bearer(ES256_EXPIRED)).await.is_err());
}
#[tokio::test]
async fn audience_and_issuer_optional_when_unset() {
let secret = URL_SAFE_NO_PAD.decode(HS256_SECRET_B64).unwrap();
let v = JwtValidator::new().with_hs256_secret(secret);
let i = JwtAuthInterceptor::new(v, Jwks::new());
assert!(i.before(&ctx_bearer(HS256_VALID)).await.is_ok());
}
#[tokio::test]
async fn missing_authorization_header_rejected() {
let secret = URL_SAFE_NO_PAD.decode(HS256_SECRET_B64).unwrap();
let v = base_validator().with_hs256_secret(secret);
let i = JwtAuthInterceptor::new(v, Jwks::new());
assert!(i.before(&CallContext::new("m")).await.is_err());
assert!(i
.before(&CallContext::new("m").with_http_header("authorization", "Basic x"))
.await
.is_err());
}
#[test]
fn der_uint_prepends_zero_when_high_bit_set() {
assert_eq!(der_uint(&[0x80]), vec![0x02, 0x02, 0x00, 0x80]);
assert_eq!(der_uint(&[0x7f]), vec![0x02, 0x01, 0x7f]);
assert_eq!(der_uint(&[0x00, 0x01]), vec![0x02, 0x01, 0x01]);
}
#[test]
fn der_tlv_long_form_length() {
let content = vec![0xabu8; 300];
let tlv = der_tlv(0x04, &content);
assert_eq!(&tlv[..4], &[0x04, 0x82, 0x01, 0x2c]);
assert_eq!(tlv.len(), 4 + 300);
}
#[test]
fn jwks_skips_enc_and_unknown_keys() {
let json = format!(
r#"{{"keys":[
{{"kty":"RSA","kid":"enc1","use":"enc","n":"{RS256_N}","e":"{RS256_E}"}},
{{"kty":"oct","kid":"sym","k":"abc"}},
{{"kty":"EC","crv":"P-384","kid":"e384","x":"{ES256_X}","y":"{ES256_Y}"}},
{{"kty":"RSA","kid":"sig1","use":"sig","n":"{RS256_N}","e":"{RS256_E}"}}
]}}"#
);
let jwks = Jwks::from_json(json.as_bytes()).unwrap();
assert_eq!(jwks.keys.len(), 1);
assert_eq!(jwks.keys[0].kid.as_deref(), Some("sig1"));
}
#[test]
fn jwks_is_empty_reflects_key_count() {
assert!(Jwks::new().is_empty(), "a fresh key set is empty");
assert!(!rsa_jwks().is_empty(), "a key set with a key is not empty");
}
#[test]
fn jwks_from_json_loads_ec_p256_key() {
let json = format!(
r#"{{"keys":[{{"kty":"EC","crv":"P-256","kid":"ek1","x":"{ES256_X}","y":"{ES256_Y}"}}]}}"#
);
let jwks = Jwks::from_json(json.as_bytes()).unwrap();
assert_eq!(jwks.keys.len(), 1, "the P-256 key must be loaded");
assert_eq!(jwks.keys[0].kid.as_deref(), Some("ek1"));
}
#[test]
fn ec_p256_rejects_wrong_length_coordinate() {
let ok_y = ES256_Y;
let short_x = URL_SAFE_NO_PAD.encode([0u8; 31]);
assert!(
Jwks::new().with_ec_p256("k", &short_x, ok_y).is_err(),
"a 31-byte x coordinate must be rejected"
);
let long_y = URL_SAFE_NO_PAD.encode([0u8; 33]);
assert!(
Jwks::new().with_ec_p256("k", ES256_X, &long_y).is_err(),
"a 33-byte y coordinate must be rejected"
);
assert!(Jwks::new().with_ec_p256("k", ES256_X, ES256_Y).is_ok());
}
#[test]
fn debug_impls_render_type_and_redact_secrets() {
let jwks_dbg = format!("{:?}", rsa_jwks());
assert!(jwks_dbg.contains("Jwks"), "Jwks Debug: {jwks_dbg}");
assert!(jwks_dbg.contains("keys"), "Jwks Debug lists key count");
let secret = b"super-secret-value-1234567890";
let validator = base_validator().with_hs256_secret(secret.to_vec());
let v_dbg = format!("{validator:?}");
assert!(
v_dbg.contains("JwtValidator"),
"JwtValidator Debug: {v_dbg}"
);
assert!(v_dbg.contains("redacted"), "the secret must be redacted");
assert!(
!v_dbg.contains("super-secret"),
"the raw HS256 secret must never appear in Debug output"
);
let interceptor = JwtAuthInterceptor::new(validator, rsa_jwks());
let i_dbg = format!("{interceptor:?}");
assert!(
i_dbg.contains("JwtAuthInterceptor"),
"JwtAuthInterceptor Debug: {i_dbg}"
);
assert!(i_dbg.contains("static"), "static key source is labelled");
}
fn claims_at(exp: Option<u64>, nbf: Option<u64>) -> JwtClaims {
JwtClaims {
iss: None,
sub: None,
aud: None,
exp,
nbf,
}
}
#[test]
fn check_claims_require_exp_boundary() {
let strict = JwtValidator::new();
assert!(
strict
.check_claims_at(&claims_at(None, None), 1_000)
.is_err(),
"no exp must be rejected when exp is required"
);
let lax = JwtValidator::new().allow_missing_exp();
assert!(
lax.check_claims_at(&claims_at(None, None), 1_000).is_ok(),
"no exp must be accepted when exp is optional"
);
assert!(
strict
.check_claims_at(&claims_at(Some(2_000), None), 1_000)
.is_ok(),
"unexpired token passes"
);
assert!(
strict
.check_claims_at(&claims_at(Some(500), None), 1_000)
.is_err(),
"expired token fails (now past exp + leeway)"
);
}
#[test]
fn check_claims_nbf_boundary_is_strict() {
let v = JwtValidator::new()
.allow_missing_exp()
.with_leeway(std::time::Duration::ZERO);
assert!(
v.check_claims_at(&claims_at(None, Some(2_000)), 1_000)
.is_err(),
"a token whose nbf is in the future must be rejected"
);
assert!(
v.check_claims_at(&claims_at(None, Some(1_000)), 1_000)
.is_ok(),
"a token is valid at exactly its nbf instant"
);
assert!(
v.check_claims_at(&claims_at(None, Some(500)), 1_000)
.is_ok(),
"a token whose nbf is in the past is valid"
);
}
#[test]
fn check_claims_exp_boundary_is_fail_closed() {
let v = JwtValidator::new().with_leeway(std::time::Duration::ZERO);
assert!(
v.check_claims_at(&claims_at(Some(999), None), 1_000)
.is_err(),
"a token past its exp is expired"
);
assert!(
v.check_claims_at(&claims_at(Some(1_000), None), 1_000)
.is_err(),
"a token is expired at exactly its exp instant"
);
assert!(
v.check_claims_at(&claims_at(Some(1_001), None), 1_000)
.is_ok(),
"a token strictly before its exp is valid"
);
let lenient = JwtValidator::new().with_leeway(std::time::Duration::from_secs(60));
assert!(
lenient
.check_claims_at(&claims_at(Some(1_000), None), 1_059)
.is_ok(),
"within leeway of exp: still valid"
);
assert!(
lenient
.check_claims_at(&claims_at(Some(1_000), None), 1_060)
.is_err(),
"at exactly exp + leeway: expired (fail-closed)"
);
}
#[test]
fn cached_jwks_freshness_is_strict() {
let ttl = std::time::Duration::from_secs(3600);
assert!(
cache_is_fresh(std::time::Duration::from_secs(3599), ttl),
"an entry younger than its TTL is fresh"
);
assert!(
!cache_is_fresh(ttl, ttl),
"an entry at exactly its TTL is stale"
);
assert!(
!cache_is_fresh(std::time::Duration::from_secs(3601), ttl),
"an entry past its TTL is stale"
);
}
#[test]
fn matching_kid_bad_signature_is_rejected_not_keymiss() {
let outcome = base_validator().validate(RS256_WRONG_KEY, &rsa_jwks());
assert!(
matches!(outcome, Err(ValidateOutcome::Rejected)),
"matched-kid bad-signature must be Rejected, got {outcome:?}"
);
}
#[test]
fn no_kid_bad_signature_is_rejected_not_keymiss() {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
let parts: Vec<&str> = RS256_VALID.split('.').collect();
let token = format!("{header}.{}.{}", parts[1], parts[2]);
let outcome = base_validator().validate(&token, &rsa_jwks());
assert!(
matches!(outcome, Err(ValidateOutcome::Rejected)),
"no-kid bad-signature must be Rejected, got {outcome:?}"
);
}
#[test]
fn unknown_kid_is_keymiss() {
let outcome = base_validator().validate(RS256_UNKNOWN_KID, &rsa_jwks());
assert!(
matches!(outcome, Err(ValidateOutcome::KeyMiss)),
"unknown-kid must be KeyMiss, got {outcome:?}"
);
}
#[test]
fn der_tlv_short_and_long_form_lengths() {
assert_eq!(&der_tlv(0x04, &[0u8; 5])[..2], &[0x04, 0x05]);
assert_eq!(&der_tlv(0x04, &[0u8; 127])[..2], &[0x04, 0x7f]);
assert_eq!(&der_tlv(0x04, &[0u8; 128])[..3], &[0x04, 0x81, 0x80]);
assert_eq!(&der_tlv(0x04, &[0u8; 300])[..4], &[0x04, 0x82, 0x01, 0x2c]);
assert_eq!(der_tlv(0x02, &[0xAA, 0xBB]), vec![0x02, 0x02, 0xAA, 0xBB]);
}
#[test]
fn jwks_body_size_limit() {
assert!(!jwks_body_exceeds_limit(0, 200_000));
assert!(!jwks_body_exceeds_limit(0, 256 * 1024));
assert!(jwks_body_exceeds_limit(0, 256 * 1024 + 1));
assert!(jwks_body_exceeds_limit(256 * 1024, 1));
}
#[test]
fn jwt_interceptor_declares_that_it_authenticates() {
let interceptor = JwtAuthInterceptor::new(base_validator(), Jwks::new());
assert!(
interceptor.authenticates(),
"a JWT auth interceptor must declare itself as one"
);
let mut chain = crate::interceptor::ServerInterceptorChain::new();
chain.push(std::sync::Arc::new(JwtAuthInterceptor::new(
base_validator(),
Jwks::new(),
)));
assert!(
chain.has_authenticator(),
"a chain guarded by a JWT interceptor must satisfy the \
extended-agent-card authentication requirement"
);
}
}