Skip to main content

camel_auth/
authn_cache.rs

1//! Authn result cache: provider-local caching of successful authentication.
2//!
3//! Mirrors [`CachingPermissionEvaluator`](crate::permission_cache::CachingPermissionEvaluator)'s
4//! backend shape — `RwLock<HashMap>` with lazy eviction, no moka, no new
5//! dependency — but caches AUTHENTICATION results (minted
6//! [`AuthenticatedPrincipal`]s) keyed by provider + request constraints +
7//! token hash. Denials are never cached. This is a different plane from
8//! `CachingPermissionEvaluator` (which caches authorization decisions) and is
9//! purely additive.
10
11use 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/// Configuration for [`AuthnCache`].
22#[derive(Debug, Clone)]
23pub struct AuthnCacheOptions {
24    /// TTL for cached authentication results. Default 30 s — matches
25    /// [`PermissionCacheOptions::positive_ttl`](crate::permission_cache::PermissionCacheOptions::positive_ttl).
26    /// The effective entry lifetime is `min(ttl, token_exp - now)`: a cached
27    /// authentication never outlives its token.
28    pub ttl: Duration,
29    /// Maximum number of cache entries before eviction kicks in.
30    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/// Cache key for an authentication result.
43///
44/// The token is stored only as a SHA-256 hash — the raw token never appears in
45/// the key. `Debug` redacts the hash to `[hash]` (ExtractedToken precedent).
46#[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    /// Build a key from the request-scoped authn inputs, hashing the token.
57    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
86/// SHA-256 hex digest of a token (same utility as
87/// [`CachingTokenIntrospector::token_hash`](crate::introspection::CachingTokenIntrospector)).
88fn 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
99/// Provider-local cache of successful authentication results.
100///
101/// Backed by `std::sync::RwLock<HashMap>` with lazy eviction, mirroring
102/// [`CachingPermissionEvaluator`]. Entries never outlive their token: the
103/// effective lifetime is `min(configured TTL, token_exp - now)`, and hits
104/// re-check `now < expires_at`.
105pub 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    /// Look up a cached authentication result.
119    ///
120    /// Returns `None` on miss OR when the entry's token has expired (hits
121    /// re-check `now < expires_at`).
122    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    /// Insert a minted principal, returning `false` when it is not stored.
136    ///
137    /// The effective lifetime is `min(configured TTL, token_exp - now)`; a
138    /// token whose `exp` has already passed (≤ 0 remaining) is NOT stored.
139    /// Tokens without an `exp` claim (e.g. static native tokens) use the
140    /// configured TTL.
141    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    /// Lazy eviction: drop expired entries, then evict the oldest when still
182    /// over capacity (mirrors `CachingPermissionEvaluator::evict_if_needed`).
183    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
214/// Remaining token lifetime derived from the `exp` claim (unix seconds) on the
215/// minted principal's claims. `None` when the token carries no `exp` claim;
216/// `Some(0)` when `exp` has already passed (saturating — never cached).
217fn 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    /// Authenticator that counts calls and accepts or rejects every token.
258    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    /// Counting wrapper around a real [`LocalJwtValidator`] that ALSO enforces
276    /// `exp` itself: jsonwebtoken's default 60 s leeway would otherwise accept
277    /// the token past its `exp`, which would mask the cache's expiry behavior.
278    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    /// Decode the `exp` claim (unix seconds) from a JWT payload without
306    /// signature verification (test-only helper).
307    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, &registry, &credentials(token))
444            .await
445            .unwrap();
446        kernel_authenticate(&plan_b, &registry, &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        // Same provider+transport+token, different audience bindings —
462        // the binding is part of the key, so the 3.1 fail-closed
463        // enforcement cannot regress via a cache collision.
464        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, &registry, &credentials(token))
491            .await
492            .unwrap();
493        kernel_authenticate(&plan_b, &registry, &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, &registry, &credentials(token))
514            .await
515            .unwrap();
516        kernel_authenticate(&plan_ws, &registry, &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, &registry, &credentials(token)).await;
555        let r2 = kernel_authenticate(&plan, &registry, &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        // JWT with exp 5s ahead: the cache entry lifetime is min(ttl, exp - now).
570        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        // First call: authenticates and is cached with a ~1s lifetime.
600        let p1 = kernel_authenticate(&plan, &registry, &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        // Sleep past exp (tokio time).
607        tokio::time::sleep(Duration::from_secs(6)).await;
608
609        // Second call: the hit re-checks exp — NOT served from cache; the
610        // provider is called again and rejects the now-expired token.
611        let r2 = kernel_authenticate(&plan, &registry, &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}