use crate::envelope::A2aHeaders;
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Identity(String);
impl Identity {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn anonymous() -> Self {
Self("anonymous".into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_anonymous(&self) -> bool {
self.0 == "anonymous"
}
}
#[derive(Debug, Clone)]
pub struct RequestContext {
pub headers: A2aHeaders,
pub caller: Option<Identity>,
}
impl RequestContext {
pub fn new(headers: A2aHeaders, caller: Option<Identity>) -> Self {
Self { headers, caller }
}
pub fn is_authenticated(&self) -> bool {
self.caller.is_some()
}
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("missing Authorization header")]
Missing,
#[error("malformed Authorization header")]
Malformed,
#[error("verification failed: {0}")]
Rejected(String),
}
#[async_trait]
pub trait Authenticator: Send + Sync {
async fn authenticate(
&self,
headers: &A2aHeaders,
payload: &[u8],
) -> Result<Identity, AuthError>;
}
#[cfg(any(feature = "test-fixtures", test))]
pub struct AllowAnonymous;
#[cfg(any(feature = "test-fixtures", test))]
#[async_trait]
impl Authenticator for AllowAnonymous {
async fn authenticate(
&self,
_headers: &A2aHeaders,
_payload: &[u8],
) -> Result<Identity, AuthError> {
Ok(Identity::anonymous())
}
}
pub type BearerVerifier = Box<dyn Fn(&str) -> Result<Identity, AuthError> + Send + Sync>;
pub struct BearerTokenAuthenticator {
verifier: BearerVerifier,
}
impl BearerTokenAuthenticator {
pub fn new<F>(verifier: F) -> Self
where
F: Fn(&str) -> Result<Identity, AuthError> + Send + Sync + 'static,
{
Self {
verifier: Box::new(verifier),
}
}
}
#[async_trait]
impl Authenticator for BearerTokenAuthenticator {
async fn authenticate(
&self,
headers: &A2aHeaders,
_payload: &[u8],
) -> Result<Identity, AuthError> {
let header = headers.authorization.as_deref().ok_or(AuthError::Missing)?;
let token = strip_bearer_prefix(header).ok_or(AuthError::Malformed)?;
(self.verifier)(token)
}
}
fn strip_bearer_prefix(header: &str) -> Option<&str> {
let (scheme, rest) = header.split_once(|c: char| c.is_ascii_whitespace())?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
let token = rest.trim_start();
if token.is_empty() {
None
} else {
Some(token)
}
}
#[cfg(test)]
mod tests {
use super::*;
use klieo_core::Headers;
fn headers_with(auth: Option<&str>) -> A2aHeaders {
let mut h = Headers::new();
if let Some(value) = auth {
h.insert("Authorization".into(), value.into());
}
A2aHeaders::decode_from(&h)
}
#[tokio::test]
async fn identity_anonymous_round_trips() {
let id = Identity::anonymous();
assert!(id.is_anonymous());
assert_eq!(id.as_str(), "anonymous");
}
#[tokio::test]
async fn allow_anonymous_returns_anonymous_identity_for_any_request() {
let authn = AllowAnonymous;
let id = authn
.authenticate(&headers_with(None), b"{}")
.await
.expect("AllowAnonymous never errors");
assert!(id.is_anonymous());
}
#[tokio::test]
async fn bearer_rejects_missing_authorization_header() {
let authn = BearerTokenAuthenticator::new(|_| Ok(Identity::new("never-called")));
let err = authn
.authenticate(&headers_with(None), b"{}")
.await
.unwrap_err();
assert!(matches!(err, AuthError::Missing));
}
#[tokio::test]
async fn bearer_rejects_wrong_scheme() {
let authn = BearerTokenAuthenticator::new(|_| Ok(Identity::new("never-called")));
let err = authn
.authenticate(&headers_with(Some("Basic abc")), b"{}")
.await
.unwrap_err();
assert!(matches!(err, AuthError::Malformed));
}
#[tokio::test]
async fn bearer_accepts_case_insensitive_scheme() {
let authn = BearerTokenAuthenticator::new(|tok| {
assert_eq!(tok, "abc");
Ok(Identity::new("alice"))
});
for header in ["bearer abc", "BEARER abc", "BeArEr abc", "Bearer\tabc"] {
authn
.authenticate(&headers_with(Some(header)), b"{}")
.await
.unwrap_or_else(|e| panic!("header {header:?} rejected: {e:?}"));
}
}
#[tokio::test]
async fn bearer_rejects_empty_token() {
let authn = BearerTokenAuthenticator::new(|_| Ok(Identity::new("never-called")));
let err = authn
.authenticate(&headers_with(Some("Bearer ")), b"{}")
.await
.unwrap_err();
assert!(matches!(err, AuthError::Malformed));
}
#[tokio::test]
async fn bearer_delegates_token_to_verifier_and_returns_identity() {
let authn = BearerTokenAuthenticator::new(|tok| {
if tok == "good" {
Ok(Identity::new("alice"))
} else {
Err(AuthError::Rejected("bad".into()))
}
});
let ok_id = authn
.authenticate(&headers_with(Some("Bearer good")), b"{}")
.await
.unwrap();
assert_eq!(ok_id.as_str(), "alice");
let err = authn
.authenticate(&headers_with(Some("Bearer bad")), b"{}")
.await
.unwrap_err();
assert!(matches!(err, AuthError::Rejected(_)));
}
#[tokio::test]
async fn request_context_authenticated_flag_reflects_caller() {
let ctx = RequestContext::new(headers_with(None), Some(Identity::new("alice")));
assert!(ctx.is_authenticated());
let ctx = RequestContext::new(headers_with(None), None);
assert!(!ctx.is_authenticated());
}
}