1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34use cheers_core::{DeviceId, RefreshError, UserId};
35
36use crate::store::{RefreshStore, RefreshTokenRecord};
37
38fn hash_token(raw: &str) -> String {
46 let digest = Sha256::digest(raw.as_bytes());
47 let mut hex = String::with_capacity(digest.len() * 2);
48 for b in digest {
49 use std::fmt::Write as _;
50 let _ = write!(hex, "{b:02x}");
51 }
52 hex
53}
54
55pub const REFRESH_TOKEN_BYTES: usize = 32;
57
58pub const CHAIN_ID_BYTES: usize = 16;
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct RefreshToken(String);
68
69impl RefreshToken {
70 pub fn generate() -> Self {
72 let mut bytes = [0u8; REFRESH_TOKEN_BYTES];
73 getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
74 Self(URL_SAFE_NO_PAD.encode(bytes))
75 }
76
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80
81 pub fn into_inner(self) -> String {
82 self.0
83 }
84}
85
86impl std::fmt::Display for RefreshToken {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(&self.0)
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(transparent)]
98pub struct ChainId(String);
99
100impl ChainId {
101 pub fn generate() -> Self {
103 let mut bytes = [0u8; CHAIN_ID_BYTES];
104 getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
105 Self(URL_SAFE_NO_PAD.encode(bytes))
106 }
107
108 pub fn as_str(&self) -> &str {
109 &self.0
110 }
111
112 pub fn into_inner(self) -> String {
113 self.0
114 }
115}
116
117impl std::fmt::Display for ChainId {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.write_str(&self.0)
120 }
121}
122
123#[derive(Debug, Clone)]
125#[non_exhaustive]
126pub struct Rotated {
127 pub token: RefreshToken,
130 pub record: RefreshTokenRecord,
131}
132
133#[derive(Debug, Clone)]
139pub struct LiveRefresh(RefreshTokenRecord);
140
141impl LiveRefresh {
142 pub fn record(&self) -> &RefreshTokenRecord {
145 &self.0
146 }
147}
148
149pub struct RefreshRotator<'s, S: RefreshStore + ?Sized> {
155 store: &'s S,
156 ttl_seconds: i64,
157}
158
159impl<'s, S: RefreshStore + ?Sized> RefreshRotator<'s, S> {
160 pub fn new(store: &'s S, ttl_seconds: i64) -> Self {
161 Self { store, ttl_seconds }
162 }
163
164 pub fn ttl_seconds(&self) -> i64 {
165 self.ttl_seconds
166 }
167
168 pub async fn mint_root(
170 &self,
171 user_id: UserId,
172 device_id: DeviceId,
173 now: i64,
174 ) -> Result<Rotated, RefreshError> {
175 let token = RefreshToken::generate();
176 let chain_id = ChainId::generate();
177 let record = RefreshTokenRecord {
178 token: hash_token(token.as_str()),
180 chain_id: chain_id.into_inner(),
181 parent: None,
182 user_id,
183 device_id,
184 issued_at: now,
185 expires_at: now + self.ttl_seconds,
186 consumed: false,
187 revoked: false,
188 };
189 self.store.put(&record).await?;
190 Ok(Rotated { token, record })
191 }
192
193 pub async fn rotate(&self, presented: &str, now: i64) -> Result<Rotated, RefreshError> {
206 let live = self.check(presented, now).await?;
207 self.commit(live, now).await
208 }
209
210 pub async fn check(&self, presented: &str, now: i64) -> Result<LiveRefresh, RefreshError> {
219 let presented_hash = hash_token(presented);
223 let existing = self
224 .store
225 .get(&presented_hash)
226 .await?
227 .ok_or(RefreshError::Unknown)?;
228
229 if existing.revoked {
230 return Err(RefreshError::ChainRevoked);
231 }
232 if existing.consumed {
233 self.store.revoke_chain(&existing.chain_id).await?;
237 return Err(RefreshError::Replay);
238 }
239 if existing.expires_at <= now {
240 return Err(RefreshError::Expired);
241 }
242 Ok(LiveRefresh(existing))
243 }
244
245 pub async fn commit(&self, live: LiveRefresh, now: i64) -> Result<Rotated, RefreshError> {
252 let LiveRefresh(existing) = live;
253
254 if !self.store.mark_consumed(&existing.token).await? {
268 self.store.revoke_chain(&existing.chain_id).await?;
269 return Err(RefreshError::Replay);
270 }
271
272 let token = RefreshToken::generate();
273 let record = RefreshTokenRecord {
274 token: hash_token(token.as_str()),
275 chain_id: existing.chain_id.clone(),
276 parent: Some(existing.token.clone()),
277 user_id: existing.user_id.clone(),
278 device_id: existing.device_id.clone(),
279 issued_at: now,
280 expires_at: now + self.ttl_seconds,
281 consumed: false,
282 revoked: false,
283 };
284 self.store.put(&record).await?;
285 Ok(Rotated { token, record })
286 }
287
288 pub async fn revoke_chain(&self, chain_id: &str) -> Result<(), RefreshError> {
291 self.store.revoke_chain(chain_id).await?;
292 Ok(())
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use async_trait::async_trait;
300 use cheers_core::StoreError;
301 use std::collections::HashMap;
302 use std::sync::Mutex;
303
304 #[derive(Default)]
305 struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
306
307 #[async_trait]
308 impl RefreshStore for MemRefreshStore {
309 async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
310 self.0
311 .lock()
312 .unwrap()
313 .insert(record.token.clone(), record.clone());
314 Ok(())
315 }
316 async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
317 Ok(self.0.lock().unwrap().get(token).cloned())
318 }
319 async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
320 let mut g = self.0.lock().unwrap();
321 match g.get_mut(token) {
322 Some(r) if !r.consumed => {
323 r.consumed = true;
324 Ok(true)
325 }
326 _ => Ok(false),
327 }
328 }
329 async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
330 let mut g = self.0.lock().unwrap();
331 for r in g.values_mut() {
332 if r.chain_id == chain_id {
333 r.revoked = true;
334 }
335 }
336 Ok(())
337 }
338 }
339
340 fn user() -> UserId {
341 UserId::new("u1")
342 }
343 fn device() -> DeviceId {
344 DeviceId::new("d1")
345 }
346
347 async fn stored<S: RefreshStore + ?Sized>(store: &S, raw: &str) -> RefreshTokenRecord {
351 store
352 .get(&hash_token(raw))
353 .await
354 .unwrap()
355 .expect("record present")
356 }
357
358 #[test]
359 fn token_generation_is_random_and_long() {
360 let a = RefreshToken::generate();
361 let b = RefreshToken::generate();
362 assert_ne!(a, b, "two generations must not collide");
363 assert_eq!(a.as_str().len(), 43);
365 }
366
367 #[test]
368 fn chain_id_generation_is_random() {
369 let a = ChainId::generate();
370 let b = ChainId::generate();
371 assert_ne!(a, b);
372 assert_eq!(a.as_str().len(), 22);
374 }
375
376 #[test]
377 fn happy_path_mint_and_rotate() {
378 let store = MemRefreshStore::default();
379 let r = RefreshRotator::new(&store, 3_600);
380 pollster::block_on(async {
381 let root = r.mint_root(user(), device(), 100).await.unwrap();
382 assert!(root.record.parent.is_none());
383 assert!(!root.record.consumed);
384 assert!(!root.record.revoked);
385 assert_eq!(root.record.expires_at, 3_700);
386
387 let next = r.rotate(root.token.as_str(), 200).await.unwrap();
388 assert_eq!(next.record.parent.as_deref(), Some(root.record.token.as_str()));
390 assert_eq!(next.record.chain_id, root.record.chain_id);
391 assert_eq!(next.record.user_id, root.record.user_id);
392 assert_eq!(next.record.device_id, root.record.device_id);
393 assert_ne!(next.token, root.token);
394 assert_ne!(next.record.token.as_str(), next.token.as_str());
396 assert_eq!(next.record.token, hash_token(next.token.as_str()));
397
398 let stored_root = stored(&store, root.token.as_str()).await;
400 assert!(stored_root.consumed);
401 assert!(!stored_root.revoked);
402
403 let stored_next = stored(&store, next.token.as_str()).await;
405 assert!(!stored_next.consumed);
406 assert!(!stored_next.revoked);
407 });
408 }
409
410 #[test]
411 fn rotate_chain_continues_across_multiple_hops() {
412 let store = MemRefreshStore::default();
413 let r = RefreshRotator::new(&store, 3_600);
414 pollster::block_on(async {
415 let a = r.mint_root(user(), device(), 100).await.unwrap();
416 let b = r.rotate(a.token.as_str(), 110).await.unwrap();
417 let c = r.rotate(b.token.as_str(), 120).await.unwrap();
418 let d = r.rotate(c.token.as_str(), 130).await.unwrap();
419
420 for hop in [&a, &b, &c, &d] {
421 assert_eq!(hop.record.chain_id, a.record.chain_id);
422 }
423 assert_eq!(b.record.parent.as_deref(), Some(a.record.token.as_str()));
424 assert_eq!(c.record.parent.as_deref(), Some(b.record.token.as_str()));
425 assert_eq!(d.record.parent.as_deref(), Some(c.record.token.as_str()));
426 });
427 }
428
429 #[test]
430 fn replay_revokes_entire_chain() {
431 let store = MemRefreshStore::default();
432 let r = RefreshRotator::new(&store, 3_600);
433 pollster::block_on(async {
434 let a = r.mint_root(user(), device(), 100).await.unwrap();
435 let b = r.rotate(a.token.as_str(), 110).await.unwrap();
436 let c = r.rotate(b.token.as_str(), 120).await.unwrap();
437
438 let err = r.rotate(a.token.as_str(), 130).await.unwrap_err();
440 assert!(matches!(err, RefreshError::Replay), "got {err:?}");
441
442 for tok in [a.token.as_str(), b.token.as_str(), c.token.as_str()] {
444 let rec = stored(&store, tok).await;
445 assert!(rec.revoked, "{tok} should be revoked");
446 }
447
448 let err = r.rotate(c.token.as_str(), 140).await.unwrap_err();
450 assert!(matches!(err, RefreshError::ChainRevoked), "got {err:?}");
451 });
452 }
453
454 #[test]
455 fn lost_consume_race_is_treated_as_replay() {
456 struct LostRace(MemRefreshStore);
462 #[async_trait]
463 impl RefreshStore for LostRace {
464 async fn put(&self, r: &RefreshTokenRecord) -> Result<(), StoreError> {
465 self.0.put(r).await
466 }
467 async fn get(&self, t: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
468 self.0.get(t).await
469 }
470 async fn mark_consumed(&self, _t: &str) -> Result<bool, StoreError> {
471 Ok(false) }
473 async fn revoke_chain(&self, c: &str) -> Result<(), StoreError> {
474 self.0.revoke_chain(c).await
475 }
476 }
477 let store = LostRace(MemRefreshStore::default());
478 let r = RefreshRotator::new(&store, 3_600);
479 pollster::block_on(async {
480 let a = r.mint_root(user(), device(), 100).await.unwrap();
481 let err = r.rotate(a.token.as_str(), 110).await.unwrap_err();
482 assert!(matches!(err, RefreshError::Replay), "got {err:?}");
483 assert!(
485 stored(&store.0, a.token.as_str()).await.revoked,
486 "chain should be revoked when a rotation loses the consume race"
487 );
488 });
489 }
490
491 #[test]
492 fn expired_token_rejected() {
493 let store = MemRefreshStore::default();
494 let r = RefreshRotator::new(&store, 60);
495 pollster::block_on(async {
496 let a = r.mint_root(user(), device(), 100).await.unwrap();
497 let err = r.rotate(a.token.as_str(), 160).await.unwrap_err();
499 assert!(matches!(err, RefreshError::Expired), "got {err:?}");
500
501 let rec = stored(&store, a.token.as_str()).await;
506 assert!(!rec.consumed);
507 assert!(!rec.revoked);
508 });
509 }
510
511 #[test]
512 fn unknown_token_rejected() {
513 let store = MemRefreshStore::default();
514 let r = RefreshRotator::new(&store, 60);
515 pollster::block_on(async {
516 let err = r.rotate("nope", 100).await.unwrap_err();
517 assert!(matches!(err, RefreshError::Unknown), "got {err:?}");
518 });
519 }
520
521 #[test]
522 fn explicit_revoke_chain_blocks_future_rotation() {
523 let store = MemRefreshStore::default();
524 let r = RefreshRotator::new(&store, 3_600);
525 pollster::block_on(async {
526 let a = r.mint_root(user(), device(), 100).await.unwrap();
527 r.revoke_chain(&a.record.chain_id).await.unwrap();
528
529 let err = r.rotate(a.token.as_str(), 110).await.unwrap_err();
530 assert!(matches!(err, RefreshError::ChainRevoked), "got {err:?}");
531 });
532 }
533
534 #[test]
535 fn revoke_chain_only_touches_its_own_chain() {
536 let store = MemRefreshStore::default();
537 let r = RefreshRotator::new(&store, 3_600);
538 pollster::block_on(async {
539 let a = r.mint_root(user(), device(), 100).await.unwrap();
540 let other = r.mint_root(user(), device(), 100).await.unwrap();
541 assert_ne!(a.record.chain_id, other.record.chain_id);
542
543 r.revoke_chain(&a.record.chain_id).await.unwrap();
544 assert!(stored(&store, a.token.as_str()).await.revoked);
545 assert!(!stored(&store, other.token.as_str()).await.revoked);
546 });
547 }
548}