use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest as _, Sha256};
const REMEMBER_ID_BYTES: usize = 24;
const fn default_remember_duration_secs() -> u64 {
2_592_000
}
fn default_remember_cookie_name() -> String {
"autumn.remember".to_owned()
}
const fn default_remember_enabled() -> bool {
true
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct RememberConfig {
pub enabled: bool,
pub duration_secs: u64,
pub cookie_name: String,
}
impl Default for RememberConfig {
fn default() -> Self {
Self {
enabled: default_remember_enabled(),
duration_secs: default_remember_duration_secs(),
cookie_name: default_remember_cookie_name(),
}
}
}
#[derive(Debug, Clone)]
pub struct RememberCredential {
pub series: String,
pub token: String,
}
#[derive(Debug, Clone)]
pub struct RememberRecord {
pub series: String,
pub token_hash: String,
pub previous_token_hash: Option<String>,
pub rotated_at: Option<chrono::DateTime<chrono::Utc>>,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum RememberDecision {
Rotate,
Accept,
Theft,
Reject,
}
fn generate_opaque_id() -> String {
let mut bytes = [0u8; REMEMBER_ID_BYTES];
getrandom::getrandom(&mut bytes).expect("getrandom must not fail on supported platforms");
URL_SAFE_NO_PAD.encode(bytes)
}
#[must_use]
pub fn generate_remember_credential() -> RememberCredential {
RememberCredential {
series: generate_opaque_id(),
token: generate_opaque_id(),
}
}
#[must_use]
pub fn generate_token() -> String {
generate_opaque_id()
}
#[must_use]
pub fn hash_remember_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
hex::encode(digest)
}
#[must_use]
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq as _;
if a.len() != b.len() {
return false;
}
a.ct_eq(b).into()
}
#[must_use]
pub fn verify_remember_token(token: &str, stored_hash: &str) -> bool {
let computed = hash_remember_token(token);
constant_time_eq(computed.as_bytes(), stored_hash.as_bytes())
}
#[must_use]
pub fn format_remember_cookie_value(series: &str, token: &str) -> String {
format!("{series}:{token}")
}
#[must_use]
pub fn parse_remember_cookie_value(value: &str) -> Option<(String, String)> {
let (series, token) = value.split_once(':')?;
if series.is_empty() || token.is_empty() {
return None;
}
Some((series.to_owned(), token.to_owned()))
}
#[must_use]
pub fn build_remember_cookie(
config: &RememberConfig,
series: &str,
token: &str,
secure: bool,
) -> String {
use std::fmt::Write as _;
let value = format_remember_cookie_value(series, token);
let mut cookie = format!("{}={value}; Path=/", config.cookie_name);
let _ = write!(cookie, "; Max-Age={}", config.duration_secs);
cookie.push_str("; HttpOnly");
if secure {
cookie.push_str("; Secure");
}
cookie.push_str("; SameSite=Lax");
cookie
}
#[must_use]
pub fn build_remember_clear_cookie(config: &RememberConfig) -> String {
format!(
"{}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax",
config.cookie_name
)
}
pub const DEFAULT_ROTATION_GRACE_SECS: i64 = 60;
#[must_use]
pub const fn default_rotation_grace() -> chrono::Duration {
chrono::Duration::seconds(DEFAULT_ROTATION_GRACE_SECS)
}
#[must_use]
pub fn evaluate_remember(
presented_token: &str,
record: Option<&RememberRecord>,
now: chrono::DateTime<chrono::Utc>,
grace: chrono::Duration,
) -> RememberDecision {
let Some(record) = record else {
return RememberDecision::Reject;
};
if record.expires_at <= now {
return RememberDecision::Reject;
}
if verify_remember_token(presented_token, &record.token_hash) {
return RememberDecision::Rotate;
}
if let (Some(prev), Some(rotated_at)) = (&record.previous_token_hash, record.rotated_at)
&& verify_remember_token(presented_token, prev)
&& now - rotated_at <= grace
{
return RememberDecision::Accept;
}
RememberDecision::Theft
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Utc};
fn ts(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).expect("valid timestamp")
}
fn grace() -> chrono::Duration {
chrono::Duration::seconds(60)
}
#[test]
fn generate_remember_credential_is_random_and_distinct() {
let cred = generate_remember_credential();
assert!(!cred.series.is_empty());
assert!(!cred.token.is_empty());
assert_ne!(cred.series, cred.token, "series and token must differ");
assert!(
cred.series.len() >= 20,
"series too short: {}",
cred.series.len()
);
assert!(
cred.token.len() >= 20,
"token too short: {}",
cred.token.len()
);
let other = generate_remember_credential();
assert_ne!(cred.series, other.series, "two calls must differ (series)");
assert_ne!(cred.token, other.token, "two calls must differ (token)");
}
#[test]
fn generate_token_is_random_and_long() {
let a = generate_token();
let b = generate_token();
assert!(a.len() >= 20);
assert_ne!(a, b, "two token calls must differ");
}
#[test]
fn hash_remember_token_is_deterministic() {
let token = "example-token-value";
assert_eq!(hash_remember_token(token), hash_remember_token(token));
assert_eq!(hash_remember_token(token).len(), 64);
assert_ne!(hash_remember_token("a"), hash_remember_token("b"));
}
#[test]
fn verify_remember_token_matches_correct_and_rejects_wrong() {
let token = "the-real-token";
let stored = hash_remember_token(token);
assert!(verify_remember_token(token, &stored));
assert!(!verify_remember_token("a-different-token", &stored));
}
#[test]
fn constant_time_eq_correctness() {
assert!(constant_time_eq(b"abcdef", b"abcdef"));
assert!(!constant_time_eq(b"abcdef", b"abcdeg"));
assert!(!constant_time_eq(b"abc", b"abcdef"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn evaluate_matching_token_not_expired_rotates() {
let token = "live-token";
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token(token),
previous_token_hash: None,
rotated_at: None,
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember(token, Some(&record), ts(1_000), grace()),
RememberDecision::Rotate
);
}
#[test]
fn evaluate_current_token_rotates_even_with_previous_present() {
let current = "current-token";
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token(current),
previous_token_hash: Some(hash_remember_token("old-token")),
rotated_at: Some(ts(990)),
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember(current, Some(&record), ts(1_000), grace()),
RememberDecision::Rotate
);
}
#[test]
fn evaluate_previous_token_within_grace_is_accept() {
let previous = "just-rotated-out";
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token("brand-new-token"),
previous_token_hash: Some(hash_remember_token(previous)),
rotated_at: Some(ts(1_000)),
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember(previous, Some(&record), ts(1_030), grace()),
RememberDecision::Accept
);
assert_eq!(
evaluate_remember(previous, Some(&record), ts(1_060), grace()),
RememberDecision::Accept
);
}
#[test]
fn evaluate_previous_token_after_grace_is_theft() {
let previous = "rotated-out-long-ago";
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token("brand-new-token"),
previous_token_hash: Some(hash_remember_token(previous)),
rotated_at: Some(ts(1_000)),
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember(previous, Some(&record), ts(1_061), grace()),
RememberDecision::Theft
);
}
#[test]
fn evaluate_wrong_token_known_series_is_theft() {
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token("the-current-token"),
previous_token_hash: None,
rotated_at: None,
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember("a-rotated-out-token", Some(&record), ts(1_000), grace()),
RememberDecision::Theft
);
}
#[test]
fn evaluate_unknown_token_with_previous_present_is_theft() {
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token("current-token"),
previous_token_hash: Some(hash_remember_token("previous-token")),
rotated_at: Some(ts(1_000)),
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember("some-unrelated-token", Some(&record), ts(1_010), grace()),
RememberDecision::Theft
);
}
#[test]
fn evaluate_wrong_token_no_previous_is_theft_not_accept() {
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token("the-current-token"),
previous_token_hash: None,
rotated_at: Some(ts(1_000)),
expires_at: ts(2_000),
};
assert_eq!(
evaluate_remember("wrong-token", Some(&record), ts(1_010), grace()),
RememberDecision::Theft
);
}
#[test]
fn evaluate_unknown_series_is_reject() {
assert_eq!(
evaluate_remember("whatever", None, ts(1_000), grace()),
RememberDecision::Reject
);
}
#[test]
fn evaluate_expired_record_is_reject_even_with_correct_token() {
let token = "correct-but-expired";
let record = RememberRecord {
series: "series-1".to_owned(),
token_hash: hash_remember_token(token),
previous_token_hash: None,
rotated_at: None,
expires_at: ts(1_000),
};
assert_eq!(
evaluate_remember(token, Some(&record), ts(1_000), grace()),
RememberDecision::Reject
);
assert_eq!(
evaluate_remember(token, Some(&record), ts(1_001), grace()),
RememberDecision::Reject
);
}
#[test]
fn default_rotation_grace_matches_constant() {
assert_eq!(DEFAULT_ROTATION_GRACE_SECS, 60);
assert_eq!(
default_rotation_grace(),
chrono::Duration::seconds(DEFAULT_ROTATION_GRACE_SECS)
);
}
#[test]
fn cookie_value_round_trip() {
let series = "series-abc";
let token = "token-xyz";
let formatted = format_remember_cookie_value(series, token);
assert_eq!(formatted, "series-abc:token-xyz");
assert_eq!(
parse_remember_cookie_value(&formatted),
Some((series.to_owned(), token.to_owned()))
);
}
#[test]
fn parse_cookie_value_rejects_malformed() {
assert_eq!(parse_remember_cookie_value(""), None);
assert_eq!(parse_remember_cookie_value("noseparator"), None);
assert_eq!(parse_remember_cookie_value(":"), None);
assert_eq!(parse_remember_cookie_value("a:"), None);
assert_eq!(parse_remember_cookie_value(":b"), None);
}
#[test]
fn parse_cookie_value_splits_once() {
assert_eq!(
parse_remember_cookie_value("series:tok:with:colons"),
Some(("series".to_owned(), "tok:with:colons".to_owned()))
);
}
#[test]
fn build_remember_cookie_attributes() {
let config = RememberConfig::default();
let secure = build_remember_cookie(&config, "s", "t", true);
assert!(secure.contains("autumn.remember=s:t"));
assert!(secure.contains("Max-Age=2592000"));
assert!(secure.contains("HttpOnly"));
assert!(secure.contains("SameSite=Lax"));
assert!(secure.contains("Path=/"));
assert!(secure.contains("Secure"));
let insecure = build_remember_cookie(&config, "s", "t", false);
assert!(!insecure.contains("Secure"));
assert!(insecure.contains("HttpOnly"));
}
#[test]
fn build_remember_clear_cookie_expires() {
let config = RememberConfig::default();
let clear = build_remember_clear_cookie(&config);
assert!(clear.contains("autumn.remember="));
assert!(clear.contains("Max-Age=0"));
}
#[test]
fn config_defaults() {
let config = RememberConfig::default();
assert!(config.enabled);
assert_eq!(config.duration_secs, 2_592_000);
assert_eq!(config.cookie_name, "autumn.remember");
}
#[test]
fn config_deserializes_partial_toml() {
let config: RememberConfig =
toml::from_str("duration_secs = 3600").expect("valid partial config");
assert_eq!(config.duration_secs, 3_600);
assert!(config.enabled);
assert_eq!(config.cookie_name, "autumn.remember");
}
}