Skip to main content

cheers_server/
refresh.rs

1//! Refresh-token rotation with replay detection.
2//!
3//! Sessions are minted by a [`TokenMinter`](cheers_core::TokenMinter); refresh
4//! tokens are the *stateful* half — short-lived access tokens are exchanged
5//! through a long-lived refresh token that rotates on every use. Each rotation:
6//!
7//! 1. consumes the presented token,
8//! 2. issues a fresh successor sharing the same `chain_id`,
9//! 3. links the successor back to its parent.
10//!
11//! If a *consumed* token is presented again, that's a replay — the entire
12//! chain is revoked. The pattern (audited in Rauthy
13//! `src/data/src/entity/sessions.rs`) keeps a stolen refresh token from
14//! being usable indefinitely: the moment either the legitimate client or
15//! the attacker rotates it, the next rotation by the other party trips the
16//! replay path and severs both.
17//!
18//! ```no_run
19//! # use cheers_server::refresh::RefreshRotator;
20//! # use cheers_server::store::RefreshStore;
21//! # use cheers_core::{RefreshError, UserId, DeviceId};
22//! # async fn demo<S: RefreshStore + ?Sized>(store: &S) -> Result<(), RefreshError> {
23//! let rotator = RefreshRotator::new(store, /* ttl_seconds */ 60 * 60 * 24 * 30);
24//! let root = rotator.mint_root(UserId::new("u1"), DeviceId::new("d1"), 1_700_000_000).await?;
25//! let next = rotator.rotate(root.token.as_str(), 1_700_000_001).await?;
26//! assert_eq!(next.record.chain_id, root.record.chain_id); // successor shares the chain
27//! # Ok(()) }
28//! ```
29
30use 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
38/// Hash a refresh secret for storage. The raw secret is handed to the client;
39/// only this hash is ever persisted (as `RefreshTokenRecord::token` and looked
40/// up by the rotator), so a read-only leak of the DB or redis yields hashes,
41/// not usable session tokens. SHA-256 is sufficient here: the input is 256 bits
42/// of CSPRNG output, so there is nothing to brute-force — the hash exists to
43/// keep plaintext bearer secrets out of storage, not to slow a dictionary
44/// attack. Hex-encoded for a stable, index-friendly, ASCII key.
45fn 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
55/// Length of the opaque refresh secret in bytes (256 bits of CSPRNG).
56pub const REFRESH_TOKEN_BYTES: usize = 32;
57
58/// Length of the chain identifier in bytes (128 bits — uniqueness only,
59/// not a secret).
60pub const CHAIN_ID_BYTES: usize = 16;
61
62/// Opaque refresh-token secret. 32 random bytes, base64url-encoded for
63/// transport. This is the client-facing wire value; only its
64/// [`hash_token`] digest is persisted in `RefreshTokenRecord::token`.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct RefreshToken(String);
68
69impl RefreshToken {
70    /// Generate a fresh token from the OS CSPRNG.
71    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/// Identifier shared by every token in a single rotation chain.
93///
94/// One chain = one `(user, device)` login session. Revoking a chain logs out
95/// that device.
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(transparent)]
98pub struct ChainId(String);
99
100impl ChainId {
101    /// Generate a fresh chain id from the OS CSPRNG.
102    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/// Result of [`RefreshRotator::mint_root`] / [`RefreshRotator::rotate`].
124#[derive(Debug, Clone)]
125#[non_exhaustive]
126pub struct Rotated {
127    /// The freshly issued secret. Hand this to the client; everything else
128    /// in `record` is what's persisted.
129    pub token: RefreshToken,
130    pub record: RefreshTokenRecord,
131}
132
133/// A refresh record that [`RefreshRotator::check`] found spendable at the
134/// time of the check: known, not revoked, not consumed, not expired.
135///
136/// Opaque on purpose: the only way to build one is a successful check, so the
137/// only way to reach [`RefreshRotator::commit`] is through it.
138#[derive(Debug, Clone)]
139pub struct LiveRefresh(RefreshTokenRecord);
140
141impl LiveRefresh {
142    /// The checked record — read `user_id` / `device_id` off it to decide
143    /// whether to commit.
144    pub fn record(&self) -> &RefreshTokenRecord {
145        &self.0
146    }
147}
148
149/// Mint and rotate refresh tokens against a [`RefreshStore`].
150///
151/// Holds a borrow of the store; the rotator itself is cheap to construct
152/// per request. `ttl_seconds` is the lifetime applied to every freshly
153/// minted token (root *and* successor).
154pub 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    /// Start a fresh chain. Returns the root token + persisted record.
169    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            // Persist only the hash; the raw `token` goes to the client below.
179            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    /// Rotate `presented` → fresh successor.
194    ///
195    /// Failure modes (in priority order):
196    /// - [`RefreshError::Unknown`] — no record matches.
197    /// - [`RefreshError::ChainRevoked`] — record exists but the chain is dead.
198    /// - [`RefreshError::Replay`] — the token was already consumed; the
199    ///   chain is revoked as a side effect of this call.
200    /// - [`RefreshError::Expired`] — within TTL is required.
201    ///
202    /// Equivalent to [`check`](Self::check) followed by
203    /// [`commit`](Self::commit); split callers use the gap between the two to
204    /// refuse a rotation without spending the token.
205    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    /// Phase one of a rotation: look `presented` up and prove it is spendable,
211    /// **without spending it**.
212    ///
213    /// Every failure of [`rotate`](Self::rotate) except a lost consume race
214    /// surfaces here — including replay, whose chain revocation happens here
215    /// as a side effect. A successful check mutates nothing, so a caller that
216    /// decides not to [`commit`](Self::commit) (R730: no device binding to
217    /// mint with) leaves the chain exactly as it found it.
218    pub async fn check(&self, presented: &str, now: i64) -> Result<LiveRefresh, RefreshError> {
219        // The store is keyed by hash, so hash the presented secret before every
220        // lookup/mutation. `existing.token` read back is therefore already the
221        // hash (used as-is for mark_consumed and as the successor's `parent`).
222        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            // Replay: the token was already exchanged for a successor.
234            // Revoke the entire chain so neither the legitimate client nor
235            // the attacker can continue.
236            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    /// Phase two of a rotation: spend the checked token and issue its
246    /// successor on the same chain.
247    ///
248    /// The [`LiveRefresh`] argument can only come from [`check`](Self::check),
249    /// so a commit is always preceded by the full validity check. The check
250    /// result is a snapshot, though, and the consume below is the real gate.
251    pub async fn commit(&self, live: LiveRefresh, now: i64) -> Result<Rotated, RefreshError> {
252        let LiveRefresh(existing) = live;
253
254        // Atomically consume *first*, and treat losing the race as a replay.
255        // The `existing.consumed` check above is only a fast path off a stale
256        // read — two concurrent rotations of the same live token can both pass
257        // it. `mark_consumed` is the real gate: it flips false→true in a single
258        // atomic step and returns `true` only to the winner. A `false` here
259        // means a concurrent rotation already consumed this token (a
260        // double-spend / stolen-token replay), so revoke the whole chain and
261        // reject — exactly as if the caller had re-presented a consumed token.
262        //
263        // Consuming before minting the successor is deliberate: if the
264        // successor `put` fails afterwards the worst case is a chain that can't
265        // rotate further (the client retries, gets `Replay`, re-authenticates)
266        // — far better than handing out two live tokens for one consume.
267        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    /// Revoke every token in `chain_id`. Idempotent. Use on logout or
289    /// device-revoke flows.
290    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    /// Look a record up by its *raw* token. The store is keyed by the hash the
348    /// rotator computes, so a test can't `get` a raw secret directly — it hashes
349    /// the same way first.
350    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        // base64url-no-pad of 32 bytes = ceil(32 * 4 / 3) = 43 chars.
364        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        // 16 bytes b64url-no-pad = 22 chars.
373        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            // `parent` links by the stored key (the hash), not the raw secret.
389            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            // The persisted key is the hash, never the raw secret.
395            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            // Old token now reads as consumed.
399            let stored_root = stored(&store, root.token.as_str()).await;
400            assert!(stored_root.consumed);
401            assert!(!stored_root.revoked);
402
403            // The successor is fresh.
404            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            // Replay `a` — it's already been rotated.
439            let err = r.rotate(a.token.as_str(), 130).await.unwrap_err();
440            assert!(matches!(err, RefreshError::Replay), "got {err:?}");
441
442            // Every record in the chain is now revoked.
443            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            // Even the still-fresh tip can no longer rotate.
449            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        // Two concurrent rotations of the same live token: `get` still sees it
457        // unconsumed (so the fast-path replay check passes), but the atomic
458        // `mark_consumed` reports `false` because the other rotation won the
459        // race. The rotator must treat that as a replay and revoke the chain —
460        // otherwise a double-spend mints two live successors.
461        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) // always "lost the race"
472            }
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            // The chain is revoked as a side effect of the lost race.
484            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            // expires_at = 160; presenting at exactly 160 is expired.
498            let err = r.rotate(a.token.as_str(), 160).await.unwrap_err();
499            assert!(matches!(err, RefreshError::Expired), "got {err:?}");
500
501            // The expired token is *not* marked consumed (the store is untouched
502            // beyond the read), so a future call still reports Expired rather
503            // than Replay. That matters for ops: an expired client should
504            // re-auth without tripping the alarm-bell replay path.
505            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}