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
37        let now = std::time::SystemTime::now()
38            .duration_since(std::time::UNIX_EPOCH)
39            .map(|d| d.as_secs())
40            .unwrap_or(u64::MAX);
41
42        if let Some(exp) = result.exp
43            && exp < now
44        {
45            return Err(AuthError::TokenExpired.into());
46        }
47
48        if let Some(nbf) = result.nbf
49            && nbf > now
50        {
51            return Err(AuthError::TokenInvalid(
52                "token not yet valid (introspection nbf check)".into(),
53            )
54            .into());
55        }
56
57        let claims = serde_json::to_value(&result).map_err(|e| {
58            AuthError::ConfigError(format!("introspection result serialization failed: {e}"))
59        })?;
60        self.claims_mapper
61            .to_principal(&claims)
62            .map_err(CamelError::from)
63    }
64}
65
66impl std::fmt::Debug for IntrospectionAuthenticator {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("IntrospectionAuthenticator")
69            .field("introspector", &"<TokenIntrospector>")
70            .field("claims_mapper", &"<ClaimsMapper>")
71            .finish()
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
79    use crate::introspection::IntrospectionResult;
80    use crate::types::AuthError;
81    use serde_json::{Map, json};
82
83    struct MockIntrospector {
84        result: IntrospectionResult,
85    }
86
87    #[async_trait]
88    impl TokenIntrospector for MockIntrospector {
89        async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
90            Ok(self.result.clone())
91        }
92    }
93
94    fn keycloak_mapper() -> Arc<dyn ClaimsMapper> {
95        let paths = ClaimPaths {
96            subject: "/sub".into(),
97            roles: vec![
98                "/realm_access/roles".into(),
99                "/resource_access/my-client/roles".into(),
100            ],
101            scopes: Some("/scope".into()),
102        };
103        Arc::new(JsonPointerClaimsMapper::new(paths))
104    }
105
106    #[tokio::test]
107    async fn active_token_maps_to_principal() {
108        let introspector = MockIntrospector {
109            result: IntrospectionResult {
110                active: true,
111                sub: Some("user-1".into()),
112                exp: None,
113                iat: None,
114                nbf: None,
115                scope: Some("read write".into()),
116                client_id: None,
117                token_type: None,
118                iss: Some("https://kc.example.com/realms/test".into()),
119                aud: None,
120                extra: {
121                    let mut m = Map::new();
122                    m.insert("realm_access".into(), json!({"roles": ["admin", "user"]}));
123                    m.insert(
124                        "resource_access".into(),
125                        json!({"my-client": {"roles": ["client-role"]}}),
126                    );
127                    m
128                },
129            },
130        };
131        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
132        let principal = auth.authenticate_bearer("opaque-token").await.unwrap();
133        assert_eq!(principal.subject, "user-1");
134        assert!(principal.has_role("admin"));
135        assert!(principal.has_role("client-role"));
136        assert_eq!(principal.scopes, vec!["read", "write"]);
137    }
138
139    #[tokio::test]
140    async fn inactive_token_returns_unauthenticated() {
141        let introspector = MockIntrospector {
142            result: IntrospectionResult {
143                active: false,
144                sub: None,
145                exp: None,
146                iat: None,
147                nbf: None,
148                scope: None,
149                client_id: None,
150                token_type: None,
151                iss: None,
152                aud: None,
153                extra: Map::new(),
154            },
155        };
156        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
157        let err = auth.authenticate_bearer("dead-token").await.unwrap_err();
158        match err {
159            CamelError::Unauthenticated(msg) => assert!(msg.contains("not active")),
160            other => panic!("expected Unauthenticated, got: {other:?}"),
161        }
162    }
163
164    #[tokio::test]
165    async fn introspection_provider_error_propagates() {
166        struct FailingIntrospector;
167        #[async_trait]
168        impl TokenIntrospector for FailingIntrospector {
169            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
170                Err(AuthError::ProviderUnavailable("connection refused".into()))
171            }
172        }
173        let auth =
174            IntrospectionAuthenticator::new(Arc::new(FailingIntrospector), keycloak_mapper());
175        let err = auth.authenticate_bearer("tok").await.unwrap_err();
176        match err {
177            CamelError::ProcessorError(msg) => {
178                assert!(msg.contains("auth provider unavailable"));
179            }
180            other => panic!("expected ProcessorError, got: {other:?}"),
181        }
182    }
183
184    #[tokio::test]
185    async fn introspection_rejects_expired_token() {
186        let now = std::time::SystemTime::now()
187            .duration_since(std::time::UNIX_EPOCH)
188            .unwrap()
189            .as_secs();
190
191        struct ExpiredIntrospector(u64);
192        #[async_trait]
193        impl TokenIntrospector for ExpiredIntrospector {
194            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
195                Ok(IntrospectionResult {
196                    active: true,
197                    sub: Some("user-1".into()),
198                    exp: Some(self.0 - 3600), // expired 1h ago
199                    iat: None,
200                    nbf: None,
201                    scope: Some("read".into()),
202                    client_id: None,
203                    token_type: None,
204                    iss: None,
205                    aud: None,
206                    extra: Map::new(),
207                })
208            }
209        }
210
211        let auth =
212            IntrospectionAuthenticator::new(Arc::new(ExpiredIntrospector(now)), keycloak_mapper());
213        let err = auth.authenticate_bearer("test-token").await.unwrap_err();
214        match err {
215            CamelError::Unauthenticated(msg) => {
216                assert!(
217                    msg.contains("expired"),
218                    "expected 'expired' in error, got: {msg}"
219                )
220            }
221            other => panic!("expected Unauthenticated, got: {other:?}"),
222        }
223    }
224
225    #[tokio::test]
226    async fn introspection_rejects_not_yet_valid_token() {
227        let now = std::time::SystemTime::now()
228            .duration_since(std::time::UNIX_EPOCH)
229            .unwrap()
230            .as_secs();
231
232        struct FutureIntrospector(u64);
233        #[async_trait]
234        impl TokenIntrospector for FutureIntrospector {
235            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
236                Ok(IntrospectionResult {
237                    active: true,
238                    sub: Some("user-1".into()),
239                    exp: Some(self.0 + 7200), // valid for 2h
240                    iat: None,
241                    nbf: Some(self.0 + 3600), // not valid for 1h
242                    scope: Some("read".into()),
243                    client_id: None,
244                    token_type: None,
245                    iss: None,
246                    aud: None,
247                    extra: Map::new(),
248                })
249            }
250        }
251
252        let auth =
253            IntrospectionAuthenticator::new(Arc::new(FutureIntrospector(now)), keycloak_mapper());
254        let err = auth.authenticate_bearer("test-token").await.unwrap_err();
255        match err {
256            CamelError::Unauthenticated(msg) => {
257                assert!(
258                    msg.contains("not yet valid"),
259                    "expected 'not yet valid' in error, got: {msg}"
260                )
261            }
262            other => panic!("expected Unauthenticated, got: {other:?}"),
263        }
264    }
265
266    #[tokio::test]
267    async fn accepts_token_with_valid_exp_and_nbf() {
268        let now = std::time::SystemTime::now()
269            .duration_since(std::time::UNIX_EPOCH)
270            .unwrap()
271            .as_secs();
272
273        struct ValidIntrospector(u64);
274        #[async_trait]
275        impl TokenIntrospector for ValidIntrospector {
276            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
277                Ok(IntrospectionResult {
278                    active: true,
279                    sub: Some("user-1".into()),
280                    exp: Some(self.0 + 3600), // valid for 1h
281                    iat: None,
282                    nbf: Some(self.0 - 3600), // was valid 1h ago
283                    scope: Some("read".into()),
284                    client_id: None,
285                    token_type: None,
286                    iss: None,
287                    aud: None,
288                    extra: {
289                        let mut m = Map::new();
290                        m.insert("realm_access".into(), json!({"roles": ["user"]}));
291                        m
292                    },
293                })
294            }
295        }
296
297        let auth =
298            IntrospectionAuthenticator::new(Arc::new(ValidIntrospector(now)), keycloak_mapper());
299        let principal = auth.authenticate_bearer("test-token").await.unwrap();
300        assert_eq!(principal.subject, "user-1");
301        assert!(principal.has_role("user"));
302    }
303
304    #[tokio::test]
305    async fn missing_subject_returns_token_invalid() {
306        let introspector = MockIntrospector {
307            result: IntrospectionResult {
308                active: true,
309                sub: None,
310                exp: None,
311                iat: None,
312                nbf: None,
313                scope: None,
314                client_id: None,
315                token_type: None,
316                iss: None,
317                aud: None,
318                extra: Map::new(),
319            },
320        };
321        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
322        let err = auth.authenticate_bearer("tok").await.unwrap_err();
323        match err {
324            CamelError::Unauthenticated(msg) => assert!(msg.contains("subject")),
325            other => panic!("expected Unauthenticated, got: {other:?}"),
326        }
327    }
328}