1use crate::store::{StoreFuture, digest};
30use rustlavel_auth::{base64, random};
31use rustlavel_oauth::Scopes;
32use std::collections::HashMap;
33use std::sync::Mutex;
34
35pub const DEFAULT_ACCESS_TTL: u64 = 3600;
38
39pub const DEFAULT_REFRESH_TTL: u64 = 30 * 24 * 3600;
41
42pub fn generate() -> String {
44 base64::encode_url(&random::bytes(32))
45}
46
47fn identifier() -> String {
49 random::hex(16)
50}
51
52#[derive(Clone)]
54pub struct AccessToken {
55 pub id: String,
56 hash: String,
57 pub client_id: String,
58 pub user_id: Option<String>,
61 pub scopes: Scopes,
62 pub family: String,
63 pub issued_at: u64,
64 pub expires_at: u64,
65 pub revoked: bool,
66}
67
68impl AccessToken {
69 pub fn issued(token: &str, now: u64, ttl: u64) -> AccessToken {
70 AccessToken {
71 id: identifier(),
72 hash: digest(token),
73 client_id: String::new(),
74 user_id: None,
75 scopes: Scopes::new(),
76 family: String::new(),
77 issued_at: now,
78 expires_at: now.saturating_add(ttl),
79 revoked: false,
80 }
81 }
82
83 pub fn for_client(mut self, client_id: impl Into<String>) -> AccessToken {
84 self.client_id = client_id.into();
85 self
86 }
87
88 pub fn for_user(mut self, user_id: Option<&str>) -> AccessToken {
90 self.user_id = user_id.map(str::to_string);
91 self
92 }
93
94 pub fn granting(mut self, scopes: Scopes) -> AccessToken {
95 self.scopes = scopes;
96 self
97 }
98
99 pub fn in_family(mut self, family: impl Into<String>) -> AccessToken {
100 self.family = family.into();
101 self
102 }
103
104 pub fn from_hash(id: impl Into<String>, hash: impl Into<String>) -> AccessToken {
110 AccessToken {
111 id: id.into(),
112 hash: hash.into(),
113 client_id: String::new(),
114 user_id: None,
115 scopes: Scopes::new(),
116 family: String::new(),
117 issued_at: 0,
118 expires_at: 0,
119 revoked: false,
120 }
121 }
122
123 pub fn hash(&self) -> &str {
124 &self.hash
125 }
126
127 pub fn is_live(&self, now: u64) -> bool {
129 !self.revoked && now < self.expires_at
130 }
131}
132
133impl std::fmt::Debug for AccessToken {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("AccessToken")
136 .field("id", &self.id)
137 .field("hash", &"<redacted>")
138 .field("client_id", &self.client_id)
139 .field("user_id", &self.user_id)
140 .field("scopes", &self.scopes)
141 .field("family", &self.family)
142 .field("expires_at", &self.expires_at)
143 .field("revoked", &self.revoked)
144 .finish()
145 }
146}
147
148#[derive(Clone)]
150pub struct RefreshToken {
151 pub id: String,
152 hash: String,
153 pub client_id: String,
154 pub user_id: Option<String>,
155 pub scopes: Scopes,
156 pub family: String,
157 pub access_token_id: String,
160 pub issued_at: u64,
161 pub expires_at: u64,
162 pub revoked: bool,
163 pub rotated: bool,
166}
167
168impl RefreshToken {
169 pub fn issued(token: &str, now: u64, ttl: u64) -> RefreshToken {
170 RefreshToken {
171 id: identifier(),
172 hash: digest(token),
173 client_id: String::new(),
174 user_id: None,
175 scopes: Scopes::new(),
176 family: String::new(),
177 access_token_id: String::new(),
178 issued_at: now,
179 expires_at: now.saturating_add(ttl),
180 revoked: false,
181 rotated: false,
182 }
183 }
184
185 pub fn for_client(mut self, client_id: impl Into<String>) -> RefreshToken {
186 self.client_id = client_id.into();
187 self
188 }
189
190 pub fn for_user(mut self, user_id: Option<&str>) -> RefreshToken {
191 self.user_id = user_id.map(str::to_string);
192 self
193 }
194
195 pub fn granting(mut self, scopes: Scopes) -> RefreshToken {
196 self.scopes = scopes;
197 self
198 }
199
200 pub fn in_family(mut self, family: impl Into<String>) -> RefreshToken {
201 self.family = family.into();
202 self
203 }
204
205 pub fn alongside(mut self, access_token_id: impl Into<String>) -> RefreshToken {
207 self.access_token_id = access_token_id.into();
208 self
209 }
210
211 pub fn from_hash(id: impl Into<String>, hash: impl Into<String>) -> RefreshToken {
213 RefreshToken {
214 id: id.into(),
215 hash: hash.into(),
216 client_id: String::new(),
217 user_id: None,
218 scopes: Scopes::new(),
219 family: String::new(),
220 access_token_id: String::new(),
221 issued_at: 0,
222 expires_at: 0,
223 revoked: false,
224 rotated: false,
225 }
226 }
227
228 pub fn hash(&self) -> &str {
229 &self.hash
230 }
231
232 pub fn is_live(&self, now: u64) -> bool {
233 !self.revoked && !self.rotated && now < self.expires_at
234 }
235}
236
237impl std::fmt::Debug for RefreshToken {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("RefreshToken")
240 .field("id", &self.id)
241 .field("hash", &"<redacted>")
242 .field("client_id", &self.client_id)
243 .field("user_id", &self.user_id)
244 .field("scopes", &self.scopes)
245 .field("family", &self.family)
246 .field("expires_at", &self.expires_at)
247 .field("revoked", &self.revoked)
248 .field("rotated", &self.rotated)
249 .finish()
250 }
251}
252
253pub trait TokenStore: Send + Sync + 'static {
255 fn store_access<'a>(&'a self, token: AccessToken) -> StoreFuture<'a, ()>;
256 fn store_refresh<'a>(&'a self, token: RefreshToken) -> StoreFuture<'a, ()>;
257
258 fn find_access<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<AccessToken>>;
261 fn find_refresh<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<RefreshToken>>;
262
263 fn rotate<'a>(&'a self, id: &'a str) -> StoreFuture<'a, bool>;
271
272 fn revoke_access<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()>;
273 fn revoke_refresh<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()>;
274
275 fn revoke_family<'a>(&'a self, family: &'a str) -> StoreFuture<'a, usize>;
278
279 fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize>;
281}
282
283#[derive(Default)]
290pub struct MemoryTokenStore {
291 access: Mutex<HashMap<String, AccessToken>>,
292 refresh: Mutex<HashMap<String, RefreshToken>>,
293}
294
295impl MemoryTokenStore {
296 pub fn new() -> MemoryTokenStore {
297 MemoryTokenStore::default()
298 }
299
300 pub fn live_access(&self, now: u64) -> usize {
302 self.access.lock().expect("token store lock poisoned").values().filter(|t| t.is_live(now)).count()
303 }
304
305 pub fn live_refresh(&self, now: u64) -> usize {
307 self.refresh.lock().expect("token store lock poisoned").values().filter(|t| t.is_live(now)).count()
308 }
309}
310
311impl TokenStore for MemoryTokenStore {
312 fn store_access<'a>(&'a self, token: AccessToken) -> StoreFuture<'a, ()> {
313 Box::pin(async move {
314 self.access
315 .lock()
316 .expect("token store lock poisoned")
317 .insert(token.hash.clone(), token);
318 Ok(())
319 })
320 }
321
322 fn store_refresh<'a>(&'a self, token: RefreshToken) -> StoreFuture<'a, ()> {
323 Box::pin(async move {
324 self.refresh
325 .lock()
326 .expect("token store lock poisoned")
327 .insert(token.hash.clone(), token);
328 Ok(())
329 })
330 }
331
332 fn find_access<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<AccessToken>> {
333 Box::pin(async move {
334 Ok(self.access.lock().expect("token store lock poisoned").get(hash).cloned())
335 })
336 }
337
338 fn find_refresh<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<RefreshToken>> {
339 Box::pin(async move {
340 Ok(self.refresh.lock().expect("token store lock poisoned").get(hash).cloned())
341 })
342 }
343
344 fn rotate<'a>(&'a self, id: &'a str) -> StoreFuture<'a, bool> {
345 Box::pin(async move {
346 let mut refresh = self.refresh.lock().expect("token store lock poisoned");
349 let Some(token) = refresh.values_mut().find(|token| token.id == id) else {
350 return Ok(false);
351 };
352 if token.rotated {
353 return Ok(false);
354 }
355 token.rotated = true;
356 Ok(true)
357 })
358 }
359
360 fn revoke_access<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
361 Box::pin(async move {
362 let mut access = self.access.lock().expect("token store lock poisoned");
363 if let Some(token) = access.values_mut().find(|token| token.id == id) {
364 token.revoked = true;
365 }
366 Ok(())
367 })
368 }
369
370 fn revoke_refresh<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
371 Box::pin(async move {
372 let mut refresh = self.refresh.lock().expect("token store lock poisoned");
373 if let Some(token) = refresh.values_mut().find(|token| token.id == id) {
374 token.revoked = true;
375 }
376 Ok(())
377 })
378 }
379
380 fn revoke_family<'a>(&'a self, family: &'a str) -> StoreFuture<'a, usize> {
381 Box::pin(async move {
382 let mut changed = 0;
383 let mut access = self.access.lock().expect("token store lock poisoned");
384 for token in access.values_mut().filter(|t| t.family == family && !t.revoked) {
385 token.revoked = true;
386 changed += 1;
387 }
388 drop(access);
389
390 let mut refresh = self.refresh.lock().expect("token store lock poisoned");
391 for token in refresh.values_mut().filter(|t| t.family == family && !t.revoked) {
392 token.revoked = true;
393 changed += 1;
394 }
395 Ok(changed)
396 })
397 }
398
399 fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize> {
400 Box::pin(async move {
401 let mut removed = 0;
402
403 let mut access = self.access.lock().expect("token store lock poisoned");
404 let before = access.len();
405 access.retain(|_, token| now < token.expires_at);
406 removed += before - access.len();
407 drop(access);
408
409 let mut refresh = self.refresh.lock().expect("token store lock poisoned");
410 let before = refresh.len();
411 refresh.retain(|_, token| now < token.expires_at);
412 removed += before - refresh.len();
413
414 Ok(removed)
415 })
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 const NOW: u64 = 1_800_000_000;
424
425 fn access(token: &str, family: &str) -> AccessToken {
426 AccessToken::issued(token, NOW, DEFAULT_ACCESS_TTL)
427 .for_client("web")
428 .for_user(Some("7"))
429 .granting(Scopes::of(["read"]))
430 .in_family(family)
431 }
432
433 fn refresh(token: &str, family: &str, access_token_id: &str) -> RefreshToken {
434 RefreshToken::issued(token, NOW, DEFAULT_REFRESH_TTL)
435 .for_client("web")
436 .for_user(Some("7"))
437 .granting(Scopes::of(["read"]))
438 .in_family(family)
439 .alongside(access_token_id)
440 }
441
442 async fn populated() -> (MemoryTokenStore, AccessToken, RefreshToken) {
443 let store = MemoryTokenStore::new();
444 let at = access("at-1", "family-1");
445 let rt = refresh("rt-1", "family-1", &at.id);
446 store.store_access(at.clone()).await.expect("stored");
447 store.store_refresh(rt.clone()).await.expect("stored");
448 (store, at, rt)
449 }
450
451 #[test]
452 fn a_generated_token_is_256_bits_and_url_safe() {
453 let token = generate();
454
455 assert_eq!(token.len(), 43);
456 assert!(token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
457 assert_ne!(token, generate());
458 }
459
460 #[test]
461 fn debug_prints_no_digest_for_either_token() {
462 let at = access("at-1", "f");
463 let rt = refresh("rt-1", "f", &at.id);
464
465 assert!(!format!("{at:?}").contains(at.hash()));
466 assert!(!format!("{rt:?}").contains(rt.hash()));
467 assert!(format!("{at:?}").contains("redacted"));
468 }
469
470 #[tokio::test]
471 async fn a_token_is_found_by_its_digest_and_not_by_its_plaintext() {
472 let (store, _, _) = populated().await;
473
474 assert!(store.find_access(&digest("at-1")).await.expect("lookup").is_some());
475 assert!(store.find_access("at-1").await.expect("lookup").is_none());
476 assert!(store.find_refresh(&digest("rt-1")).await.expect("lookup").is_some());
477 }
478
479 #[tokio::test]
480 async fn rotation_succeeds_exactly_once() {
481 let (store, _, rt) = populated().await;
484
485 assert!(store.rotate(&rt.id).await.expect("rotate"));
486 assert!(!store.rotate(&rt.id).await.expect("rotate"), "the second must lose");
487 assert!(!store.rotate("no-such-id").await.expect("rotate"));
488 }
489
490 #[tokio::test]
491 async fn a_rotated_token_is_no_longer_live() {
492 let (store, _, rt) = populated().await;
493 store.rotate(&rt.id).await.expect("rotate");
494
495 let reloaded = store.find_refresh(&digest("rt-1")).await.expect("lookup").expect("found");
496 assert!(reloaded.rotated);
497 assert!(!reloaded.is_live(NOW));
498 }
499
500 #[tokio::test]
501 async fn revoking_a_family_takes_down_both_halves_of_it() {
502 let (store, _, _) = populated().await;
503 store.store_access(access("at-2", "family-2")).await.expect("stored");
504
505 assert_eq!(store.revoke_family("family-1").await.expect("revoked"), 2);
506 assert_eq!(store.live_access(NOW), 1, "the other family is untouched");
507 assert_eq!(store.live_refresh(NOW), 0);
508
509 assert_eq!(store.revoke_family("family-1").await.expect("revoked"), 0);
512 }
513
514 #[tokio::test]
515 async fn revoking_one_token_leaves_its_sibling_alone() {
516 let (store, at, _) = populated().await;
517
518 store.revoke_access(&at.id).await.expect("revoked");
519
520 assert_eq!(store.live_access(NOW), 0);
521 assert_eq!(store.live_refresh(NOW), 1);
522 }
523
524 #[tokio::test]
525 async fn an_expired_token_is_not_live_and_is_eventually_purged() {
526 let (store, _, _) = populated().await;
527
528 assert_eq!(store.live_access(NOW + DEFAULT_ACCESS_TTL), 0);
529 assert_eq!(store.live_refresh(NOW + DEFAULT_ACCESS_TTL), 1, "refresh outlives access");
530
531 assert_eq!(store.purge(NOW + DEFAULT_ACCESS_TTL).await.expect("purged"), 1);
532 assert_eq!(store.purge(NOW + DEFAULT_REFRESH_TTL).await.expect("purged"), 1);
533 }
534}