1use std::collections::HashMap;
14use std::sync::Mutex;
15use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19use crate::{extract_bearer_token, validate_token, AuthResult, JwtConfig, JwtError};
20
21const CAPACITY: usize = 10_000;
26
27struct Entry {
28 result: AuthResult,
29 expires: Instant,
30}
31
32pub struct JwtCache {
34 entries: Mutex<HashMap<String, Entry>>,
35 max_lifetime: Duration,
36}
37
38impl JwtCache {
39 pub fn new(enabled: bool, max_lifetime_secs: u64) -> Option<Self> {
44 if !enabled || max_lifetime_secs == 0 {
45 return None;
46 }
47 Some(Self {
48 entries: Mutex::new(HashMap::new()),
49 max_lifetime: Duration::from_secs(max_lifetime_secs),
50 })
51 }
52
53 pub fn authenticate(
59 &self,
60 auth_header: Option<&str>,
61 config: &JwtConfig,
62 ) -> Result<AuthResult, JwtError> {
63 let Some(header) = auth_header else {
64 return crate::authenticate(None, config);
65 };
66 let token = extract_bearer_token(header)?;
67
68 if let Some(hit) = self.get(token) {
69 return Ok(hit);
70 }
71
72 let result = validate_token(token, config)?;
73
74 let ttl = match remaining_lifetime(&result) {
78 Expiry::Absent => self.max_lifetime,
81 Expiry::In(remaining) if remaining.is_zero() => return Ok(result),
82 Expiry::In(remaining) => self.max_lifetime.min(remaining),
83 Expiry::Unreadable => return Ok(result),
88 };
89
90 self.insert(token.to_string(), result.clone(), ttl);
91 Ok(result)
92 }
93
94 fn get(&self, token: &str) -> Option<AuthResult> {
95 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
96 let entry = entries.get(token)?;
97 if entry.expires > Instant::now() {
98 return Some(entry.result.clone());
99 }
100 entries.remove(token);
101 None
102 }
103
104 fn insert(&self, token: String, result: AuthResult, ttl: Duration) {
105 let now = Instant::now();
106 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
107
108 if entries.len() >= CAPACITY {
109 entries.retain(|_, e| e.expires > now);
110 if entries.len() >= CAPACITY {
113 entries.clear();
114 }
115 }
116
117 entries.insert(
118 token,
119 Entry {
120 result,
121 expires: now + ttl,
122 },
123 );
124 }
125}
126
127enum Expiry {
129 Absent,
131 In(Duration),
133 Unreadable,
136}
137
138fn remaining_lifetime(result: &AuthResult) -> Expiry {
142 let Some(exp) = result.claims.get("exp") else {
143 return Expiry::Absent;
144 };
145
146 let exp_secs = match exp.as_i64() {
147 Some(n) => n,
148 None => match exp.as_f64() {
151 Some(f) if f.is_finite() => f.trunc() as i64,
152 _ => return Expiry::Unreadable,
153 },
154 };
155
156 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
157 return Expiry::Unreadable;
158 };
159
160 Expiry::In(Duration::from_secs(
161 exp_secs.saturating_sub(now.as_secs() as i64).max(0) as u64,
162 ))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use serde_json::json;
169
170 fn result_with(claims: serde_json::Value) -> AuthResult {
171 AuthResult {
172 role: "web_user".to_string(),
173 claims: claims
174 .as_object()
175 .unwrap()
176 .iter()
177 .map(|(k, v)| (k.clone(), v.clone()))
178 .collect(),
179 }
180 }
181
182 #[test]
183 fn disabled_by_flag_or_by_zero_lifetime() {
184 assert!(JwtCache::new(false, 3600).is_none());
185 assert!(JwtCache::new(true, 0).is_none());
186 assert!(JwtCache::new(true, 1).is_some());
187 }
188
189 #[test]
190 fn a_hit_is_returned_and_an_expired_entry_is_not() {
191 let cache = JwtCache::new(true, 3600).unwrap();
192 let result = result_with(json!({"sub": "alice"}));
193
194 cache.insert("tok".to_string(), result.clone(), Duration::from_secs(60));
195 assert_eq!(cache.get("tok").unwrap().role, "web_user");
196
197 cache.insert("stale".to_string(), result, Duration::from_secs(0));
199 assert!(cache.get("stale").is_none());
200 assert!(!cache.entries.lock().unwrap().contains_key("stale"));
202 }
203
204 fn now_secs() -> i64 {
205 SystemTime::now()
206 .duration_since(UNIX_EPOCH)
207 .unwrap()
208 .as_secs() as i64
209 }
210
211 #[test]
212 fn exp_bounds_the_lifetime_but_does_not_extend_it() {
213 let soon = now_secs() + 30;
214 let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
215 panic!("an integer exp should be readable");
216 };
217 assert!(remaining <= Duration::from_secs(30));
218 assert!(remaining > Duration::from_secs(25));
219
220 let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": soon - 600})))
222 else {
223 panic!("an integer exp should be readable");
224 };
225 assert!(none_left.is_zero());
226
227 assert!(matches!(
229 remaining_lifetime(&result_with(json!({"sub": "alice"}))),
230 Expiry::Absent
231 ));
232 }
233
234 #[test]
235 fn a_fractional_exp_is_read_rather_than_treated_as_absent() {
236 let soon = now_secs() as f64 + 30.5;
241 let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
242 panic!("a fractional exp must not read as absent or unreadable");
243 };
244 assert!(remaining <= Duration::from_secs(31));
245 assert!(remaining > Duration::from_secs(25));
246
247 let past = now_secs() as f64 - 600.5;
249 let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": past}))) else {
250 panic!("a fractional exp must not read as absent or unreadable");
251 };
252 assert!(none_left.is_zero());
253 }
254
255 #[test]
256 fn an_exp_that_is_not_a_number_is_unreadable_not_absent() {
257 for value in [json!({"exp": "soon"}), json!({"exp": null})] {
260 assert!(
261 matches!(
262 remaining_lifetime(&result_with(value.clone())),
263 Expiry::Unreadable
264 ),
265 "{value:?}"
266 );
267 }
268 }
269
270 #[test]
271 fn a_token_with_an_unreadable_exp_is_not_cached() {
272 let cache = JwtCache::new(true, 3600).unwrap();
273 assert!(cache.get("tok").is_none());
276 assert!(cache.entries.lock().unwrap().is_empty());
277 }
278
279 #[test]
280 fn the_cache_is_bounded() {
281 let cache = JwtCache::new(true, 3600).unwrap();
282 let result = result_with(json!({"sub": "alice"}));
283 for i in 0..CAPACITY + 50 {
284 cache.insert(format!("tok{i}"), result.clone(), Duration::from_secs(600));
285 }
286 assert!(cache.entries.lock().unwrap().len() <= CAPACITY);
287 }
288}