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}