1use std::collections::HashMap;
12use std::fmt;
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use sha2::{Digest, Sha256};
16
17use camel_api::security_policy::{AuthPrincipal, TransportId};
18
19use crate::kernel::AuthenticatedPrincipal;
20
21#[derive(Debug, Clone)]
23pub struct AuthnCacheOptions {
24 pub ttl: Duration,
29 pub max_entries: usize,
31}
32
33impl Default for AuthnCacheOptions {
34 fn default() -> Self {
35 Self {
36 ttl: Duration::from_secs(30),
37 max_entries: 10_000,
38 }
39 }
40}
41
42#[derive(Clone, PartialEq, Eq, Hash)]
47pub struct AuthnCacheKey {
48 provider: String,
49 audiences: Vec<String>,
50 issuers: Vec<String>,
51 transport: TransportId,
52 token_hash: String,
53}
54
55impl AuthnCacheKey {
56 pub fn new(
58 provider: &str,
59 audiences: &[String],
60 issuers: &[String],
61 transport: TransportId,
62 token: &str,
63 ) -> Self {
64 Self {
65 provider: provider.to_string(),
66 audiences: audiences.to_vec(),
67 issuers: issuers.to_vec(),
68 transport,
69 token_hash: token_hash(token),
70 }
71 }
72}
73
74impl fmt::Debug for AuthnCacheKey {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.debug_struct("AuthnCacheKey")
77 .field("provider", &self.provider)
78 .field("audiences", &self.audiences)
79 .field("issuers", &self.issuers)
80 .field("transport", &self.transport)
81 .field("token_hash", &"[hash]")
82 .finish()
83 }
84}
85
86fn token_hash(token: &str) -> String {
89 let mut hasher = Sha256::new();
90 hasher.update(token.as_bytes());
91 hex::encode(hasher.finalize())
92}
93
94struct CacheEntry {
95 principal: AuthenticatedPrincipal,
96 expires_at: Instant,
97}
98
99pub struct AuthnCache {
106 cache: std::sync::RwLock<HashMap<AuthnCacheKey, CacheEntry>>,
107 options: AuthnCacheOptions,
108}
109
110impl AuthnCache {
111 pub fn new(options: AuthnCacheOptions) -> Self {
112 Self {
113 cache: std::sync::RwLock::new(HashMap::new()),
114 options,
115 }
116 }
117
118 pub fn get(&self, key: &AuthnCacheKey) -> Option<AuthenticatedPrincipal> {
123 let cache = self
124 .cache
125 .read()
126 .unwrap_or_else(|poisoned| poisoned.into_inner());
127 let entry = cache.get(key)?;
128 if Instant::now() < entry.expires_at {
129 Some(entry.principal.clone())
130 } else {
131 None
132 }
133 }
134
135 pub fn insert(&self, key: AuthnCacheKey, principal: AuthenticatedPrincipal) -> bool {
142 let now = Instant::now();
143 let lifetime = match token_remaining(&principal) {
144 Some(remaining) => {
145 if remaining.is_zero() {
146 return false;
147 }
148 remaining.min(self.options.ttl)
149 }
150 None => self.options.ttl,
151 };
152
153 self.evict_if_needed();
154
155 let mut cache = self
156 .cache
157 .write()
158 .unwrap_or_else(|poisoned| poisoned.into_inner());
159 cache.insert(
160 key,
161 CacheEntry {
162 principal,
163 expires_at: now + lifetime,
164 },
165 );
166 true
167 }
168
169 pub fn len(&self) -> usize {
170 let cache = self
171 .cache
172 .read()
173 .unwrap_or_else(|poisoned| poisoned.into_inner());
174 cache.len()
175 }
176
177 pub fn is_empty(&self) -> bool {
178 self.len() == 0
179 }
180
181 fn evict_if_needed(&self) {
184 let mut cache = self
185 .cache
186 .write()
187 .unwrap_or_else(|poisoned| poisoned.into_inner());
188 if cache.len() < self.options.max_entries {
189 return;
190 }
191 let now = Instant::now();
192 cache.retain(|_, entry| now < entry.expires_at);
193 if cache.len() >= self.options.max_entries {
194 let oldest_key = cache
195 .iter()
196 .min_by_key(|(_, e)| e.expires_at)
197 .map(|(k, _)| k.clone());
198 if let Some(key) = oldest_key {
199 cache.remove(&key);
200 }
201 }
202 }
203}
204
205impl fmt::Debug for AuthnCache {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 f.debug_struct("AuthnCache")
208 .field("ttl", &self.options.ttl)
209 .field("max_entries", &self.options.max_entries)
210 .finish_non_exhaustive()
211 }
212}
213
214fn token_remaining(principal: &AuthenticatedPrincipal) -> Option<Duration> {
218 let exp = principal.principal().claims.get("exp")?.as_u64()?;
219 let now_wall = SystemTime::now()
220 .duration_since(UNIX_EPOCH)
221 .unwrap_or_default()
222 .as_secs();
223 Some(Duration::from_secs(exp.saturating_sub(now_wall)))
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
230 use crate::credential_source::{CredentialSource, ExtractedToken};
231 use crate::jwks::{Jwk, JwksProvider};
232 use crate::jwt::LocalJwtValidator;
233 use crate::kernel::kernel_authenticate;
234 use crate::registry::{ProviderEntry, ProviderRegistry};
235 use crate::token_authenticator::AuthnRequest;
236 use crate::types::AuthError;
237 use camel_api::CamelError;
238 use camel_api::security_policy::{AccessMode, AudienceBinding, Principal, RouteSecurityPlan};
239 use serde_json::json;
240 use std::sync::Arc;
241 use std::sync::atomic::{AtomicUsize, Ordering};
242
243 static TEST_RSA_PRIVATE_PEM: &[u8] = include_bytes!("../tests/fixtures/test_rsa_private.pem");
244 static TEST_RSA_PUBLIC_PEM: &[u8] = include_bytes!("../tests/fixtures/test_rsa_public.pem");
245
246 fn test_principal() -> Principal {
247 Principal {
248 subject: "svc-user".into(),
249 issuer: "test".into(),
250 audience: vec![],
251 scopes: vec![],
252 roles: vec![],
253 claims: serde_json::Value::Null,
254 }
255 }
256
257 struct CountingAuthenticator {
259 count: AtomicUsize,
260 accept: bool,
261 }
262
263 #[async_trait::async_trait]
264 impl crate::token_authenticator::TokenAuthenticator for CountingAuthenticator {
265 async fn authenticate_bearer(&self, _token: &str) -> Result<Principal, CamelError> {
266 self.count.fetch_add(1, Ordering::SeqCst);
267 if self.accept {
268 Ok(test_principal())
269 } else {
270 Err(CamelError::Unauthenticated("bad token".into()))
271 }
272 }
273 }
274
275 struct CountingValidator {
279 inner: LocalJwtValidator,
280 count: AtomicUsize,
281 }
282
283 #[async_trait::async_trait]
284 impl crate::token_authenticator::TokenAuthenticator for CountingValidator {
285 async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
286 self.count.fetch_add(1, Ordering::SeqCst);
287 self.inner.authenticate_bearer(token).await
288 }
289
290 async fn authenticate(&self, req: AuthnRequest<'_>) -> Result<Principal, CamelError> {
291 self.count.fetch_add(1, Ordering::SeqCst);
292 let now_wall = SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .unwrap_or_default()
295 .as_secs();
296 if let Some(exp) = token_exp(req.token)
297 && exp <= now_wall
298 {
299 return Err(CamelError::Unauthenticated("token expired".into()));
300 }
301 self.inner.authenticate(req).await
302 }
303 }
304
305 fn token_exp(token: &str) -> Option<u64> {
308 use base64::Engine;
309 let payload = token.split('.').nth(1)?;
310 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
311 .decode(payload)
312 .ok()?;
313 let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
314 claims.get("exp")?.as_u64()
315 }
316
317 struct MockJwks {
318 kid: String,
319 public_pem: &'static [u8],
320 }
321
322 #[async_trait::async_trait]
323 impl JwksProvider for MockJwks {
324 async fn get_signing_keys(&self) -> Result<Vec<Jwk>, AuthError> {
325 Ok(vec![Jwk {
326 kid: self.kid.clone(),
327 kty: "RSA".into(),
328 alg: Some("RS256".into()),
329 r#use: None,
330 n: String::from_utf8_lossy(self.public_pem).into_owned(),
331 e: "AQAB".into(),
332 }])
333 }
334
335 async fn refresh(&self) -> Result<(), AuthError> {
336 Ok(())
337 }
338 }
339
340 fn jwt_validator(audience: Vec<&str>, issuer: &str) -> LocalJwtValidator {
341 let mapper = Arc::new(JsonPointerClaimsMapper::new(ClaimPaths {
342 subject: "/sub".into(),
343 roles: vec!["/groups".into()],
344 scopes: Some("/scope".into()),
345 }));
346 LocalJwtValidator::new(
347 audience.iter().map(|s| s.to_string()).collect(),
348 issuer.to_string(),
349 Arc::new(MockJwks {
350 kid: "test-key".into(),
351 public_pem: TEST_RSA_PUBLIC_PEM,
352 }),
353 mapper,
354 )
355 }
356
357 fn make_token(kid: &str, claims: &serde_json::Value) -> String {
358 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
359 header.kid = Some(kid.to_string());
360 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_PEM).unwrap();
361 jsonwebtoken::encode(&header, claims, &encoding_key).unwrap()
362 }
363
364 fn plan(
365 provider_ref: &str,
366 transport: TransportId,
367 binding: Option<AudienceBinding>,
368 ) -> RouteSecurityPlan {
369 RouteSecurityPlan {
370 access_mode: AccessMode::Authenticated,
371 provider_ref: Some(provider_ref.to_string()),
372 transport,
373 credential_sources: vec![CredentialSource::AuthorizationHeader],
374 audience_binding: binding,
375 }
376 }
377
378 fn credentials(token: &str) -> ExtractedToken {
379 ExtractedToken {
380 token: token.to_string(),
381 source: CredentialSource::AuthorizationHeader,
382 }
383 }
384
385 fn cached_registry(
386 accept: bool,
387 ) -> (
388 Arc<AuthnCache>,
389 Arc<CountingAuthenticator>,
390 ProviderRegistry,
391 ) {
392 let cache = Arc::new(AuthnCache::new(AuthnCacheOptions::default()));
393 let auth = Arc::new(CountingAuthenticator {
394 count: AtomicUsize::new(0),
395 accept,
396 });
397 let registry = ProviderRegistry::new().with_authn_cache(cache.clone());
398 registry.register(
399 "idp-a",
400 ProviderEntry {
401 authenticator: auth.clone(),
402 audience_binding: None,
403 },
404 );
405 (cache, auth, registry)
406 }
407
408 #[tokio::test]
409 async fn cache_separates_providers() {
410 let cache = Arc::new(AuthnCache::new(AuthnCacheOptions::default()));
411 let binding = AudienceBinding {
412 issuers: vec!["https://a".into()],
413 audiences: vec!["api".into()],
414 };
415 let auth_a = Arc::new(CountingAuthenticator {
416 count: AtomicUsize::new(0),
417 accept: true,
418 });
419 let auth_b = Arc::new(CountingAuthenticator {
420 count: AtomicUsize::new(0),
421 accept: true,
422 });
423 let registry = ProviderRegistry::new().with_authn_cache(cache.clone());
424 registry.register(
425 "idp-a",
426 ProviderEntry {
427 authenticator: auth_a.clone(),
428 audience_binding: Some(binding.clone()),
429 },
430 );
431 registry.register(
432 "idp-b",
433 ProviderEntry {
434 authenticator: auth_b.clone(),
435 audience_binding: Some(binding.clone()),
436 },
437 );
438
439 let token = "same-token";
440 let plan_a = plan("idp-a", TransportId::Http, Some(binding.clone()));
441 let plan_b = plan("idp-b", TransportId::Http, Some(binding.clone()));
442
443 kernel_authenticate(&plan_a, ®istry, &credentials(token))
444 .await
445 .unwrap();
446 kernel_authenticate(&plan_b, ®istry, &credentials(token))
447 .await
448 .unwrap();
449
450 assert_eq!(
451 cache.len(),
452 2,
453 "provider is part of the key — identical binding+token must yield two entries"
454 );
455 assert_eq!(auth_a.count.load(Ordering::SeqCst), 1);
456 assert_eq!(auth_b.count.load(Ordering::SeqCst), 1);
457 }
458
459 #[tokio::test]
460 async fn cache_separates_bindings() {
461 let cache = Arc::new(AuthnCache::new(AuthnCacheOptions::default()));
465 let binding_a = AudienceBinding {
466 issuers: vec!["https://a".into()],
467 audiences: vec!["api-a".into()],
468 };
469 let binding_b = AudienceBinding {
470 issuers: vec!["https://a".into()],
471 audiences: vec!["api-b".into()],
472 };
473 let auth = Arc::new(CountingAuthenticator {
474 count: AtomicUsize::new(0),
475 accept: true,
476 });
477 let registry = ProviderRegistry::new().with_authn_cache(cache.clone());
478 registry.register(
479 "idp-a",
480 ProviderEntry {
481 authenticator: auth.clone(),
482 audience_binding: Some(binding_a.clone()),
483 },
484 );
485
486 let token = "same-token";
487 let plan_a = plan("idp-a", TransportId::Http, Some(binding_a));
488 let plan_b = plan("idp-a", TransportId::Http, Some(binding_b));
489
490 kernel_authenticate(&plan_a, ®istry, &credentials(token))
491 .await
492 .unwrap();
493 kernel_authenticate(&plan_b, ®istry, &credentials(token))
494 .await
495 .unwrap();
496
497 assert_eq!(
498 cache.len(),
499 2,
500 "audiences/issuers are part of the key — different bindings must not collide"
501 );
502 assert_eq!(auth.count.load(Ordering::SeqCst), 2);
503 }
504
505 #[tokio::test]
506 async fn cache_separates_transports() {
507 let (cache, auth, registry) = cached_registry(true);
508 let token = "same-token";
509
510 let plan_http = plan("idp-a", TransportId::Http, None);
511 let plan_ws = plan("idp-a", TransportId::Ws, None);
512
513 kernel_authenticate(&plan_http, ®istry, &credentials(token))
514 .await
515 .unwrap();
516 kernel_authenticate(&plan_ws, ®istry, &credentials(token))
517 .await
518 .unwrap();
519
520 assert_eq!(
521 cache.len(),
522 2,
523 "transport is part of the key — same provider+token on Http and Ws must yield two entries"
524 );
525 assert_eq!(auth.count.load(Ordering::SeqCst), 2);
526 }
527
528 #[test]
529 fn cache_key_debug_redacts_token() {
530 let key = AuthnCacheKey::new(
531 "idp-a",
532 &["api".to_string()],
533 &["https://a".to_string()],
534 TransportId::Http,
535 "super-secret-token-value",
536 );
537 let debug = format!("{key:?}");
538 assert!(
539 !debug.contains("super-secret-token-value"),
540 "Debug must not leak the token: {debug}"
541 );
542 assert!(
543 debug.contains("[hash]"),
544 "Debug must show the redaction marker: {debug}"
545 );
546 }
547
548 #[tokio::test]
549 async fn denials_not_cached() {
550 let (cache, auth, registry) = cached_registry(false);
551 let plan = plan("idp-a", TransportId::Http, None);
552 let token = "wrong-token";
553
554 let r1 = kernel_authenticate(&plan, ®istry, &credentials(token)).await;
555 let r2 = kernel_authenticate(&plan, ®istry, &credentials(token)).await;
556
557 assert!(matches!(r1, Err(CamelError::Unauthenticated(_))));
558 assert!(matches!(r2, Err(CamelError::Unauthenticated(_))));
559 assert_eq!(
560 auth.count.load(Ordering::SeqCst),
561 2,
562 "denials must not be cached — the provider is called every time"
563 );
564 assert_eq!(cache.len(), 0, "denials are never inserted");
565 }
566
567 #[tokio::test]
568 async fn expired_token_not_served_from_cache() {
569 let now = chrono::Utc::now().timestamp() as u64;
571 let claims = json!({
572 "sub": "user-1",
573 "iss": "https://a",
574 "aud": "api",
575 "exp": now + 5,
576 "iat": now,
577 });
578 let token = make_token("test-key", &claims);
579
580 let counting = Arc::new(CountingValidator {
581 inner: jwt_validator(vec!["api"], "https://a"),
582 count: AtomicUsize::new(0),
583 });
584 let cache = Arc::new(AuthnCache::new(AuthnCacheOptions::default()));
585 let binding = AudienceBinding {
586 issuers: vec!["https://a".into()],
587 audiences: vec!["api".into()],
588 };
589 let registry = ProviderRegistry::new().with_authn_cache(cache.clone());
590 registry.register(
591 "idp-a",
592 ProviderEntry {
593 authenticator: counting.clone(),
594 audience_binding: Some(binding.clone()),
595 },
596 );
597 let plan = plan("idp-a", TransportId::Http, Some(binding));
598
599 let p1 = kernel_authenticate(&plan, ®istry, &credentials(&token))
601 .await
602 .unwrap();
603 assert_eq!(p1.provider_id(), "idp-a");
604 assert_eq!(counting.count.load(Ordering::SeqCst), 1);
605
606 tokio::time::sleep(Duration::from_secs(6)).await;
608
609 let r2 = kernel_authenticate(&plan, ®istry, &credentials(&token)).await;
612 assert!(
613 matches!(r2, Err(CamelError::Unauthenticated(_))),
614 "expired token must not be served from cache"
615 );
616 assert_eq!(
617 counting.count.load(Ordering::SeqCst),
618 2,
619 "post-expiry re-auth must call the provider again"
620 );
621 }
622}