use crate::envelope::A2aHeaders;
use klieo_auth_common::Identity as AuthIdentity;
#[derive(Debug, Clone)]
pub struct RequestContext {
pub headers: A2aHeaders,
pub caller: Option<AuthIdentity>,
pub cancel: tokio_util::sync::CancellationToken,
}
impl RequestContext {
pub fn new(headers: A2aHeaders, caller: Option<AuthIdentity>) -> Self {
Self {
headers,
caller,
cancel: tokio_util::sync::CancellationToken::new(),
}
}
#[must_use]
pub fn with_cancel(mut self, cancel: tokio_util::sync::CancellationToken) -> Self {
self.cancel = cancel;
self
}
pub fn is_authenticated(&self) -> bool {
self.caller.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::envelope::A2aHeaders;
use async_trait::async_trait;
#[allow(deprecated)]
use klieo_auth_common::{AllowAnonymous, AuthError, Authenticator, BearerTokenAuthenticator};
use klieo_auth_common::{Headers as AuthHeaders, Identity};
use tokio_util::sync::CancellationToken;
fn headers_with(auth: Option<&str>) -> A2aHeaders {
let mut h = klieo_core::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 h = headers_with(None);
let id = authn
.authenticate(&h as &dyn AuthHeaders, 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 h = headers_with(None);
let err = authn
.authenticate(&h as &dyn AuthHeaders, 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 h = headers_with(Some("Basic abc"));
let err = authn
.authenticate(&h as &dyn AuthHeaders, 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"] {
let h = headers_with(Some(header));
authn
.authenticate(&h as &dyn AuthHeaders, 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 h = headers_with(Some("Bearer "));
let err = authn
.authenticate(&h as &dyn AuthHeaders, 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 = headers_with(Some("Bearer good"));
let ok_id = authn
.authenticate(&ok as &dyn AuthHeaders, b"{}")
.await
.unwrap();
assert_eq!(ok_id.as_str(), "alice");
let bad = headers_with(Some("Bearer bad"));
let err = authn
.authenticate(&bad as &dyn AuthHeaders, 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());
}
#[test]
fn request_context_with_cancel_overrides_default() {
let token = CancellationToken::new();
let ctx = RequestContext::new(headers_with(None), None).with_cancel(token.clone());
token.cancel();
assert!(ctx.cancel.is_cancelled());
}
#[test]
fn request_context_new_has_fresh_uncancelled_token() {
let ctx = RequestContext::new(headers_with(None), None);
assert!(!ctx.cancel.is_cancelled());
}
#[tokio::test]
async fn allow_anonymous_authorize_method_returns_ok_for_any_method() {
let authn = AllowAnonymous;
let id = Identity::anonymous();
for method in ["SendMessage", "GetTask", "CancelTask"] {
authn
.authorize_method(&id, method)
.await
.unwrap_or_else(|e| panic!("AllowAnonymous rejected {method:?}: {e:?}"));
}
}
#[tokio::test]
async fn bearer_authorize_method_default_impl_returns_ok() {
let authn = BearerTokenAuthenticator::new(|_| Ok(Identity::new("never-called")));
let id = Identity::new("alice");
authn
.authorize_method(&id, "SendMessage")
.await
.expect("default authorize_method impl returns Ok");
}
#[tokio::test]
async fn custom_authenticator_can_reject_method_via_authorize_method() {
struct ScopeGatedAuthn;
#[async_trait]
impl Authenticator for ScopeGatedAuthn {
async fn authenticate(
&self,
_headers: &dyn AuthHeaders,
_payload: &[u8],
) -> Result<Identity, AuthError> {
Ok(Identity::new("scoped-caller"))
}
async fn authorize_method(
&self,
_identity: &Identity,
method: &str,
) -> Result<(), AuthError> {
if method == "CancelTask" {
Err(AuthError::Rejected("scope mismatch".into()))
} else {
Ok(())
}
}
}
let authn = ScopeGatedAuthn;
let id = Identity::new("scoped-caller");
authn
.authorize_method(&id, "SendMessage")
.await
.expect("SendMessage permitted by scope");
let err = authn
.authorize_method(&id, "CancelTask")
.await
.expect_err("CancelTask must be rejected");
assert!(matches!(err, AuthError::Rejected(msg) if msg == "scope mismatch"));
}
}