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::{AuthnRequest, 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    /// Introspect a token and enforce the request-scoped audience/issuer sets
29    /// against the introspected claims.
30    ///
31    /// Fail-closed by design: when a non-empty request set is present, an absent
32    /// `iss`/`aud` introspection claim rejects. RFC 7662 makes these claims
33    /// optional and Keycloak may not return `aud`, so a provider relying on
34    /// request-scoped audience enforcement must ensure the introspection
35    /// response carries the claim.
36    async fn introspect_principal(
37        &self,
38        token: &str,
39        audiences: &[String],
40        issuers: &[String],
41    ) -> Result<Principal, CamelError> {
42        let result = self.introspector.introspect(token).await?;
43        if !result.active {
44            return Err(AuthError::TokenInvalid("token is not active".into()).into());
45        }
46
47        let now = std::time::SystemTime::now()
48            .duration_since(std::time::UNIX_EPOCH)
49            .map(|d| d.as_secs())
50            .unwrap_or(u64::MAX);
51
52        if let Some(exp) = result.exp
53            && exp < now
54        {
55            return Err(AuthError::TokenExpired.into());
56        }
57
58        if let Some(nbf) = result.nbf
59            && nbf > now
60        {
61            return Err(AuthError::TokenInvalid(
62                "token not yet valid (introspection nbf check)".into(),
63            )
64            .into());
65        }
66
67        // Request-scoped issuer enforcement.
68        if !issuers.is_empty() {
69            let iss = result
70                .iss
71                .as_deref()
72                .ok_or_else(|| AuthError::TokenInvalid("token missing issuer claim".into()))?;
73            if !issuers.iter().any(|i| i == iss) {
74                return Err(AuthError::TokenInvalid("token issuer not accepted".into()).into());
75            }
76        }
77
78        // Request-scoped audience enforcement (aud may be a string or array).
79        if !audiences.is_empty() {
80            let aud_values: Vec<String> = match &result.aud {
81                Some(serde_json::Value::String(s)) => vec![s.clone()],
82                Some(serde_json::Value::Array(arr)) => arr
83                    .iter()
84                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
85                    .collect(),
86                _ => vec![],
87            };
88            if !aud_values.iter().any(|a| audiences.contains(a)) {
89                return Err(AuthError::TokenInvalid("token audience not accepted".into()).into());
90            }
91        }
92
93        let claims = serde_json::to_value(&result).map_err(|e| {
94            AuthError::ConfigError(format!("introspection result serialization failed: {e}"))
95        })?;
96        self.claims_mapper
97            .to_principal(&claims)
98            .map_err(CamelError::from)
99    }
100}
101
102#[async_trait]
103impl TokenAuthenticator for IntrospectionAuthenticator {
104    async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
105        self.introspect_principal(token, &[], &[]).await
106    }
107
108    async fn authenticate(&self, req: AuthnRequest<'_>) -> Result<Principal, CamelError> {
109        self.introspect_principal(req.token, req.audiences, req.accepted_issuers)
110            .await
111    }
112}
113
114impl std::fmt::Debug for IntrospectionAuthenticator {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("IntrospectionAuthenticator")
117            .field("introspector", &"<TokenIntrospector>")
118            .field("claims_mapper", &"<ClaimsMapper>")
119            .finish()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
127    use crate::introspection::IntrospectionResult;
128    use crate::types::AuthError;
129    use serde_json::{Map, json};
130
131    struct MockIntrospector {
132        result: IntrospectionResult,
133    }
134
135    #[async_trait]
136    impl TokenIntrospector for MockIntrospector {
137        async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
138            Ok(self.result.clone())
139        }
140    }
141
142    fn keycloak_mapper() -> Arc<dyn ClaimsMapper> {
143        let paths = ClaimPaths {
144            subject: "/sub".into(),
145            roles: vec![
146                "/realm_access/roles".into(),
147                "/resource_access/my-client/roles".into(),
148            ],
149            scopes: Some("/scope".into()),
150        };
151        Arc::new(JsonPointerClaimsMapper::new(paths))
152    }
153
154    #[tokio::test]
155    async fn active_token_maps_to_principal() {
156        let introspector = MockIntrospector {
157            result: IntrospectionResult {
158                active: true,
159                sub: Some("user-1".into()),
160                exp: None,
161                iat: None,
162                nbf: None,
163                scope: Some("read write".into()),
164                client_id: None,
165                token_type: None,
166                iss: Some("https://kc.example.com/realms/test".into()),
167                aud: None,
168                extra: {
169                    let mut m = Map::new();
170                    m.insert("realm_access".into(), json!({"roles": ["admin", "user"]}));
171                    m.insert(
172                        "resource_access".into(),
173                        json!({"my-client": {"roles": ["client-role"]}}),
174                    );
175                    m
176                },
177            },
178        };
179        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
180        let principal = auth.authenticate_bearer("opaque-token").await.unwrap();
181        assert_eq!(principal.subject, "user-1");
182        assert!(principal.has_role("admin"));
183        assert!(principal.has_role("client-role"));
184        assert_eq!(principal.scopes, vec!["read", "write"]);
185    }
186
187    #[tokio::test]
188    async fn inactive_token_returns_unauthenticated() {
189        let introspector = MockIntrospector {
190            result: IntrospectionResult {
191                active: false,
192                sub: None,
193                exp: None,
194                iat: None,
195                nbf: None,
196                scope: None,
197                client_id: None,
198                token_type: None,
199                iss: None,
200                aud: None,
201                extra: Map::new(),
202            },
203        };
204        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
205        let err = auth.authenticate_bearer("dead-token").await.unwrap_err();
206        match err {
207            CamelError::Unauthenticated(msg) => assert!(msg.contains("not active")),
208            other => panic!("expected Unauthenticated, got: {other:?}"),
209        }
210    }
211
212    #[tokio::test]
213    async fn introspection_provider_error_propagates() {
214        struct FailingIntrospector;
215        #[async_trait]
216        impl TokenIntrospector for FailingIntrospector {
217            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
218                Err(AuthError::ProviderUnavailable("connection refused".into()))
219            }
220        }
221        let auth =
222            IntrospectionAuthenticator::new(Arc::new(FailingIntrospector), keycloak_mapper());
223        let err = auth.authenticate_bearer("tok").await.unwrap_err();
224        match err {
225            CamelError::ProcessorError(msg) => {
226                assert!(msg.contains("auth provider unavailable"));
227            }
228            other => panic!("expected ProcessorError, got: {other:?}"),
229        }
230    }
231
232    #[tokio::test]
233    async fn introspection_rejects_expired_token() {
234        let now = std::time::SystemTime::now()
235            .duration_since(std::time::UNIX_EPOCH)
236            .unwrap()
237            .as_secs();
238
239        struct ExpiredIntrospector(u64);
240        #[async_trait]
241        impl TokenIntrospector for ExpiredIntrospector {
242            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
243                Ok(IntrospectionResult {
244                    active: true,
245                    sub: Some("user-1".into()),
246                    exp: Some(self.0 - 3600), // expired 1h ago
247                    iat: None,
248                    nbf: None,
249                    scope: Some("read".into()),
250                    client_id: None,
251                    token_type: None,
252                    iss: None,
253                    aud: None,
254                    extra: Map::new(),
255                })
256            }
257        }
258
259        let auth =
260            IntrospectionAuthenticator::new(Arc::new(ExpiredIntrospector(now)), keycloak_mapper());
261        let err = auth.authenticate_bearer("test-token").await.unwrap_err();
262        match err {
263            CamelError::Unauthenticated(msg) => {
264                assert!(
265                    msg.contains("expired"),
266                    "expected 'expired' in error, got: {msg}"
267                )
268            }
269            other => panic!("expected Unauthenticated, got: {other:?}"),
270        }
271    }
272
273    #[tokio::test]
274    async fn introspection_rejects_not_yet_valid_token() {
275        let now = std::time::SystemTime::now()
276            .duration_since(std::time::UNIX_EPOCH)
277            .unwrap()
278            .as_secs();
279
280        struct FutureIntrospector(u64);
281        #[async_trait]
282        impl TokenIntrospector for FutureIntrospector {
283            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
284                Ok(IntrospectionResult {
285                    active: true,
286                    sub: Some("user-1".into()),
287                    exp: Some(self.0 + 7200), // valid for 2h
288                    iat: None,
289                    nbf: Some(self.0 + 3600), // not valid for 1h
290                    scope: Some("read".into()),
291                    client_id: None,
292                    token_type: None,
293                    iss: None,
294                    aud: None,
295                    extra: Map::new(),
296                })
297            }
298        }
299
300        let auth =
301            IntrospectionAuthenticator::new(Arc::new(FutureIntrospector(now)), keycloak_mapper());
302        let err = auth.authenticate_bearer("test-token").await.unwrap_err();
303        match err {
304            CamelError::Unauthenticated(msg) => {
305                assert!(
306                    msg.contains("not yet valid"),
307                    "expected 'not yet valid' in error, got: {msg}"
308                )
309            }
310            other => panic!("expected Unauthenticated, got: {other:?}"),
311        }
312    }
313
314    #[tokio::test]
315    async fn accepts_token_with_valid_exp_and_nbf() {
316        let now = std::time::SystemTime::now()
317            .duration_since(std::time::UNIX_EPOCH)
318            .unwrap()
319            .as_secs();
320
321        struct ValidIntrospector(u64);
322        #[async_trait]
323        impl TokenIntrospector for ValidIntrospector {
324            async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
325                Ok(IntrospectionResult {
326                    active: true,
327                    sub: Some("user-1".into()),
328                    exp: Some(self.0 + 3600), // valid for 1h
329                    iat: None,
330                    nbf: Some(self.0 - 3600), // was valid 1h ago
331                    scope: Some("read".into()),
332                    client_id: None,
333                    token_type: None,
334                    iss: None,
335                    aud: None,
336                    extra: {
337                        let mut m = Map::new();
338                        m.insert("realm_access".into(), json!({"roles": ["user"]}));
339                        m
340                    },
341                })
342            }
343        }
344
345        let auth =
346            IntrospectionAuthenticator::new(Arc::new(ValidIntrospector(now)), keycloak_mapper());
347        let principal = auth.authenticate_bearer("test-token").await.unwrap();
348        assert_eq!(principal.subject, "user-1");
349        assert!(principal.has_role("user"));
350    }
351
352    #[tokio::test]
353    async fn missing_subject_returns_token_invalid() {
354        let introspector = MockIntrospector {
355            result: IntrospectionResult {
356                active: true,
357                sub: None,
358                exp: None,
359                iat: None,
360                nbf: None,
361                scope: None,
362                client_id: None,
363                token_type: None,
364                iss: None,
365                aud: None,
366                extra: Map::new(),
367            },
368        };
369        let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
370        let err = auth.authenticate_bearer("tok").await.unwrap_err();
371        match err {
372            CamelError::Unauthenticated(msg) => assert!(msg.contains("subject")),
373            other => panic!("expected Unauthenticated, got: {other:?}"),
374        }
375    }
376}