camel-auth 0.12.0

Provider-neutral authentication and claim mapping for rust-camel
Documentation
use std::sync::Arc;

use async_trait::async_trait;
use camel_api::CamelError;
use camel_api::security_policy::Principal;

use crate::claims::ClaimsMapper;
use crate::introspection::TokenIntrospector;
use crate::token_authenticator::TokenAuthenticator;
use crate::types::AuthError;

pub struct IntrospectionAuthenticator {
    introspector: Arc<dyn TokenIntrospector>,
    claims_mapper: Arc<dyn ClaimsMapper>,
}

impl IntrospectionAuthenticator {
    pub fn new(
        introspector: Arc<dyn TokenIntrospector>,
        claims_mapper: Arc<dyn ClaimsMapper>,
    ) -> Self {
        Self {
            introspector,
            claims_mapper,
        }
    }
}

#[async_trait]
impl TokenAuthenticator for IntrospectionAuthenticator {
    async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
        let result = self.introspector.introspect(token).await?;
        if !result.active {
            return Err(AuthError::TokenInvalid("token is not active".into()).into());
        }
        let claims = serde_json::to_value(&result).map_err(|e| {
            AuthError::ConfigError(format!("introspection result serialization failed: {e}"))
        })?;
        self.claims_mapper
            .to_principal(&claims)
            .map_err(CamelError::from)
    }
}

impl std::fmt::Debug for IntrospectionAuthenticator {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("IntrospectionAuthenticator")
            .field("introspector", &"<TokenIntrospector>")
            .field("claims_mapper", &"<ClaimsMapper>")
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
    use crate::introspection::IntrospectionResult;
    use crate::types::AuthError;
    use serde_json::{Map, json};

    struct MockIntrospector {
        result: IntrospectionResult,
    }

    #[async_trait]
    impl TokenIntrospector for MockIntrospector {
        async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
            Ok(self.result.clone())
        }
    }

    fn keycloak_mapper() -> Arc<dyn ClaimsMapper> {
        let paths = ClaimPaths {
            subject: "/sub".into(),
            roles: vec![
                "/realm_access/roles".into(),
                "/resource_access/my-client/roles".into(),
            ],
            scopes: Some("/scope".into()),
        };
        Arc::new(JsonPointerClaimsMapper::new(paths))
    }

    #[tokio::test]
    async fn active_token_maps_to_principal() {
        let introspector = MockIntrospector {
            result: IntrospectionResult {
                active: true,
                sub: Some("user-1".into()),
                exp: None,
                iat: None,
                nbf: None,
                scope: Some("read write".into()),
                client_id: None,
                token_type: None,
                iss: Some("https://kc.example.com/realms/test".into()),
                aud: None,
                extra: {
                    let mut m = Map::new();
                    m.insert("realm_access".into(), json!({"roles": ["admin", "user"]}));
                    m.insert(
                        "resource_access".into(),
                        json!({"my-client": {"roles": ["client-role"]}}),
                    );
                    m
                },
            },
        };
        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
        let principal = auth.authenticate_bearer("opaque-token").await.unwrap();
        assert_eq!(principal.subject, "user-1");
        assert!(principal.has_role("admin"));
        assert!(principal.has_role("client-role"));
        assert_eq!(principal.scopes, vec!["read", "write"]);
    }

    #[tokio::test]
    async fn inactive_token_returns_unauthenticated() {
        let introspector = MockIntrospector {
            result: IntrospectionResult {
                active: false,
                sub: None,
                exp: None,
                iat: None,
                nbf: None,
                scope: None,
                client_id: None,
                token_type: None,
                iss: None,
                aud: None,
                extra: Map::new(),
            },
        };
        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
        let err = auth.authenticate_bearer("dead-token").await.unwrap_err();
        match err {
            CamelError::Unauthenticated(msg) => assert!(msg.contains("not active")),
            other => panic!("expected Unauthenticated, got: {other:?}"),
        }
    }

    #[tokio::test]
    async fn introspection_provider_error_propagates() {
        struct FailingIntrospector;
        #[async_trait]
        impl TokenIntrospector for FailingIntrospector {
            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
                Err(AuthError::ProviderUnavailable("connection refused".into()))
            }
        }
        let auth =
            IntrospectionAuthenticator::new(Arc::new(FailingIntrospector), keycloak_mapper());
        let err = auth.authenticate_bearer("tok").await.unwrap_err();
        match err {
            CamelError::ProcessorError(msg) => {
                assert!(msg.contains("auth provider unavailable"));
            }
            other => panic!("expected ProcessorError, got: {other:?}"),
        }
    }

    #[tokio::test]
    async fn missing_subject_returns_token_invalid() {
        let introspector = MockIntrospector {
            result: IntrospectionResult {
                active: true,
                sub: None,
                exp: None,
                iat: None,
                nbf: None,
                scope: None,
                client_id: None,
                token_type: None,
                iss: None,
                aud: None,
                extra: Map::new(),
            },
        };
        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
        let err = auth.authenticate_bearer("tok").await.unwrap_err();
        match err {
            CamelError::Unauthenticated(msg) => assert!(msg.contains("subject")),
            other => panic!("expected Unauthenticated, got: {other:?}"),
        }
    }
}