Skip to main content

authplane_sdk/cache/
token_cache.rs

1//! In-memory token cache with TTL buffer for `client_credentials` results.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5use std::time::{Duration, Instant};
6
7use serde_json::Value;
8
9/// A cached access-token entry.
10#[derive(Debug, Clone)]
11pub struct CachedToken {
12    pub access_token: String,
13    pub token_type: String,
14    /// AS-supplied lifetime hint, preserved across the cache boundary so a
15    /// caller that schedules its own refresh off `TokenResponse.expires_in`
16    /// sees the same `None` / `Some(N)` distinction it would have gotten
17    /// from a fresh AS round-trip. Positive values are clamped to
18    /// [`TokenCache::MAX_CACHE_TTL_SECONDS`] before storage, matching the
19    /// clamp applied to the live TTL — so a caller scheduling its own
20    /// refresh sees the same upper bound the cache will honour.
21    pub expires_in: Option<i64>,
22    pub scope: String,
23    /// Raw `cnf` confirmation object from the AS token response (RFC 9449
24    /// §6.1). Preserved verbatim so a token that was issued as
25    /// DPoP-bound still looks DPoP-bound on cache hits — without this,
26    /// downstream code gating on `cnf` / `cnf_jkt` sees the wrong shape
27    /// the moment a token round-trips through the cache and silently
28    /// loses its sender-constrained guarantee.
29    pub cnf: Option<Value>,
30    /// DPoP key thumbprint at `cnf.jkt`, mirrored from the source
31    /// `TokenResponse`. Empty string when the cached token is not
32    /// DPoP-bound.
33    pub cnf_jkt: String,
34}
35
36impl From<CachedToken> for crate::oauth::TokenResponse {
37    /// Rehydrate a `TokenResponse` from a cached entry. `refresh_token` and
38    /// `issued_token_type` default to empty — `client_credentials` responses
39    /// never carry them. `cnf` and `cnf_jkt` are preserved verbatim from the
40    /// cache so a DPoP-bound token still looks DPoP-bound on cache hits.
41    fn from(cached: CachedToken) -> Self {
42        crate::oauth::TokenResponse {
43            access_token: cached.access_token,
44            token_type: cached.token_type,
45            expires_in: cached.expires_in,
46            scope: cached.scope,
47            refresh_token: String::new(),
48            issued_token_type: String::new(),
49            cnf: cached.cnf,
50            cnf_jkt: cached.cnf_jkt,
51        }
52    }
53}
54
55#[derive(Debug, Clone)]
56struct Entry {
57    token: CachedToken,
58    expires_at: Instant,
59}
60
61/// In-memory cache for AS-issued machine tokens.
62///
63/// Tokens are evicted `ttl_buffer_seconds` before their actual expiry so the
64/// SDK never returns a token that is about to die mid-request.
65#[derive(Debug)]
66pub struct TokenCache {
67    ttl_buffer: Duration,
68    default_ttl: Duration,
69    entries: Mutex<HashMap<String, Entry>>,
70}
71
72impl TokenCache {
73    /// Default TTL buffer applied before token expiry on every `get`.
74    pub const DEFAULT_TTL_BUFFER_SECONDS: f64 = 30.0;
75    /// Default fallback TTL when the AS does not supply `expires_in`.
76    pub const DEFAULT_TTL_SECONDS: f64 = 3600.0;
77    /// Upper bound clamp for AS-supplied `expires_in` values, in seconds.
78    /// `Instant + Duration` panics on overflow, so an absurd AS reply
79    /// (e.g. `i64::MAX`) cannot flow through unchecked. 30 days is well
80    /// above any realistic access-token lifetime.
81    pub const MAX_CACHE_TTL_SECONDS: i64 = 30 * 24 * 60 * 60;
82
83    pub fn new() -> Self {
84        Self::with_config(Self::DEFAULT_TTL_BUFFER_SECONDS, Self::DEFAULT_TTL_SECONDS)
85    }
86
87    pub fn with_config(ttl_buffer_seconds: f64, default_ttl_seconds: f64) -> Self {
88        Self {
89            ttl_buffer: Duration::from_secs_f64(ttl_buffer_seconds.max(0.0)),
90            default_ttl: Duration::from_secs_f64(default_ttl_seconds.max(0.0)),
91            entries: Mutex::new(HashMap::new()),
92        }
93    }
94
95    /// Get a cached token if it exists and has not expired (after applying the buffer).
96    pub fn get(&self, key: &str) -> Option<CachedToken> {
97        let mut entries = self.entries.lock().expect("poisoned");
98        let now = Instant::now();
99        if let Some(entry) = entries.get(key) {
100            if now < entry.expires_at {
101                return Some(entry.token.clone());
102            }
103            entries.remove(key);
104        }
105        None
106    }
107
108    /// Insert a token into the cache. Skips caching if the effective TTL
109    /// (after buffer) would be non-positive.
110    ///
111    /// `expires_in` follows the [`TokenResponse::expires_in`] semantics:
112    ///
113    /// * `None` — the AS omitted the hint; the cache applies its configured
114    ///   `default_ttl` (then the buffer).
115    /// * `Some(0)` — the AS asked for immediate expiry (RFC 6749 §5.1
116    ///   permits this for one-shot flows). The entry is not stored, since
117    ///   it would be born expired.
118    /// * `Some(n)` with `n > 0` — use `n` seconds, then apply the buffer.
119    ///   Clamped to [`MAX_CACHE_TTL_SECONDS`] both for the live TTL and
120    ///   for the hint preserved on [`CachedToken::expires_in`], so the
121    ///   stored value never advertises a lifetime the cache will not
122    ///   actually honour. The clamp also keeps `Instant + Duration`
123    ///   from overflowing on an absurd AS reply.
124    ///
125    /// `cnf` / `cnf_jkt` preserve the DPoP confirmation binding through
126    /// cache round-trips (RFC 9449 §6.1). Pass `None` / `""` for plain
127    /// bearer tokens.
128    ///
129    /// [`TokenResponse::expires_in`]: crate::oauth::TokenResponse::expires_in
130    /// [`MAX_CACHE_TTL_SECONDS`]: Self::MAX_CACHE_TTL_SECONDS
131    /// [`CachedToken::expires_in`]: CachedToken::expires_in
132    #[allow(clippy::too_many_arguments)] // mirrors the TokenResponse fields we need to cache verbatim
133    pub fn set(
134        &self,
135        key: &str,
136        access_token: &str,
137        token_type: &str,
138        expires_in: Option<i64>,
139        scope: &str,
140        cnf: Option<&Value>,
141        cnf_jkt: &str,
142    ) {
143        let (raw_ttl, stored_expires_in) = match expires_in {
144            None => (self.default_ttl, None),
145            // RFC 6749 §5.1 explicit `expires_in: 0` ⇒ token is born expired;
146            // skip caching rather than apply the default fallback (which
147            // would silently extend a deliberately-zero lifetime to an hour).
148            Some(0) => return,
149            // `optional_non_negative_i64` rejects negative `expires_in` at
150            // parse time, so the only reachable `Some(_)` branch is `n > 0`.
151            // Defend anyway in case a future direct-deserialize path skips
152            // that validation.
153            Some(n) if n < 0 => return,
154            Some(n) => {
155                let clamped = n.min(Self::MAX_CACHE_TTL_SECONDS);
156                (Duration::from_secs(clamped as u64), Some(clamped))
157            }
158        };
159        let effective_ttl = raw_ttl.saturating_sub(self.ttl_buffer);
160        if effective_ttl.is_zero() {
161            return;
162        }
163        let entry = Entry {
164            token: CachedToken {
165                access_token: access_token.to_string(),
166                token_type: token_type.to_string(),
167                expires_in: stored_expires_in,
168                scope: scope.to_string(),
169                cnf: cnf.cloned(),
170                cnf_jkt: cnf_jkt.to_string(),
171            },
172            expires_at: Instant::now() + effective_ttl,
173        };
174        let mut entries = self.entries.lock().expect("poisoned");
175        entries.insert(key.to_string(), entry);
176    }
177
178    /// Remove a cached entry.
179    pub fn delete(&self, key: &str) {
180        let mut entries = self.entries.lock().expect("poisoned");
181        entries.remove(key);
182    }
183
184    /// Number of currently-stored entries (test/admin helper).
185    pub fn len(&self) -> usize {
186        self.entries.lock().expect("poisoned").len()
187    }
188
189    /// Whether the cache is empty.
190    pub fn is_empty(&self) -> bool {
191        self.len() == 0
192    }
193
194    /// Build a deterministic cache key from scope + resource.
195    ///
196    /// Scope tokens are sorted so the key is order-independent.
197    pub fn cache_key(scope: &str, resource: &str) -> String {
198        let mut parts: Vec<&str> = if scope.is_empty() {
199            Vec::new()
200        } else {
201            scope.split_whitespace().collect()
202        };
203        parts.sort_unstable();
204        let scope_part = parts.join(" ");
205        if !resource.is_empty() {
206            if scope_part.is_empty() {
207                return format!("|{resource}");
208            }
209            return format!("{scope_part}|{resource}");
210        }
211        if scope_part.is_empty() {
212            "_default".to_string()
213        } else {
214            scope_part
215        }
216    }
217}
218
219impl Default for TokenCache {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::thread;
229
230    #[test]
231    fn cache_key_sorts_scopes_and_appends_resource() {
232        assert_eq!(
233            TokenCache::cache_key("read write", "https://api"),
234            "read write|https://api"
235        );
236        assert_eq!(
237            TokenCache::cache_key("write read", "https://api"),
238            "read write|https://api"
239        );
240        assert_eq!(TokenCache::cache_key("", "https://api"), "|https://api");
241        assert_eq!(TokenCache::cache_key("read", ""), "read");
242        assert_eq!(TokenCache::cache_key("", ""), "_default");
243    }
244
245    #[test]
246    fn set_and_get_round_trip() {
247        let cache = TokenCache::with_config(0.0, 60.0);
248        cache.set("k", "tok", "Bearer", Some(60), "read", None, "");
249        let entry = cache.get("k").expect("entry should exist");
250        assert_eq!(entry.access_token, "tok");
251        assert_eq!(entry.token_type, "Bearer");
252        assert_eq!(entry.scope, "read");
253        assert_eq!(entry.expires_in, Some(60));
254        assert!(entry.cnf.is_none());
255        assert_eq!(entry.cnf_jkt, "");
256    }
257
258    #[test]
259    fn set_skips_when_buffer_consumes_ttl() {
260        let cache = TokenCache::with_config(60.0, 3600.0);
261        cache.set("k", "tok", "Bearer", Some(30), "read", None, "");
262        assert!(cache.get("k").is_none());
263        assert!(cache.is_empty());
264    }
265
266    #[test]
267    fn entry_expires_after_buffer_adjusted_ttl() {
268        let cache = TokenCache::with_config(0.0, 3600.0);
269        cache.set("k", "tok", "Bearer", Some(1), "read", None, "");
270        // Sleep slightly longer than 1s; entry should be evicted on next get.
271        thread::sleep(Duration::from_millis(1100));
272        assert!(cache.get("k").is_none());
273    }
274
275    #[test]
276    fn delete_removes_entry() {
277        let cache = TokenCache::new();
278        cache.set("k", "tok", "Bearer", Some(60), "", None, "");
279        cache.delete("k");
280        assert!(cache.get("k").is_none());
281    }
282
283    #[test]
284    fn missing_expires_in_falls_back_to_default_ttl() {
285        // `None` means the AS omitted `expires_in`; the cache must apply
286        // its configured `default_ttl` rather than treat it as immediate
287        // expiry.
288        let cache = TokenCache::with_config(0.0, 60.0);
289        cache.set("k", "tok", "Bearer", None, "read", None, "");
290        let entry = cache.get("k").expect("default-TTL fallback should store");
291        assert_eq!(entry.expires_in, None);
292    }
293
294    #[test]
295    fn explicit_zero_expires_in_does_not_extend_to_default_ttl() {
296        // RFC 6749 §5.1 permits `expires_in: 0` for one-shot flows.
297        // Previously the cache collapsed Some(0) to default_ttl (3600s) —
298        // a token meant to die immediately was kept for an hour. Now an
299        // explicit zero refuses to store at all.
300        let cache = TokenCache::with_config(0.0, 3600.0);
301        cache.set("k", "tok", "Bearer", Some(0), "read", None, "");
302        assert!(cache.get("k").is_none());
303        assert!(cache.is_empty());
304    }
305
306    #[test]
307    fn dpop_binding_survives_cache_round_trip() {
308        // RFC 9449 §6.1: a DPoP-bound access token carries its key
309        // thumbprint at `cnf.jkt`. The cache used to drop both `cnf`
310        // and `cnf_jkt`, so a token issued as sender-constrained
311        // looked bearer-only on every cache hit. Pin the fix here so
312        // any future refactor that re-introduces the asymmetry fails
313        // loudly.
314        let cache = TokenCache::with_config(0.0, 60.0);
315        let cnf = serde_json::json!({"jkt": "thumbprint-xyz"});
316        cache.set(
317            "k",
318            "dpop-tok",
319            "DPoP",
320            Some(60),
321            "read",
322            Some(&cnf),
323            "thumbprint-xyz",
324        );
325        let entry = cache.get("k").expect("entry should exist");
326        assert_eq!(entry.token_type, "DPoP");
327        assert_eq!(entry.cnf_jkt, "thumbprint-xyz");
328        assert_eq!(
329            entry
330                .cnf
331                .as_ref()
332                .and_then(|c| c.get("jkt"))
333                .and_then(|v| v.as_str()),
334            Some("thumbprint-xyz"),
335        );
336    }
337
338    #[test]
339    fn defaults_match_documented_constants() {
340        assert!((TokenCache::DEFAULT_TTL_BUFFER_SECONDS - 30.0).abs() < f64::EPSILON);
341        assert!((TokenCache::DEFAULT_TTL_SECONDS - 3600.0).abs() < f64::EPSILON);
342    }
343
344    #[test]
345    fn huge_expires_in_is_clamped_to_max_ttl() {
346        // `Instant + Duration` panics on overflow. An AS that replies with
347        // `expires_in: i64::MAX` (or any value larger than
348        // `MAX_CACHE_TTL_SECONDS`) must be clamped so the cache stores a
349        // sane entry rather than crashing — or storing nothing — silently.
350        // The stored hint is clamped too, so a caller scheduling its own
351        // refresh sees the same upper bound the cache will honour.
352        let cache = TokenCache::with_config(0.0, 60.0);
353        cache.set("k", "tok", "Bearer", Some(i64::MAX), "read", None, "");
354        let entry = cache
355            .get("k")
356            .expect("clamped entry should store, not crash, not skip");
357        assert_eq!(entry.expires_in, Some(TokenCache::MAX_CACHE_TTL_SECONDS));
358    }
359}