use crate::{AuthError, Authenticator, Headers, Identity};
use async_trait::async_trait;
use tracing::instrument;
#[cfg(any(feature = "test-fixtures", test))]
pub struct AllowAnonymous;
#[cfg(any(feature = "test-fixtures", test))]
#[async_trait]
impl Authenticator for AllowAnonymous {
#[instrument(
skip_all,
fields(
klieo.auth.principal_hash = tracing::field::Empty,
klieo.auth.scopes_count = tracing::field::Empty,
),
err,
)]
async fn authenticate(
&self,
_headers: &dyn Headers,
_payload: &[u8],
) -> Result<Identity, AuthError> {
let identity = Identity::anonymous();
let span = tracing::Span::current();
span.record(
"klieo.auth.principal_hash",
klieo_core::principal_hash(identity.as_str()).as_str(),
);
span.record("klieo.auth.scopes_count", identity.scopes().len());
Ok(identity)
}
fn allows_anonymous(&self) -> bool {
true
}
}
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 {
#[instrument(
skip_all,
fields(
klieo.auth.principal_hash = tracing::field::Empty,
klieo.auth.scopes_count = tracing::field::Empty,
),
err,
)]
async fn authenticate(
&self,
headers: &dyn Headers,
_payload: &[u8],
) -> Result<Identity, AuthError> {
let header = headers.get("authorization").ok_or(AuthError::Missing)?;
let token = strip_bearer_prefix(header).ok_or(AuthError::Malformed)?;
let identity = (self.verifier)(token)?;
let span = tracing::Span::current();
span.record(
"klieo.auth.principal_hash",
klieo_core::principal_hash(identity.as_str()).as_str(),
);
span.record("klieo.auth.scopes_count", identity.scopes().len());
Ok(identity)
}
}
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 std::collections::HashMap;
struct MapHeaders(HashMap<String, String>);
impl MapHeaders {
fn new() -> Self {
Self(HashMap::new())
}
fn with(mut self, name: &str, value: &str) -> Self {
self.0.insert(name.to_ascii_lowercase(), value.to_string());
self
}
}
impl Headers for MapHeaders {
fn get(&self, name: &str) -> Option<&str> {
self.0.get(&name.to_ascii_lowercase()).map(|s| s.as_str())
}
}
#[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 headers = MapHeaders::new();
let id = authn
.authenticate(&headers, 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 headers = MapHeaders::new();
let err = authn.authenticate(&headers, 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 headers = MapHeaders::new().with("Authorization", "Basic abc");
let err = authn.authenticate(&headers, 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 headers = MapHeaders::new().with("Authorization", header);
authn
.authenticate(&headers, 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 headers = MapHeaders::new().with("Authorization", "Bearer ");
let err = authn.authenticate(&headers, 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 headers = MapHeaders::new().with("Authorization", "Bearer good");
let ok_id = authn.authenticate(&headers, b"{}").await.unwrap();
assert_eq!(ok_id.as_str(), "alice");
let bad = MapHeaders::new().with("Authorization", "Bearer bad");
let err = authn.authenticate(&bad, b"{}").await.unwrap_err();
assert!(matches!(err, AuthError::Rejected(_)));
}
#[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");
}
}