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