use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use a2a_protocol_types::error::{A2aError, A2aResult, ErrorCode};
use crate::call_context::CallContext;
use crate::interceptor::ServerInterceptor;
#[cfg(feature = "auth-jwt")]
pub mod jwt;
#[cfg(feature = "auth-jwt")]
pub use jwt::{Jwks, JwtAuthInterceptor, JwtValidator};
pub(crate) fn auth_rejected() -> A2aError {
A2aError::new(ErrorCode::InvalidRequest, "authentication required")
}
#[must_use]
pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn any_constant_time_match(candidate: &[u8], allowed: &HashSet<Vec<u8>>) -> bool {
let mut matched = false;
for value in allowed {
matched |= constant_time_eq(candidate, value);
}
matched
}
pub struct ApiKeyAuthInterceptor {
header_name: String,
allowed: HashSet<Vec<u8>>,
}
impl ApiKeyAuthInterceptor {
#[must_use]
pub fn new<I, S>(keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
header_name: "x-api-key".to_owned(),
allowed: keys.into_iter().map(|k| k.into().into_bytes()).collect(),
}
}
#[must_use]
pub fn with_header(mut self, header_name: impl Into<String>) -> Self {
self.header_name = header_name.into().to_ascii_lowercase();
self
}
}
impl std::fmt::Debug for ApiKeyAuthInterceptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKeyAuthInterceptor")
.field("header_name", &self.header_name)
.field("allowed_keys", &self.allowed.len())
.finish()
}
}
impl ServerInterceptor for ApiKeyAuthInterceptor {
fn before<'a>(
&'a self,
ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let key = ctx
.http_headers()
.get(&self.header_name)
.ok_or_else(auth_rejected)?;
if any_constant_time_match(key.as_bytes(), &self.allowed) {
Ok(())
} else {
Err(auth_rejected())
}
})
}
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
}
}
pub struct BearerTokenAuthInterceptor {
allowed: HashSet<Vec<u8>>,
}
impl BearerTokenAuthInterceptor {
#[must_use]
pub fn new<I, S>(tokens: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed: tokens.into_iter().map(|t| t.into().into_bytes()).collect(),
}
}
}
impl std::fmt::Debug for BearerTokenAuthInterceptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BearerTokenAuthInterceptor")
.field("allowed_tokens", &self.allowed.len())
.finish()
}
}
pub(crate) fn extract_bearer(auth_header: &str) -> Option<&str> {
let rest = auth_header.strip_prefix("Bearer ").or_else(|| {
let (scheme, rest) = auth_header.split_once(' ')?;
scheme.eq_ignore_ascii_case("bearer").then_some(rest)
})?;
let token = rest.trim();
(!token.is_empty()).then_some(token)
}
impl ServerInterceptor for BearerTokenAuthInterceptor {
fn before<'a>(
&'a self,
ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let header = ctx
.http_headers()
.get("authorization")
.ok_or_else(auth_rejected)?;
let token = extract_bearer(header).ok_or_else(auth_rejected)?;
if any_constant_time_match(token.as_bytes(), &self.allowed) {
Ok(())
} else {
Err(auth_rejected())
}
})
}
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
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AuthenticatedPrincipal {
pub subject: Option<String>,
pub issuer: Option<String>,
}
pub type SharedPrincipal = Arc<AuthenticatedPrincipal>;
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with(header: &str, value: &str) -> CallContext {
CallContext::new("message/send").with_http_header(header, value)
}
#[test]
fn constant_time_eq_matches_and_rejects() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn extract_bearer_variants() {
assert_eq!(extract_bearer("Bearer tok"), Some("tok"));
assert_eq!(extract_bearer("bearer tok"), Some("tok"));
assert_eq!(extract_bearer("BEARER tok "), Some("tok"));
assert_eq!(extract_bearer("Basic tok"), None);
assert_eq!(extract_bearer("Bearer "), None);
assert_eq!(extract_bearer("Bearer"), None);
assert_eq!(extract_bearer(""), None);
}
#[tokio::test]
async fn api_key_accepts_allowed_and_rejects_others() {
let i = ApiKeyAuthInterceptor::new(["key-1", "key-2"]);
assert!(i.before(&ctx_with("x-api-key", "key-1")).await.is_ok());
assert!(i.before(&ctx_with("x-api-key", "key-2")).await.is_ok());
assert!(i.before(&ctx_with("x-api-key", "nope")).await.is_err());
assert!(i.before(&CallContext::new("m")).await.is_err());
}
#[tokio::test]
async fn api_key_custom_header() {
let i = ApiKeyAuthInterceptor::new(["k"]).with_header("X-Company-Key");
assert!(i.before(&ctx_with("x-company-key", "k")).await.is_ok());
assert!(i.before(&ctx_with("x-api-key", "k")).await.is_err());
}
#[tokio::test]
async fn bearer_accepts_allowed_and_rejects_others() {
let i = BearerTokenAuthInterceptor::new(["tok-a", "tok-b"]);
assert!(i
.before(&ctx_with("authorization", "Bearer tok-a"))
.await
.is_ok());
assert!(i
.before(&ctx_with("authorization", "bearer tok-b"))
.await
.is_ok());
assert!(i
.before(&ctx_with("authorization", "Bearer wrong"))
.await
.is_err());
assert!(i
.before(&ctx_with("authorization", "Basic tok-a"))
.await
.is_err());
assert!(i.before(&CallContext::new("m")).await.is_err());
}
#[tokio::test]
async fn rejection_message_is_generic() {
let i = BearerTokenAuthInterceptor::new(["tok"]);
let missing = i.before(&CallContext::new("m")).await.unwrap_err();
let wrong = i
.before(&ctx_with("authorization", "Bearer nope"))
.await
.unwrap_err();
assert_eq!(missing.message, wrong.message);
assert_eq!(missing.message, "authentication required");
}
#[test]
fn debug_impls_render_type_and_redact_secrets() {
let api = ApiKeyAuthInterceptor::new(["super-secret-api-key"]).with_header("X-Company-Key");
let api_dbg = format!("{api:?}");
assert!(
api_dbg.contains("ApiKeyAuthInterceptor"),
"ApiKey Debug: {api_dbg}"
);
assert!(
api_dbg.contains("x-company-key"),
"header name is shown (lowercased)"
);
assert!(
!api_dbg.contains("super-secret-api-key"),
"raw API keys must never appear in Debug output"
);
let bearer = BearerTokenAuthInterceptor::new(["super-secret-bearer-token"]);
let bearer_dbg = format!("{bearer:?}");
assert!(
bearer_dbg.contains("BearerTokenAuthInterceptor"),
"Bearer Debug: {bearer_dbg}"
);
assert!(
!bearer_dbg.contains("super-secret-bearer-token"),
"raw bearer tokens must never appear in Debug output"
);
}
#[test]
fn api_key_interceptor_declares_that_it_authenticates() {
let interceptor = ApiKeyAuthInterceptor::new(["k1"]);
assert!(
interceptor.authenticates(),
"an auth interceptor must declare itself as one, or a chain \
containing only it reports no authenticator"
);
let mut chain = crate::interceptor::ServerInterceptorChain::new();
chain.push(std::sync::Arc::new(ApiKeyAuthInterceptor::new(["k1"])));
assert!(
chain.has_authenticator(),
"a chain guarded by an API-key interceptor must satisfy the \
extended-agent-card authentication requirement"
);
}
}