klieo-auth-common 2.1.0

Shared authentication traits and types for klieo HTTP transports
Documentation
//! Built-in [`Authenticator`] implementations:
//! [`BearerTokenAuthenticator`] (production-ready) and
//! [`AllowAnonymous`] (test-fixture only).

use crate::{AuthError, Authenticator, Headers, Identity};
use async_trait::async_trait;
use tracing::instrument;

/// **TEST FIXTURE / DEMO ONLY.** Allows every request through with
/// [`Identity::anonymous`].
///
/// Wiring this into a production server is a security bug โ€” anyone
/// publishable on the agent's subject can invoke any handler method.
/// Use [`BearerTokenAuthenticator`] (or your own implementation) in
/// production.
///
/// Gated behind the `test-fixtures` feature (W2.A14 / CWE-1188): default
/// builds cannot reach the type, so production wiring fails to compile
/// rather than silently accepting any caller.
#[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
    }
}

/// Closure type for verifying a raw bearer token. Returns the verified
/// caller [`Identity`] on success.
pub type BearerVerifier = Box<dyn Fn(&str) -> Result<Identity, AuthError> + Send + Sync>;

/// Bearer-token authenticator: validates `Authorization: Bearer <token>`
/// against a caller-supplied verifier closure.
///
/// The closure receives the raw token string and returns a verified
/// [`Identity`] on success or [`AuthError::Rejected`] on failure. Wrap
/// your JWT, opaque-token, or Vault verification logic inside it โ€” the
/// closure runs on every request, so prefer in-process caches over
/// round-tripping a remote service.
///
/// ```ignore
/// use klieo_auth_common::{BearerTokenAuthenticator, Identity, AuthError};
///
/// let authn = BearerTokenAuthenticator::new(|token: &str| {
///     // Verify the JWT signature, exp, aud โ€” return Identity on success.
///     match my_jwt_verify(token) {
///         Ok(claims) => Ok(Identity::new(claims.sub)),
///         Err(e) => Err(AuthError::Rejected(e.to_string())),
///     }
/// });
/// ```
pub struct BearerTokenAuthenticator {
    verifier: BearerVerifier,
}

impl BearerTokenAuthenticator {
    /// Build a new bearer-token authenticator with the supplied verifier
    /// closure.
    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)
    }
}

/// Strip the `Bearer ` scheme case-insensitively per RFC 7235 ยง2.1.
/// Returns the trimmed token or `None` if the header doesn't begin with
/// `bearer` (any casing) followed by whitespace + a non-empty value.
/// W2.A14 โ€” old impl rejected `bearer ` / `BEARER ` / `Bearer\t`, which
/// is an interop bug rather than a security risk but masks legitimate
/// requests and pushes operators toward looser proxies.
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;

    /// Test-only `Headers` impl backed by a plain map.
    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));
    }

    /// W2.A14 / RFC 7235 ยง2.1: scheme matching must be case-insensitive.
    #[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");
    }
}