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
}
type LabelledCredential = (Vec<u8>, Option<String>);
#[derive(Debug, PartialEq, Eq)]
enum CredentialMatch<'a> {
NoMatch,
Unnamed,
Named(&'a str),
}
fn labelled_constant_time_match<'a>(
candidate: &[u8],
allowed: &'a [LabelledCredential],
) -> CredentialMatch<'a> {
let mut selected = usize::MAX;
for (index, (value, _)) in allowed.iter().enumerate() {
let hit = constant_time_eq(candidate, value);
let mask = 0_usize.wrapping_sub(usize::from(hit));
selected = (selected & !mask) | (index & mask);
}
match allowed.get(selected) {
None => CredentialMatch::NoMatch,
Some((_, None)) => CredentialMatch::Unnamed,
Some((_, Some(label))) => CredentialMatch::Named(label),
}
}
pub struct ApiKeyAuthInterceptor {
header_name: String,
allowed: Vec<LabelledCredential>,
}
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(), None))
.collect(),
}
}
#[must_use]
pub fn with_labelled_keys<I, K, L>(entries: I) -> Self
where
I: IntoIterator<Item = (K, L)>,
K: Into<String>,
L: Into<String>,
{
Self {
header_name: "x-api-key".to_owned(),
allowed: entries
.into_iter()
.map(|(k, label)| (k.into().into_bytes(), Some(label.into())))
.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)?;
match labelled_constant_time_match(key.as_bytes(), &self.allowed) {
CredentialMatch::NoMatch => Err(auth_rejected()),
CredentialMatch::Unnamed => Ok(()),
CredentialMatch::Named(identity) => {
ctx.set_caller_identity(identity);
Ok(())
}
}
})
}
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: Vec<LabelledCredential>,
}
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(), None))
.collect(),
}
}
#[must_use]
pub fn with_labelled_tokens<I, T, L>(entries: I) -> Self
where
I: IntoIterator<Item = (T, L)>,
T: Into<String>,
L: Into<String>,
{
Self {
allowed: entries
.into_iter()
.map(|(t, label)| (t.into().into_bytes(), Some(label.into())))
.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)?;
match labelled_constant_time_match(token.as_bytes(), &self.allowed) {
CredentialMatch::NoMatch => Err(auth_rejected()),
CredentialMatch::Unnamed => Ok(()),
CredentialMatch::Named(identity) => {
ctx.set_caller_identity(identity);
Ok(())
}
}
})
}
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 identity_tests;
#[cfg(test)]
mod tests;