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