Skip to main content

camel_auth/
introspection_auth.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use camel_api::CamelError;
5use camel_api::security_policy::Principal;
6
7use crate::claims::ClaimsMapper;
8use crate::introspection::TokenIntrospector;
9use crate::token_authenticator::TokenAuthenticator;
10use crate::types::AuthError;
11
12pub struct IntrospectionAuthenticator {
13    introspector: Arc<dyn TokenIntrospector>,
14    claims_mapper: Arc<dyn ClaimsMapper>,
15}
16
17impl IntrospectionAuthenticator {
18    pub fn new(
19        introspector: Arc<dyn TokenIntrospector>,
20        claims_mapper: Arc<dyn ClaimsMapper>,
21    ) -> Self {
22        Self {
23            introspector,
24            claims_mapper,
25        }
26    }
27}
28
29#[async_trait]
30impl TokenAuthenticator for IntrospectionAuthenticator {
31    async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
32        let result = self.introspector.introspect(token).await?;
33        if !result.active {
34            return Err(AuthError::TokenInvalid("token is not active".into()).into());
35        }
36        let claims = serde_json::to_value(&result).map_err(|e| {
37            AuthError::ConfigError(format!("introspection result serialization failed: {e}"))
38        })?;
39        self.claims_mapper
40            .to_principal(&claims)
41            .map_err(CamelError::from)
42    }
43}
44
45impl std::fmt::Debug for IntrospectionAuthenticator {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("IntrospectionAuthenticator")
48            .field("introspector", &"<TokenIntrospector>")
49            .field("claims_mapper", &"<ClaimsMapper>")
50            .finish()
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
58    use crate::introspection::IntrospectionResult;
59    use crate::types::AuthError;
60    use serde_json::{Map, json};
61
62    struct MockIntrospector {
63        result: IntrospectionResult,
64    }
65
66    #[async_trait]
67    impl TokenIntrospector for MockIntrospector {
68        async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
69            Ok(self.result.clone())
70        }
71    }
72
73    fn keycloak_mapper() -> Arc<dyn ClaimsMapper> {
74        let paths = ClaimPaths {
75            subject: "/sub".into(),
76            roles: vec![
77                "/realm_access/roles".into(),
78                "/resource_access/my-client/roles".into(),
79            ],
80            scopes: Some("/scope".into()),
81        };
82        Arc::new(JsonPointerClaimsMapper::new(paths))
83    }
84
85    #[tokio::test]
86    async fn active_token_maps_to_principal() {
87        let introspector = MockIntrospector {
88            result: IntrospectionResult {
89                active: true,
90                sub: Some("user-1".into()),
91                exp: None,
92                iat: None,
93                nbf: None,
94                scope: Some("read write".into()),
95                client_id: None,
96                token_type: None,
97                iss: Some("https://kc.example.com/realms/test".into()),
98                aud: None,
99                extra: {
100                    let mut m = Map::new();
101                    m.insert("realm_access".into(), json!({"roles": ["admin", "user"]}));
102                    m.insert(
103                        "resource_access".into(),
104                        json!({"my-client": {"roles": ["client-role"]}}),
105                    );
106                    m
107                },
108            },
109        };
110        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
111        let principal = auth.authenticate_bearer("opaque-token").await.unwrap();
112        assert_eq!(principal.subject, "user-1");
113        assert!(principal.has_role("admin"));
114        assert!(principal.has_role("client-role"));
115        assert_eq!(principal.scopes, vec!["read", "write"]);
116    }
117
118    #[tokio::test]
119    async fn inactive_token_returns_unauthenticated() {
120        let introspector = MockIntrospector {
121            result: IntrospectionResult {
122                active: false,
123                sub: None,
124                exp: None,
125                iat: None,
126                nbf: None,
127                scope: None,
128                client_id: None,
129                token_type: None,
130                iss: None,
131                aud: None,
132                extra: Map::new(),
133            },
134        };
135        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
136        let err = auth.authenticate_bearer("dead-token").await.unwrap_err();
137        match err {
138            CamelError::Unauthenticated(msg) => assert!(msg.contains("not active")),
139            other => panic!("expected Unauthenticated, got: {other:?}"),
140        }
141    }
142
143    #[tokio::test]
144    async fn introspection_provider_error_propagates() {
145        struct FailingIntrospector;
146        #[async_trait]
147        impl TokenIntrospector for FailingIntrospector {
148            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
149                Err(AuthError::ProviderUnavailable("connection refused".into()))
150            }
151        }
152        let auth =
153            IntrospectionAuthenticator::new(Arc::new(FailingIntrospector), keycloak_mapper());
154        let err = auth.authenticate_bearer("tok").await.unwrap_err();
155        match err {
156            CamelError::ProcessorError(msg) => {
157                assert!(msg.contains("auth provider unavailable"));
158            }
159            other => panic!("expected ProcessorError, got: {other:?}"),
160        }
161    }
162
163    #[tokio::test]
164    async fn missing_subject_returns_token_invalid() {
165        let introspector = MockIntrospector {
166            result: IntrospectionResult {
167                active: true,
168                sub: None,
169                exp: None,
170                iat: None,
171                nbf: None,
172                scope: None,
173                client_id: None,
174                token_type: None,
175                iss: None,
176                aud: None,
177                extra: Map::new(),
178            },
179        };
180        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
181        let err = auth.authenticate_bearer("tok").await.unwrap_err();
182        match err {
183            CamelError::Unauthenticated(msg) => assert!(msg.contains("subject")),
184            other => panic!("expected Unauthenticated, got: {other:?}"),
185        }
186    }
187}