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/// Mint and rotate refresh tokens against a [`RefreshStore`].
134///
135/// Holds a borrow of the store; the rotator itself is cheap to construct
136/// per request. `ttl_seconds` is the lifetime applied to every freshly
137/// minted token (root *and* successor).
138pub 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    /// Start a fresh chain. Returns the root token + persisted record.
153    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            // Persist only the hash; the raw `token` goes to the client below.
163            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    /// Rotate `presented` → fresh successor.
178    ///
179    /// Failure modes (in priority order):
180    /// - [`RefreshError::Unknown`] — no record matches.
181    /// - [`RefreshError::ChainRevoked`] — record exists but the chain is dead.
182    /// - [`RefreshError::Replay`] — the token was already consumed; the
183    ///   chain is revoked as a side effect of this call.
184    /// - [`RefreshError::Expired`] — within TTL is required.
185    pub async fn rotate(&self, presented: &str, now: i64) -> Result<Rotated, RefreshError> {
186        // The store is keyed by hash, so hash the presented secret before every
187        // lookup/mutation. `existing.token` read back is therefore already the
188        // hash (used as-is for mark_consumed and as the successor's `parent`).
189        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            // Replay: the token was already exchanged for a successor.
201            // Revoke the entire chain so neither the legitimate client nor
202            // the attacker can continue.
203            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        // Atomically consume *first*, and treat losing the race as a replay.
211        // The `existing.consumed` check above is only a fast path off a stale
212        // read — two concurrent rotations of the same live token can both pass
213        // it. `mark_consumed` is the real gate: it flips false→true in a single
214        // atomic step and returns `true` only to the winner. A `false` here
215        // means a concurrent rotation already consumed this token (a
216        // double-spend / stolen-token replay), so revoke the whole chain and
217        // reject — exactly as if the caller had re-presented a consumed token.
218        //
219        // Consuming before minting the successor is deliberate: if the
220        // successor `put` fails afterwards the worst case is a chain that can't
221        // rotate further (the client retries, gets `Replay`, re-authenticates)
222        // — far better than handing out two live tokens for one consume.
223        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    /// Revoke every token in `chain_id`. Idempotent. Use on logout or
245    /// device-revoke flows.
246    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    /// Look a record up by its *raw* token. The store is keyed by the hash the
304    /// rotator computes, so a test can't `get` a raw secret directly — it hashes
305    /// the same way first.
306    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        // base64url-no-pad of 32 bytes = ceil(32 * 4 / 3) = 43 chars.
320        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        // 16 bytes b64url-no-pad = 22 chars.
329        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            // `parent` links by the stored key (the hash), not the raw secret.
345            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            // The persisted key is the hash, never the raw secret.
351            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            // Old token now reads as consumed.
355            let stored_root = stored(&store, root.token.as_str()).await;
356            assert!(stored_root.consumed);
357            assert!(!stored_root.revoked);
358
359            // The successor is fresh.
360            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            // Replay `a` — it's already been rotated.
395            let err = r.rotate(a.token.as_str(), 130).await.unwrap_err();
396            assert!(matches!(err, RefreshError::Replay), "got {err:?}");
397
398            // Every record in the chain is now revoked.
399            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            // Even the still-fresh tip can no longer rotate.
405            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        // Two concurrent rotations of the same live token: `get` still sees it
413        // unconsumed (so the fast-path replay check passes), but the atomic
414        // `mark_consumed` reports `false` because the other rotation won the
415        // race. The rotator must treat that as a replay and revoke the chain —
416        // otherwise a double-spend mints two live successors.
417        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) // always "lost the race"
428            }
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            // The chain is revoked as a side effect of the lost race.
440            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            // expires_at = 160; presenting at exactly 160 is expired.
454            let err = r.rotate(a.token.as_str(), 160).await.unwrap_err();
455            assert!(matches!(err, RefreshError::Expired), "got {err:?}");
456
457            // The expired token is *not* marked consumed (the store is untouched
458            // beyond the read), so a future call still reports Expired rather
459            // than Replay. That matters for ops: an expired client should
460            // re-auth without tripping the alarm-bell replay path.
461            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}