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 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 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 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), 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), iat: None,
289 nbf: Some(self.0 + 3600), 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), iat: None,
330 nbf: Some(self.0 - 3600), 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}