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
133pub struct RefreshRotator<'s, S: RefreshStore + ?Sized> {
139 store: &'s S,
140 ttl_seconds: i64,
141}
142
143impl<'s, S: RefreshStore + ?Sized> RefreshRotator<'s, S> {
144 pub fn new(store: &'s S, ttl_seconds: i64) -> Self {
145 Self { store, ttl_seconds }
146 }
147
148 pub fn ttl_seconds(&self) -> i64 {
149 self.ttl_seconds
150 }
151
152 pub async fn mint_root(
154 &self,
155 user_id: UserId,
156 device_id: DeviceId,
157 now: i64,
158 ) -> Result<Rotated, RefreshError> {
159 let token = RefreshToken::generate();
160 let chain_id = ChainId::generate();
161 let record = RefreshTokenRecord {
162 token: hash_token(token.as_str()),
164 chain_id: chain_id.into_inner(),
165 parent: None,
166 user_id,
167 device_id,
168 issued_at: now,
169 expires_at: now + self.ttl_seconds,
170 consumed: false,
171 revoked: false,
172 };
173 self.store.put(&record).await?;
174 Ok(Rotated { token, record })
175 }
176
177 pub async fn rotate(&self, presented: &str, now: i64) -> Result<Rotated, RefreshError> {
186 let presented_hash = hash_token(presented);
190 let existing = self
191 .store
192 .get(&presented_hash)
193 .await?
194 .ok_or(RefreshError::Unknown)?;
195
196 if existing.revoked {
197 return Err(RefreshError::ChainRevoked);
198 }
199 if existing.consumed {
200 self.store.revoke_chain(&existing.chain_id).await?;
204 return Err(RefreshError::Replay);
205 }
206 if existing.expires_at <= now {
207 return Err(RefreshError::Expired);
208 }
209
210 if !self.store.mark_consumed(&existing.token).await? {
224 self.store.revoke_chain(&existing.chain_id).await?;
225 return Err(RefreshError::Replay);
226 }
227
228 let token = RefreshToken::generate();
229 let record = RefreshTokenRecord {
230 token: hash_token(token.as_str()),
231 chain_id: existing.chain_id.clone(),
232 parent: Some(existing.token.clone()),
233 user_id: existing.user_id.clone(),
234 device_id: existing.device_id.clone(),
235 issued_at: now,
236 expires_at: now + self.ttl_seconds,
237 consumed: false,
238 revoked: false,
239 };
240 self.store.put(&record).await?;
241 Ok(Rotated { token, record })
242 }
243
244 pub async fn revoke_chain(&self, chain_id: &str) -> Result<(), RefreshError> {
247 self.store.revoke_chain(chain_id).await?;
248 Ok(())
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use async_trait::async_trait;
256 use cheers_core::StoreError;
257 use std::collections::HashMap;
258 use std::sync::Mutex;
259
260 #[derive(Default)]
261 struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
262
263 #[async_trait]
264 impl RefreshStore for MemRefreshStore {
265 async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
266 self.0
267 .lock()
268 .unwrap()
269 .insert(record.token.clone(), record.clone());
270 Ok(())
271 }
272 async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
273 Ok(self.0.lock().unwrap().get(token).cloned())
274 }
275 async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
276 let mut g = self.0.lock().unwrap();
277 match g.get_mut(token) {
278 Some(r) if !r.consumed => {
279 r.consumed = true;
280 Ok(true)
281 }
282 _ => Ok(false),
283 }
284 }
285 async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
286 let mut g = self.0.lock().unwrap();
287 for r in g.values_mut() {
288 if r.chain_id == chain_id {
289 r.revoked = true;
290 }
291 }
292 Ok(())
293 }
294 }
295
296 fn user() -> UserId {
297 UserId::new("u1")
298 }
299 fn device() -> DeviceId {
300 DeviceId::new("d1")
301 }
302
303 async fn stored<S: RefreshStore + ?Sized>(store: &S, raw: &str) -> RefreshTokenRecord {
307 store
308 .get(&hash_token(raw))
309 .await
310 .unwrap()
311 .expect("record present")
312 }
313
314 #[test]
315 fn token_generation_is_random_and_long() {
316 let a = RefreshToken::generate();
317 let b = RefreshToken::generate();
318 assert_ne!(a, b, "two generations must not collide");
319 assert_eq!(a.as_str().len(), 43);
321 }
322
323 #[test]
324 fn chain_id_generation_is_random() {
325 let a = ChainId::generate();
326 let b = ChainId::generate();
327 assert_ne!(a, b);
328 assert_eq!(a.as_str().len(), 22);
330 }
331
332 #[test]
333 fn happy_path_mint_and_rotate() {
334 let store = MemRefreshStore::default();
335 let r = RefreshRotator::new(&store, 3_600);
336 pollster::block_on(async {
337 let root = r.mint_root(user(), device(), 100).await.unwrap();
338 assert!(root.record.parent.is_none());
339 assert!(!root.record.consumed);
340 assert!(!root.record.revoked);
341 assert_eq!(root.record.expires_at, 3_700);
342
343 let next = r.rotate(root.token.as_str(), 200).await.unwrap();
344 assert_eq!(next.record.parent.as_deref(), Some(root.record.token.as_str()));
346 assert_eq!(next.record.chain_id, root.record.chain_id);
347 assert_eq!(next.record.user_id, root.record.user_id);
348 assert_eq!(next.record.device_id, root.record.device_id);
349 assert_ne!(next.token, root.token);
350 assert_ne!(next.record.token.as_str(), next.token.as_str());
352 assert_eq!(next.record.token, hash_token(next.token.as_str()));
353
354 let stored_root = stored(&store, root.token.as_str()).await;
356 assert!(stored_root.consumed);
357 assert!(!stored_root.revoked);
358
359 let stored_next = stored(&store, next.token.as_str()).await;
361 assert!(!stored_next.consumed);
362 assert!(!stored_next.revoked);
363 });
364 }
365
366 #[test]
367 fn rotate_chain_continues_across_multiple_hops() {
368 let store = MemRefreshStore::default();
369 let r = RefreshRotator::new(&store, 3_600);
370 pollster::block_on(async {
371 let a = r.mint_root(user(), device(), 100).await.unwrap();
372 let b = r.rotate(a.token.as_str(), 110).await.unwrap();
373 let c = r.rotate(b.token.as_str(), 120).await.unwrap();
374 let d = r.rotate(c.token.as_str(), 130).await.unwrap();
375
376 for hop in [&a, &b, &c, &d] {
377 assert_eq!(hop.record.chain_id, a.record.chain_id);
378 }
379 assert_eq!(b.record.parent.as_deref(), Some(a.record.token.as_str()));
380 assert_eq!(c.record.parent.as_deref(), Some(b.record.token.as_str()));
381 assert_eq!(d.record.parent.as_deref(), Some(c.record.token.as_str()));
382 });
383 }
384
385 #[test]
386 fn replay_revokes_entire_chain() {
387 let store = MemRefreshStore::default();
388 let r = RefreshRotator::new(&store, 3_600);
389 pollster::block_on(async {
390 let a = r.mint_root(user(), device(), 100).await.unwrap();
391 let b = r.rotate(a.token.as_str(), 110).await.unwrap();
392 let c = r.rotate(b.token.as_str(), 120).await.unwrap();
393
394 let err = r.rotate(a.token.as_str(), 130).await.unwrap_err();
396 assert!(matches!(err, RefreshError::Replay), "got {err:?}");
397
398 for tok in [a.token.as_str(), b.token.as_str(), c.token.as_str()] {
400 let rec = stored(&store, tok).await;
401 assert!(rec.revoked, "{tok} should be revoked");
402 }
403
404 let err = r.rotate(c.token.as_str(), 140).await.unwrap_err();
406 assert!(matches!(err, RefreshError::ChainRevoked), "got {err:?}");
407 });
408 }
409
410 #[test]
411 fn lost_consume_race_is_treated_as_replay() {
412 struct LostRace(MemRefreshStore);
418 #[async_trait]
419 impl RefreshStore for LostRace {
420 async fn put(&self, r: &RefreshTokenRecord) -> Result<(), StoreError> {
421 self.0.put(r).await
422 }
423 async fn get(&self, t: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
424 self.0.get(t).await
425 }
426 async fn mark_consumed(&self, _t: &str) -> Result<bool, StoreError> {
427 Ok(false) }
429 async fn revoke_chain(&self, c: &str) -> Result<(), StoreError> {
430 self.0.revoke_chain(c).await
431 }
432 }
433 let store = LostRace(MemRefreshStore::default());
434 let r = RefreshRotator::new(&store, 3_600);
435 pollster::block_on(async {
436 let a = r.mint_root(user(), device(), 100).await.unwrap();
437 let err = r.rotate(a.token.as_str(), 110).await.unwrap_err();
438 assert!(matches!(err, RefreshError::Replay), "got {err:?}");
439 assert!(
441 stored(&store.0, a.token.as_str()).await.revoked,
442 "chain should be revoked when a rotation loses the consume race"
443 );
444 });
445 }
446
447 #[test]
448 fn expired_token_rejected() {
449 let store = MemRefreshStore::default();
450 let r = RefreshRotator::new(&store, 60);
451 pollster::block_on(async {
452 let a = r.mint_root(user(), device(), 100).await.unwrap();
453 let err = r.rotate(a.token.as_str(), 160).await.unwrap_err();
455 assert!(matches!(err, RefreshError::Expired), "got {err:?}");
456
457 let rec = stored(&store, a.token.as_str()).await;
462 assert!(!rec.consumed);
463 assert!(!rec.revoked);
464 });
465 }
466
467 #[test]
468 fn unknown_token_rejected() {
469 let store = MemRefreshStore::default();
470 let r = RefreshRotator::new(&store, 60);
471 pollster::block_on(async {
472 let err = r.rotate("nope", 100).await.unwrap_err();
473 assert!(matches!(err, RefreshError::Unknown), "got {err:?}");
474 });
475 }
476
477 #[test]
478 fn explicit_revoke_chain_blocks_future_rotation() {
479 let store = MemRefreshStore::default();
480 let r = RefreshRotator::new(&store, 3_600);
481 pollster::block_on(async {
482 let a = r.mint_root(user(), device(), 100).await.unwrap();
483 r.revoke_chain(&a.record.chain_id).await.unwrap();
484
485 let err = r.rotate(a.token.as_str(), 110).await.unwrap_err();
486 assert!(matches!(err, RefreshError::ChainRevoked), "got {err:?}");
487 });
488 }
489
490 #[test]
491 fn revoke_chain_only_touches_its_own_chain() {
492 let store = MemRefreshStore::default();
493 let r = RefreshRotator::new(&store, 3_600);
494 pollster::block_on(async {
495 let a = r.mint_root(user(), device(), 100).await.unwrap();
496 let other = r.mint_root(user(), device(), 100).await.unwrap();
497 assert_ne!(a.record.chain_id, other.record.chain_id);
498
499 r.revoke_chain(&a.record.chain_id).await.unwrap();
500 assert!(stored(&store, a.token.as_str()).await.revoked);
501 assert!(!stored(&store, other.token.as_str()).await.revoked);
502 });
503 }
504}