1use std::collections::HashMap;
2use std::fmt;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use tokio::sync::{Mutex, RwLock};
10
11use crate::http_client::{SsrfClientOptions, build_ssrf_pinned_client};
12use crate::types::AuthError;
13use camel_api::SsrfPolicy;
14use zeroize::Zeroizing;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct IntrospectionResult {
18 pub active: bool,
19 #[serde(default)]
20 pub sub: Option<String>,
21 #[serde(default)]
22 pub exp: Option<u64>,
23 #[serde(default)]
24 pub iat: Option<u64>,
25 #[serde(default)]
26 pub nbf: Option<u64>,
27 #[serde(default)]
28 pub scope: Option<String>,
29 #[serde(default)]
30 pub client_id: Option<String>,
31 #[serde(default)]
32 pub token_type: Option<String>,
33 #[serde(default)]
34 pub iss: Option<String>,
35 #[serde(default)]
36 pub aud: Option<serde_json::Value>,
37 #[serde(flatten)]
38 pub extra: serde_json::Map<String, serde_json::Value>,
39}
40
41#[derive(Debug, Clone)]
42pub struct IntrospectionCacheOptions {
43 pub max_entries: usize,
44 pub default_ttl: Duration,
45 pub negative_ttl: Duration,
46}
47
48impl Default for IntrospectionCacheOptions {
49 fn default() -> Self {
50 Self {
51 max_entries: 10_000,
52 default_ttl: Duration::from_secs(60),
53 negative_ttl: Duration::from_secs(5),
54 }
55 }
56}
57
58#[async_trait]
59pub trait TokenIntrospector: Send + Sync {
60 async fn introspect(&self, token: &str) -> Result<IntrospectionResult, AuthError>;
61}
62
63pub(crate) struct CachedEntry {
64 result: IntrospectionResult,
65 expires_at: Instant,
66}
67
68pub struct CachingTokenIntrospector {
70 endpoint: String,
71 client_id: String,
72 client_secret: Zeroizing<String>,
73 http: reqwest::Client,
74 pub(crate) cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
75 in_flight: Mutex<HashMap<String, Arc<Mutex<()>>>>,
76 max_cache_size: usize,
77 default_ttl: Duration,
78 negative_ttl: Duration,
79}
80
81impl CachingTokenIntrospector {
82 pub async fn new(
83 endpoint: String,
84 client_id: String,
85 client_secret: String,
86 options: IntrospectionCacheOptions,
87 policy: SsrfPolicy,
88 ) -> Result<Self, AuthError> {
89 let http = build_ssrf_pinned_client(
90 &endpoint,
91 "introspection endpoint",
92 &SsrfClientOptions::new(policy)
93 .with_connect_timeout(Duration::from_secs(5))
94 .with_request_timeout(Duration::from_secs(10)),
95 )
96 .await?;
97 Ok(Self::with_client(
98 endpoint,
99 client_id,
100 Zeroizing::new(client_secret),
101 options,
102 http,
103 ))
104 }
105
106 #[doc(hidden)]
107 pub fn new_unchecked_for_test(
108 endpoint: String,
109 client_id: String,
110 client_secret: String,
111 options: IntrospectionCacheOptions,
112 ) -> Result<Self, AuthError> {
113 let http = reqwest::Client::builder()
118 .connect_timeout(Duration::from_secs(5))
119 .timeout(Duration::from_secs(10))
120 .build()
121 .map_err(|e| {
122 AuthError::ConfigError(format!("introspection HTTP client build failed: {e}"))
123 })?;
124 Ok(Self::with_client(
125 endpoint,
126 client_id,
127 Zeroizing::new(client_secret),
128 options,
129 http,
130 ))
131 }
132
133 fn with_client(
134 endpoint: String,
135 client_id: String,
136 client_secret: Zeroizing<String>,
137 options: IntrospectionCacheOptions,
138 http: reqwest::Client,
139 ) -> Self {
140 Self {
141 endpoint,
142 client_id,
143 client_secret,
144 http,
145 cache: Arc::new(RwLock::new(HashMap::new())),
146 in_flight: Mutex::new(HashMap::new()),
147 max_cache_size: options.max_entries,
148 default_ttl: options.default_ttl,
149 negative_ttl: options.negative_ttl,
150 }
151 }
152
153 fn token_hash(token: &str) -> String {
154 let mut hasher = Sha256::new();
155 hasher.update(token.as_bytes());
156 hex::encode(hasher.finalize())
157 }
158
159 fn compute_ttl(&self, result: &IntrospectionResult) -> Duration {
160 if !result.active {
161 return self.negative_ttl;
162 }
163 if let Some(exp) = result.exp {
164 let now = std::time::SystemTime::now()
165 .duration_since(std::time::UNIX_EPOCH)
166 .unwrap_or_default()
167 .as_secs();
168 if exp > now {
169 let remaining = Duration::from_secs(exp - now);
170 return remaining.min(self.default_ttl);
171 }
172 }
173 self.default_ttl
174 }
175
176 async fn evict_if_needed(&self) {
177 let mut cache = self.cache.write().await;
178 if cache.len() < self.max_cache_size {
179 return;
180 }
181 let now = Instant::now();
182 cache.retain(|_, entry| entry.expires_at > now);
183 if cache.len() >= self.max_cache_size {
184 let oldest_key = cache
185 .iter()
186 .min_by_key(|(_, e)| e.expires_at)
187 .map(|(k, _)| k.clone());
188 if let Some(key) = oldest_key {
189 cache.remove(&key);
190 }
191 }
192 }
193}
194
195impl fmt::Debug for CachingTokenIntrospector {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 f.debug_struct("CachingTokenIntrospector")
198 .field("endpoint", &self.endpoint)
199 .field("client_id", &self.client_id)
200 .field("client_secret", &"[REDACTED]")
201 .field("max_cache_size", &self.max_cache_size)
202 .field("default_ttl", &self.default_ttl)
203 .field("negative_ttl", &self.negative_ttl)
204 .finish_non_exhaustive()
205 }
206}
207
208#[async_trait]
209impl TokenIntrospector for CachingTokenIntrospector {
210 async fn introspect(&self, token: &str) -> Result<IntrospectionResult, AuthError> {
211 let key = Self::token_hash(token);
212
213 {
214 let cache = self.cache.read().await;
215 if let Some(entry) = cache.get(&key)
216 && entry.expires_at > Instant::now()
217 {
218 tracing::debug!(target: "camel_auth::introspection", cache_outcome = "hit");
219 return Ok(entry.result.clone());
220 }
221 }
222
223 let key_mutex = {
230 let mut in_flight_map = self.in_flight.lock().await;
231 in_flight_map
232 .entry(key.clone())
233 .or_insert_with(|| Arc::new(Mutex::new(())))
234 .clone()
235 };
236
237 let result: Result<IntrospectionResult, AuthError> = async {
241 let _guard = key_mutex.lock().await;
242 {
244 let cache = self.cache.read().await;
245 if let Some(entry) = cache.get(&key)
246 && entry.expires_at > Instant::now()
247 {
248 tracing::debug!(target: "camel_auth::introspection", cache_outcome = "hit_after_wait");
249 return Ok(entry.result.clone());
250 }
251 }
252
253 tracing::debug!(
254 target: "camel_auth::introspection",
255 cache_outcome = "miss"
256 );
257
258 let response = self
259 .http
260 .post(&self.endpoint)
261 .form(&[
262 ("token", token),
263 ("client_id", &self.client_id),
264 ("client_secret", self.client_secret.as_str()),
265 ])
266 .send()
267 .await
268 .map_err(|e| {
269 AuthError::ProviderUnavailable(format!("introspection request failed: {e}"))
270 })?;
271
272 let status = response.status();
273 if status.as_u16() == 401 || status.as_u16() == 403 {
274 return Err(AuthError::ProviderUnavailable(
275 "introspection client unauthorized".into(),
276 ));
277 }
278 if status.is_server_error() {
279 return Err(AuthError::ProviderUnavailable(format!(
280 "introspection endpoint returned {}",
281 status
282 )));
283 }
284 if status.is_client_error() {
285 return Err(AuthError::TokenInvalid(format!(
286 "introspection endpoint returned client error {}",
287 status
288 )));
289 }
290
291 let result: IntrospectionResult = response.json().await.map_err(|e| {
292 AuthError::ProviderUnavailable(format!("invalid introspection response: {e}"))
293 })?;
294
295 let ttl = self.compute_ttl(&result);
296 let entry = CachedEntry {
297 result: result.clone(),
298 expires_at: Instant::now() + ttl,
299 };
300
301 self.evict_if_needed().await;
302 {
303 let mut cache = self.cache.write().await;
304 cache.insert(key.clone(), entry);
305 }
306
307 Ok(result)
308 }
309 .await;
310
311 drop(key_mutex);
316 {
317 let mut in_flight_map = self.in_flight.lock().await;
318 if let Some(arc) = in_flight_map.get(&key)
319 && Arc::strong_count(arc) == 1
320 {
321 in_flight_map.remove(&key);
322 }
323 }
324 result
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use wiremock::matchers::{body_string_contains, method};
332 use wiremock::{Mock, MockServer, ResponseTemplate};
333
334 #[test]
335 fn deserialize_minimal_active() {
336 let json = r#"{"active": true}"#;
337 let result: IntrospectionResult = serde_json::from_str(json).unwrap();
338 assert!(result.active);
339 assert!(result.sub.is_none());
340 assert!(result.extra.is_empty());
341 }
342
343 #[test]
344 fn deserialize_full_rfc7662() {
345 let json = r#"{
346 "active": true,
347 "sub": "user-1",
348 "exp": 1700000000,
349 "iat": 1699999999,
350 "nbf": 1699999900,
351 "scope": "read write",
352 "client_id": "my-client",
353 "token_type": "Bearer",
354 "iss": "https://kc.example.com/realms/test",
355 "aud": ["my-api"],
356 "realm_access": {"roles": ["admin", "user"]},
357 "resource_access": {"my-client": {"roles": ["client-role"]}}
358 }"#;
359 let result: IntrospectionResult = serde_json::from_str(json).unwrap();
360 assert!(result.active);
361 assert_eq!(result.sub.as_deref(), Some("user-1"));
362 assert_eq!(result.exp, Some(1700000000));
363 assert_eq!(result.scope.as_deref(), Some("read write"));
364 assert_eq!(result.client_id.as_deref(), Some("my-client"));
365 assert_eq!(result.token_type.as_deref(), Some("Bearer"));
366 assert_eq!(
367 result.iss.as_deref(),
368 Some("https://kc.example.com/realms/test")
369 );
370 assert!(result.extra.contains_key("realm_access"));
371 assert!(result.extra.contains_key("resource_access"));
372 }
373
374 #[test]
375 fn deserialize_inactive() {
376 let json = r#"{"active": false}"#;
377 let result: IntrospectionResult = serde_json::from_str(json).unwrap();
378 assert!(!result.active);
379 }
380
381 #[test]
382 fn deserialize_unknown_fields_go_to_extra() {
383 let json = r#"{"active": true, "custom_field": "hello"}"#;
384 let result: IntrospectionResult = serde_json::from_str(json).unwrap();
385 assert_eq!(result.extra["custom_field"], "hello");
386 }
387
388 #[test]
389 fn cache_options_defaults() {
390 let opts = IntrospectionCacheOptions::default();
391 assert_eq!(opts.max_entries, 10_000);
392 assert_eq!(opts.default_ttl, Duration::from_secs(60));
393 assert_eq!(opts.negative_ttl, Duration::from_secs(5));
394 }
395
396 fn test_cache_opts() -> IntrospectionCacheOptions {
397 IntrospectionCacheOptions {
398 max_entries: 100,
399 default_ttl: Duration::from_secs(60),
400 negative_ttl: Duration::from_secs(2),
401 }
402 }
403
404 #[tokio::test]
405 async fn cache_hit_returns_cached_result_without_http_call() {
406 let server = MockServer::start().await;
407 Mock::given(method("POST"))
408 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
409 "active": true,
410 "sub": "cached-user"
411 })))
412 .expect(1)
413 .mount(&server)
414 .await;
415
416 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
417 server.uri(),
418 "client-id".into(),
419 "client-secret".into(),
420 test_cache_opts(),
421 )
422 .expect("introspector"); let r1 = introspector.introspect("token-a").await.unwrap();
425 let r2 = introspector.introspect("token-a").await.unwrap();
426 assert_eq!(r1.sub, r2.sub);
427 assert!(r1.active);
428 }
429
430 #[tokio::test]
431 async fn expired_entry_re_introspects() {
432 let server = MockServer::start().await;
433 Mock::given(method("POST"))
434 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
435 "active": true, "sub": "user"
436 })))
437 .expect(2)
438 .mount(&server)
439 .await;
440
441 let opts = IntrospectionCacheOptions {
442 max_entries: 100,
443 default_ttl: Duration::from_millis(50),
444 negative_ttl: Duration::from_secs(2),
445 };
446 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
447 server.uri(),
448 "cid".into(),
449 "cs".into(),
450 opts,
451 )
452 .expect("introspector"); introspector.introspect("tok").await.unwrap();
455 tokio::time::sleep(Duration::from_millis(80)).await;
456 introspector.introspect("tok").await.unwrap();
457 }
458
459 #[tokio::test]
460 async fn inactive_token_cached_with_negative_ttl() {
461 let server = MockServer::start().await;
462 Mock::given(method("POST"))
463 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
464 "active": false
465 })))
466 .expect(1)
467 .mount(&server)
468 .await;
469
470 let opts = IntrospectionCacheOptions {
471 max_entries: 100,
472 default_ttl: Duration::from_secs(60),
473 negative_ttl: Duration::from_secs(10),
474 };
475 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
476 server.uri(),
477 "cid".into(),
478 "cs".into(),
479 opts,
480 )
481 .expect("introspector"); let r = introspector.introspect("dead-token").await.unwrap();
484 assert!(!r.active);
485 let r2 = introspector.introspect("dead-token").await.unwrap();
486 assert!(!r2.active);
487 }
488
489 #[tokio::test]
490 async fn cache_key_does_not_contain_raw_token() {
491 let server = MockServer::start().await;
492 Mock::given(method("POST"))
493 .respond_with(
494 ResponseTemplate::new(200).set_body_json(serde_json::json!({"active": true})),
495 )
496 .mount(&server)
497 .await;
498
499 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
500 server.uri(),
501 "cid".into(),
502 "cs".into(),
503 test_cache_opts(),
504 )
505 .expect("introspector"); introspector.introspect("secret-token-value").await.unwrap();
508 let cache = introspector.cache.read().await;
509 for key in cache.keys() {
510 assert!(
511 !key.contains("secret-token-value"),
512 "cache key must not contain raw token"
513 );
514 }
515 }
516
517 #[tokio::test]
518 async fn eviction_removes_oldest_when_over_capacity() {
519 let server = MockServer::start().await;
520 for i in 0..5 {
521 Mock::given(method("POST"))
522 .and(body_string_contains(format!("token-{i}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
524 "active": true, "sub": format!("user-{i}")
525 })))
526 .mount(&server)
527 .await;
528 }
529
530 let opts = IntrospectionCacheOptions {
531 max_entries: 2,
532 default_ttl: Duration::from_secs(600),
533 negative_ttl: Duration::from_secs(5),
534 };
535 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
536 server.uri(),
537 "cid".into(),
538 "cs".into(),
539 opts,
540 )
541 .expect("introspector"); introspector.introspect("token-0").await.unwrap();
544 introspector.introspect("token-1").await.unwrap();
545 introspector.introspect("token-2").await.unwrap();
546 introspector.introspect("token-3").await.unwrap();
547 introspector.introspect("token-4").await.unwrap();
548
549 let cache = introspector.cache.read().await;
550 assert!(cache.len() <= 2, "cache must respect max_entries");
551 }
552
553 #[test]
554 fn debug_redacts_client_secret() {
555 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
556 "https://example.com".into(),
557 "cid".into(),
558 "super-secret-value".into(),
559 test_cache_opts(),
560 )
561 .expect("introspector"); let debug = format!("{introspector:?}");
563 assert!(
564 !debug.contains("super-secret-value"),
565 "Debug must not leak client_secret"
566 );
567 assert!(debug.contains("REDACTED"));
568 }
569
570 #[tokio::test]
571 async fn production_constructor_rejects_http_endpoint() {
572 let result = CachingTokenIntrospector::new(
573 "http://insecure.example.com/introspect".into(),
574 "cid".into(),
575 "cs".into(),
576 test_cache_opts(),
577 SsrfPolicy::PublicHttpsOnly,
578 )
579 .await;
580 assert!(result.is_err());
581 let err = result.unwrap_err();
582 assert!(matches!(err, AuthError::ConfigError(ref s) if s.contains("HTTPS")));
583 }
584
585 #[tokio::test]
586 async fn production_constructor_rejects_localhost() {
587 let result = CachingTokenIntrospector::new(
588 "https://localhost:8080/introspect".into(),
589 "cid".into(),
590 "cs".into(),
591 test_cache_opts(),
592 SsrfPolicy::PublicHttpsOnly,
593 )
594 .await;
595 assert!(result.is_err());
596 let err = result.unwrap_err();
597 assert!(
598 matches!(err, AuthError::ConfigError(ref s) if s.contains("private") || s.contains("loopback"))
599 );
600 }
601
602 #[tokio::test]
603 async fn http_error_500_returns_provider_unavailable() {
604 let server = MockServer::start().await;
605 Mock::given(method("POST"))
606 .respond_with(ResponseTemplate::new(500))
607 .mount(&server)
608 .await;
609
610 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
611 server.uri(),
612 "cid".into(),
613 "cs".into(),
614 test_cache_opts(),
615 )
616 .expect("introspector"); let result = introspector.introspect("tok").await;
618 assert!(result.is_err());
619 let err = result.unwrap_err();
620 assert!(matches!(err, AuthError::ProviderUnavailable(_)));
621 }
622
623 #[tokio::test]
624 async fn http_401_returns_provider_unavailable() {
625 let server = MockServer::start().await;
626 Mock::given(method("POST"))
627 .respond_with(ResponseTemplate::new(401))
628 .mount(&server)
629 .await;
630
631 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
632 server.uri(),
633 "cid".into(),
634 "cs".into(),
635 test_cache_opts(),
636 )
637 .expect("introspector"); let result = introspector.introspect("tok").await;
639 assert!(result.is_err());
640 let err = result.unwrap_err();
641 assert!(matches!(err, AuthError::ProviderUnavailable(ref s) if s.contains("unauthorized")));
642 }
643
644 #[tokio::test]
645 async fn concurrent_different_tokens_no_head_of_line_blocking() {
646 let server = MockServer::start().await;
647 Mock::given(method("POST"))
648 .respond_with(
649 ResponseTemplate::new(200)
650 .set_delay(Duration::from_millis(500))
651 .set_body_json(serde_json::json!({"active": true})),
652 )
653 .expect(2)
654 .mount(&server)
655 .await;
656
657 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
658 server.uri(),
659 "cid".into(),
660 "cs".into(),
661 test_cache_opts(),
662 )
663 .expect("introspector"); let start = Instant::now();
666 let (r1, r2) = tokio::join!(
667 introspector.introspect("token-parallel-a"),
668 introspector.introspect("token-parallel-b"),
669 );
670 let elapsed = start.elapsed();
671
672 assert!(r1.is_ok(), "first introspect failed: {:?}", r1.err());
673 assert!(r2.is_ok(), "second introspect failed: {:?}", r2.err());
674 assert!(r1.unwrap().active);
675 assert!(r2.unwrap().active);
676 assert!(
677 elapsed < Duration::from_millis(800),
678 "expected parallel introspection (<800ms), got {elapsed:?} (serial would be ~1000ms)"
679 );
680 }
681
682 #[tokio::test]
683 async fn concurrent_same_token_dedup_preserved() {
684 let server = MockServer::start().await;
685 Mock::given(method("POST"))
686 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
687 "active": true
688 })))
689 .expect(1)
690 .mount(&server)
691 .await;
692
693 let introspector = CachingTokenIntrospector::new_unchecked_for_test(
694 server.uri(),
695 "cid".into(),
696 "cs".into(),
697 test_cache_opts(),
698 )
699 .expect("introspector"); let (r1, r2) = tokio::join!(
702 introspector.introspect("same-dedup-token"),
703 introspector.introspect("same-dedup-token"),
704 );
705
706 assert!(r1.is_ok(), "first caller failed: {:?}", r1.err());
707 assert!(r2.is_ok(), "second caller failed: {:?}", r2.err());
708 }
709}