mod rotation;
mod store;
mod types;
#[cfg(test)]
use crate::authn::ids::{TenantId, UserId};
#[cfg(test)]
use crate::session::data::{AuthState, SessionData};
#[cfg(test)]
use chrono::{DateTime, Duration, Utc};
#[cfg(test)]
use rotation::{collect_family_device_targets, generate_token_value, hash_token};
#[cfg(test)]
use zeroize::Zeroizing;
pub use rotation::{
IssueRequest, issue_refresh_token, refresh_session, refresh_session_with_status_check,
revoke_refresh_token,
};
pub use store::{RefreshTokenStore, RotationOutcome};
pub use types::{RefreshError, RefreshToken, RefreshTokenConfig, RefreshTokenId, TokenFamilyId};
#[cfg(test)]
mod atomic_rotation;
#[cfg(test)]
mod authn_time;
#[cfg(test)]
mod basics;
#[cfg(all(test, feature = "device"))]
mod device_cascade;
#[cfg(test)]
mod status_check;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod refresh_unit_tests {
use super::*;
use crate::testing::mock_random::MockRng;
#[test]
fn hash_token_no_pepper_is_sha256() {
let h1 = hash_token("test-token-value", None);
let h2 = hash_token("test-token-value", None);
assert_eq!(h1, h2, "hash must be deterministic");
assert!(!h1.is_empty());
}
#[test]
fn hash_token_with_pepper_differs_from_without() {
let without = hash_token("test-token", None);
let with = hash_token("test-token", Some(b"my-secret-pepper"));
assert_ne!(without, with, "peppered hash must differ from unpeppered");
}
#[test]
fn hash_token_empty_pepper_falls_back_to_sha256() {
let none_pepper = hash_token("token", None);
let empty_pepper = hash_token("token", Some(b""));
assert_eq!(
none_pepper, empty_pepper,
"empty pepper must fall back to SHA-256 path"
);
}
#[test]
fn hash_token_different_inputs_different_hashes() {
let h1 = hash_token("token-a", None);
let h2 = hash_token("token-b", None);
assert_ne!(h1, h2);
}
#[test]
fn hash_token_different_peppers_different_hashes() {
let h1 = hash_token("same-token", Some(b"pepper-1"));
let h2 = hash_token("same-token", Some(b"pepper-2"));
assert_ne!(h1, h2);
}
#[test]
fn generate_token_value_produces_nonempty_base64() {
let rng = MockRng::new(42);
let token = generate_token_value(&rng);
assert!(!token.is_empty());
assert_eq!(token.len(), 43, "32 bytes → 43 base64url chars");
}
#[test]
fn generate_token_value_is_deterministic() {
let t1 = generate_token_value(&MockRng::new(99));
let t2 = generate_token_value(&MockRng::new(99));
assert_eq!(t1, t2);
}
#[test]
fn generate_token_value_different_seeds_differ() {
let t1 = generate_token_value(&MockRng::new(1));
let t2 = generate_token_value(&MockRng::new(2));
assert_ne!(t1, t2);
}
#[test]
fn default_config_has_safe_defaults() {
let cfg = RefreshTokenConfig::default();
assert_eq!(cfg.ttl, Duration::days(30));
assert_eq!(cfg.max_per_user, 10);
assert!(cfg.rotation, "rotation must be on by default");
assert!(cfg.hash_pepper.is_none(), "no pepper by default");
}
#[test]
fn refresh_error_display_per_variant() {
use std::io;
let variants: Vec<(RefreshError<io::Error>, &str)> = vec![
(RefreshError::NotFound, "not found"),
(RefreshError::Expired, "expired"),
(RefreshError::Revoked, "revoked"),
(RefreshError::DeviceMismatch, "device mismatch"),
(RefreshError::AccountInactive, "not active"),
(RefreshError::Store(io::Error::other("boom")), "store error"),
];
for (err, expected_substr) in variants {
let msg = err.to_string();
assert!(
msg.contains(expected_substr),
"RefreshError display for {:?} must contain {expected_substr:?}, got {msg:?}",
std::mem::discriminant(&err)
);
}
}
#[test]
fn hash_pepper_is_held_in_a_wiping_wrapper() {
fn wipes_on_drop<T: zeroize::ZeroizeOnDrop>(_: &T) {}
let config = RefreshTokenConfig {
hash_pepper: Some(Zeroizing::new(b"p".to_vec())),
..RefreshTokenConfig::default()
};
wipes_on_drop(config.hash_pepper.as_ref().expect("set"));
assert_eq!(config.pepper(), Some(b"p".as_slice()));
assert_eq!(RefreshTokenConfig::default().pepper(), None);
}
#[test]
fn refresh_token_config_debug_redacts_hash_pepper() {
let pepper = b"super-secret-pepper-bytes".to_vec();
let config = RefreshTokenConfig {
hash_pepper: Some(Zeroizing::new(pepper.clone())),
..RefreshTokenConfig::default()
};
let dbg = format!("{:?}", config);
assert!(
dbg.contains("<redacted 25 bytes>"),
"Debug must redact the pepper via the manual impl; got {dbg}"
);
assert!(
!dbg.contains("super-secret") && !dbg.contains("pepper-bytes"),
"raw pepper bytes must not appear in Debug output; got {dbg}"
);
let empty = RefreshTokenConfig::default();
let dbg_empty = format!("{:?}", empty);
assert!(dbg_empty.contains("hash_pepper: None"), "got {dbg_empty}");
}
}