Skip to main content

cloudillo_core/
proxy_token_cache.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Shared LRU cache for federated access tokens.
5//!
6//! Tokens returned from a remote's `/api/auth/access-token` endpoint are
7//! HS256-signed with the remote's secret and valid for `ACCESS_TOKEN_EXPIRY`
8//! (currently 3600s). Without caching, every cross-instance HTTP request
9//! triggers a fresh proxy-token exchange (P384 verify + HS256 sign on the
10//! remote). This cache amortises that cost across multiple requests to the
11//! same remote.
12//!
13//! The cache lives on `AppState` (not on `Request`) so that any code path
14//! that talks to a remote tenant — file sync, profile sync, future instant
15//! messaging, etc. — can share the same warm token pool.
16
17use std::num::NonZeroUsize;
18use std::sync::Arc;
19
20use lru::LruCache;
21use serde::Deserialize;
22
23use crate::prelude::*;
24
25/// Subtracted from a cached access token's JWT `exp` to give clock-skew
26/// margin before re-minting.
27const SAFETY_MARGIN_SECS: i64 = 60;
28
29/// Default LRU capacity. ~200 bytes per entry × 256 ≈ 50 KB, comfortably
30/// large for realistic peer counts. The `None` fallback is unreachable
31/// (256 is non-zero) but coded as `NonZeroUsize::MIN` rather than a
32/// panic so this stays panic-free in spirit and in form.
33const DEFAULT_CAPACITY: NonZeroUsize = match NonZeroUsize::new(256) {
34	Some(n) => n,
35	None => NonZeroUsize::MIN,
36};
37
38#[derive(Debug, Clone)]
39struct CachedAccessToken {
40	token: Box<str>,
41	valid_until: Timestamp,
42}
43
44type TokenCacheKey = (TnId, Box<str>);
45type TokenCacheInner = LruCache<TokenCacheKey, CachedAccessToken>;
46
47/// LRU-bounded cache of access tokens keyed on (local tn_id, remote id_tag).
48///
49/// Uses `parking_lot::Mutex` (no poisoning) because `LruCache::get` mutates
50/// recency state.
51#[derive(Debug)]
52pub struct ProxyTokenCache {
53	entries: Arc<parking_lot::Mutex<TokenCacheInner>>,
54}
55
56impl ProxyTokenCache {
57	pub fn new() -> Self {
58		Self::with_capacity(DEFAULT_CAPACITY)
59	}
60
61	pub fn with_capacity(capacity: NonZeroUsize) -> Self {
62		Self { entries: Arc::new(parking_lot::Mutex::new(LruCache::new(capacity))) }
63	}
64
65	/// Returns a cached token if one is still valid; `None` otherwise.
66	pub fn get(&self, tn_id: TnId, id_tag: &str) -> Option<Box<str>> {
67		let mut cache = self.entries.lock();
68		let now = Timestamp::now();
69		cache
70			.get(&(tn_id, Box::<str>::from(id_tag)))
71			.filter(|e| e.valid_until.0 > now.0)
72			.map(|e| e.token.clone())
73	}
74
75	/// Inserts a freshly-minted token, deriving its expiry from the JWT's
76	/// own `exp` claim.
77	pub fn insert(&self, tn_id: TnId, id_tag: &str, token: Box<str>) {
78		let valid_until = match read_jwt_exp(&token) {
79			Ok(exp) => Timestamp(exp.0 - SAFETY_MARGIN_SECS),
80			Err(e) => {
81				warn!(id_tag = %id_tag, error = %e,
82					"failed to read access-token exp; using minimal cache TTL");
83				Timestamp::from_now(60)
84			}
85		};
86		let mut cache = self.entries.lock();
87		cache.put((tn_id, Box::<str>::from(id_tag)), CachedAccessToken { token, valid_until });
88	}
89
90	/// Drops the cached token for `(tn_id, id_tag)`. Call after a 401/403
91	/// from the remote so the next request mints fresh.
92	pub fn invalidate(&self, tn_id: TnId, id_tag: &str) {
93		let mut cache = self.entries.lock();
94		cache.pop(&(tn_id, Box::<str>::from(id_tag)));
95	}
96}
97
98impl Default for ProxyTokenCache {
99	fn default() -> Self {
100		Self::new()
101	}
102}
103
104#[derive(Deserialize)]
105struct AccessTokenExp {
106	exp: i64,
107}
108
109fn read_jwt_exp(jwt: &str) -> ClResult<Timestamp> {
110	let claim: AccessTokenExp = cloudillo_types::utils::decode_jwt_no_verify(jwt)?;
111	Ok(Timestamp(claim.exp))
112}
113
114// vim: ts=4