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