use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use cheers_core::{DeviceId, RefreshError, UserId};
use crate::store::{RefreshStore, RefreshTokenRecord};
fn hash_token(raw: &str) -> String {
let digest = Sha256::digest(raw.as_bytes());
let mut hex = String::with_capacity(digest.len() * 2);
for b in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{b:02x}");
}
hex
}
pub const REFRESH_TOKEN_BYTES: usize = 32;
pub const CHAIN_ID_BYTES: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RefreshToken(String);
impl RefreshToken {
pub fn generate() -> Self {
let mut bytes = [0u8; REFRESH_TOKEN_BYTES];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
Self(URL_SAFE_NO_PAD.encode(bytes))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for RefreshToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ChainId(String);
impl ChainId {
pub fn generate() -> Self {
let mut bytes = [0u8; CHAIN_ID_BYTES];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
Self(URL_SAFE_NO_PAD.encode(bytes))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for ChainId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Rotated {
pub token: RefreshToken,
pub record: RefreshTokenRecord,
}
#[derive(Debug, Clone)]
pub struct LiveRefresh(RefreshTokenRecord);
impl LiveRefresh {
pub fn record(&self) -> &RefreshTokenRecord {
&self.0
}
}
pub struct RefreshRotator<'s, S: RefreshStore + ?Sized> {
store: &'s S,
ttl_seconds: i64,
}
impl<'s, S: RefreshStore + ?Sized> RefreshRotator<'s, S> {
pub fn new(store: &'s S, ttl_seconds: i64) -> Self {
Self { store, ttl_seconds }
}
pub fn ttl_seconds(&self) -> i64 {
self.ttl_seconds
}
pub async fn mint_root(
&self,
user_id: UserId,
device_id: DeviceId,
now: i64,
) -> Result<Rotated, RefreshError> {
let token = RefreshToken::generate();
let chain_id = ChainId::generate();
let record = RefreshTokenRecord {
token: hash_token(token.as_str()),
chain_id: chain_id.into_inner(),
parent: None,
user_id,
device_id,
issued_at: now,
expires_at: now + self.ttl_seconds,
consumed: false,
revoked: false,
};
self.store.put(&record).await?;
Ok(Rotated { token, record })
}
pub async fn rotate(&self, presented: &str, now: i64) -> Result<Rotated, RefreshError> {
let live = self.check(presented, now).await?;
self.commit(live, now).await
}
pub async fn check(&self, presented: &str, now: i64) -> Result<LiveRefresh, RefreshError> {
let presented_hash = hash_token(presented);
let existing = self
.store
.get(&presented_hash)
.await?
.ok_or(RefreshError::Unknown)?;
if existing.revoked {
return Err(RefreshError::ChainRevoked);
}
if existing.consumed {
self.store.revoke_chain(&existing.chain_id).await?;
return Err(RefreshError::Replay);
}
if existing.expires_at <= now {
return Err(RefreshError::Expired);
}
Ok(LiveRefresh(existing))
}
pub async fn commit(&self, live: LiveRefresh, now: i64) -> Result<Rotated, RefreshError> {
let LiveRefresh(existing) = live;
if !self.store.mark_consumed(&existing.token).await? {
self.store.revoke_chain(&existing.chain_id).await?;
return Err(RefreshError::Replay);
}
let token = RefreshToken::generate();
let record = RefreshTokenRecord {
token: hash_token(token.as_str()),
chain_id: existing.chain_id.clone(),
parent: Some(existing.token.clone()),
user_id: existing.user_id.clone(),
device_id: existing.device_id.clone(),
issued_at: now,
expires_at: now + self.ttl_seconds,
consumed: false,
revoked: false,
};
self.store.put(&record).await?;
Ok(Rotated { token, record })
}
pub async fn revoke_chain(&self, chain_id: &str) -> Result<(), RefreshError> {
self.store.revoke_chain(chain_id).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use cheers_core::StoreError;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
#[async_trait]
impl RefreshStore for MemRefreshStore {
async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
self.0
.lock()
.unwrap()
.insert(record.token.clone(), record.clone());
Ok(())
}
async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
Ok(self.0.lock().unwrap().get(token).cloned())
}
async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
let mut g = self.0.lock().unwrap();
match g.get_mut(token) {
Some(r) if !r.consumed => {
r.consumed = true;
Ok(true)
}
_ => Ok(false),
}
}
async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
let mut g = self.0.lock().unwrap();
for r in g.values_mut() {
if r.chain_id == chain_id {
r.revoked = true;
}
}
Ok(())
}
}
fn user() -> UserId {
UserId::new("u1")
}
fn device() -> DeviceId {
DeviceId::new("d1")
}
async fn stored<S: RefreshStore + ?Sized>(store: &S, raw: &str) -> RefreshTokenRecord {
store
.get(&hash_token(raw))
.await
.unwrap()
.expect("record present")
}
#[test]
fn token_generation_is_random_and_long() {
let a = RefreshToken::generate();
let b = RefreshToken::generate();
assert_ne!(a, b, "two generations must not collide");
assert_eq!(a.as_str().len(), 43);
}
#[test]
fn chain_id_generation_is_random() {
let a = ChainId::generate();
let b = ChainId::generate();
assert_ne!(a, b);
assert_eq!(a.as_str().len(), 22);
}
#[test]
fn happy_path_mint_and_rotate() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 3_600);
pollster::block_on(async {
let root = r.mint_root(user(), device(), 100).await.unwrap();
assert!(root.record.parent.is_none());
assert!(!root.record.consumed);
assert!(!root.record.revoked);
assert_eq!(root.record.expires_at, 3_700);
let next = r.rotate(root.token.as_str(), 200).await.unwrap();
assert_eq!(next.record.parent.as_deref(), Some(root.record.token.as_str()));
assert_eq!(next.record.chain_id, root.record.chain_id);
assert_eq!(next.record.user_id, root.record.user_id);
assert_eq!(next.record.device_id, root.record.device_id);
assert_ne!(next.token, root.token);
assert_ne!(next.record.token.as_str(), next.token.as_str());
assert_eq!(next.record.token, hash_token(next.token.as_str()));
let stored_root = stored(&store, root.token.as_str()).await;
assert!(stored_root.consumed);
assert!(!stored_root.revoked);
let stored_next = stored(&store, next.token.as_str()).await;
assert!(!stored_next.consumed);
assert!(!stored_next.revoked);
});
}
#[test]
fn rotate_chain_continues_across_multiple_hops() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 3_600);
pollster::block_on(async {
let a = r.mint_root(user(), device(), 100).await.unwrap();
let b = r.rotate(a.token.as_str(), 110).await.unwrap();
let c = r.rotate(b.token.as_str(), 120).await.unwrap();
let d = r.rotate(c.token.as_str(), 130).await.unwrap();
for hop in [&a, &b, &c, &d] {
assert_eq!(hop.record.chain_id, a.record.chain_id);
}
assert_eq!(b.record.parent.as_deref(), Some(a.record.token.as_str()));
assert_eq!(c.record.parent.as_deref(), Some(b.record.token.as_str()));
assert_eq!(d.record.parent.as_deref(), Some(c.record.token.as_str()));
});
}
#[test]
fn replay_revokes_entire_chain() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 3_600);
pollster::block_on(async {
let a = r.mint_root(user(), device(), 100).await.unwrap();
let b = r.rotate(a.token.as_str(), 110).await.unwrap();
let c = r.rotate(b.token.as_str(), 120).await.unwrap();
let err = r.rotate(a.token.as_str(), 130).await.unwrap_err();
assert!(matches!(err, RefreshError::Replay), "got {err:?}");
for tok in [a.token.as_str(), b.token.as_str(), c.token.as_str()] {
let rec = stored(&store, tok).await;
assert!(rec.revoked, "{tok} should be revoked");
}
let err = r.rotate(c.token.as_str(), 140).await.unwrap_err();
assert!(matches!(err, RefreshError::ChainRevoked), "got {err:?}");
});
}
#[test]
fn lost_consume_race_is_treated_as_replay() {
struct LostRace(MemRefreshStore);
#[async_trait]
impl RefreshStore for LostRace {
async fn put(&self, r: &RefreshTokenRecord) -> Result<(), StoreError> {
self.0.put(r).await
}
async fn get(&self, t: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
self.0.get(t).await
}
async fn mark_consumed(&self, _t: &str) -> Result<bool, StoreError> {
Ok(false) }
async fn revoke_chain(&self, c: &str) -> Result<(), StoreError> {
self.0.revoke_chain(c).await
}
}
let store = LostRace(MemRefreshStore::default());
let r = RefreshRotator::new(&store, 3_600);
pollster::block_on(async {
let a = r.mint_root(user(), device(), 100).await.unwrap();
let err = r.rotate(a.token.as_str(), 110).await.unwrap_err();
assert!(matches!(err, RefreshError::Replay), "got {err:?}");
assert!(
stored(&store.0, a.token.as_str()).await.revoked,
"chain should be revoked when a rotation loses the consume race"
);
});
}
#[test]
fn expired_token_rejected() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 60);
pollster::block_on(async {
let a = r.mint_root(user(), device(), 100).await.unwrap();
let err = r.rotate(a.token.as_str(), 160).await.unwrap_err();
assert!(matches!(err, RefreshError::Expired), "got {err:?}");
let rec = stored(&store, a.token.as_str()).await;
assert!(!rec.consumed);
assert!(!rec.revoked);
});
}
#[test]
fn unknown_token_rejected() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 60);
pollster::block_on(async {
let err = r.rotate("nope", 100).await.unwrap_err();
assert!(matches!(err, RefreshError::Unknown), "got {err:?}");
});
}
#[test]
fn explicit_revoke_chain_blocks_future_rotation() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 3_600);
pollster::block_on(async {
let a = r.mint_root(user(), device(), 100).await.unwrap();
r.revoke_chain(&a.record.chain_id).await.unwrap();
let err = r.rotate(a.token.as_str(), 110).await.unwrap_err();
assert!(matches!(err, RefreshError::ChainRevoked), "got {err:?}");
});
}
#[test]
fn revoke_chain_only_touches_its_own_chain() {
let store = MemRefreshStore::default();
let r = RefreshRotator::new(&store, 3_600);
pollster::block_on(async {
let a = r.mint_root(user(), device(), 100).await.unwrap();
let other = r.mint_root(user(), device(), 100).await.unwrap();
assert_ne!(a.record.chain_id, other.record.chain_id);
r.revoke_chain(&a.record.chain_id).await.unwrap();
assert!(stored(&store, a.token.as_str()).await.revoked);
assert!(!stored(&store, other.token.as_str()).await.revoked);
});
}
}