pub mod token;
use std::fmt;
use crate::crypto::constant_time::constant_time_eq;
use crate::crypto::zeroize::Zeroizing;
use crate::session::token::{generate_token_with_hash, hash_token_for_lookup};
use crate::util::log::{debug, info, warn};
use crate::util::timestamp::Timestamp;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionConfig {
token_bytes: usize,
lifetime_secs: u64,
idle_timeout_secs: u64,
allow_refresh: bool,
max_refreshes: u32,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
token_bytes: 32,
lifetime_secs: 3600,
idle_timeout_secs: 1800,
allow_refresh: true,
max_refreshes: 5,
}
}
}
impl SessionConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
const MIN_TOKEN_BYTES: usize = 16;
#[must_use]
pub fn with_token_bytes(mut self, token_bytes: usize) -> Self {
self.token_bytes = token_bytes.max(Self::MIN_TOKEN_BYTES);
self
}
#[must_use]
pub fn with_lifetime_secs(mut self, lifetime_secs: u64) -> Self {
self.lifetime_secs = lifetime_secs;
self
}
#[must_use]
pub fn with_idle_timeout_secs(mut self, idle_timeout_secs: u64) -> Self {
self.idle_timeout_secs = idle_timeout_secs;
self
}
#[must_use]
pub fn with_allow_refresh(mut self, allow_refresh: bool) -> Self {
self.allow_refresh = allow_refresh;
self
}
#[must_use]
pub fn with_max_refreshes(mut self, max_refreshes: u32) -> Self {
self.max_refreshes = max_refreshes;
self
}
#[must_use]
#[inline]
pub fn token_bytes(&self) -> usize {
self.token_bytes
}
#[must_use]
#[inline]
pub fn lifetime_secs(&self) -> u64 {
self.lifetime_secs
}
#[must_use]
#[inline]
pub fn idle_timeout_secs(&self) -> u64 {
self.idle_timeout_secs
}
#[must_use]
#[inline]
pub fn allow_refresh(&self) -> bool {
self.allow_refresh
}
#[must_use]
#[inline]
pub fn max_refreshes(&self) -> u32 {
self.max_refreshes
}
}
#[derive(Debug, Clone)]
pub struct Session {
token_hash: [u8; 32],
created_at: Timestamp,
expires_at: Timestamp,
last_activity: Timestamp,
refresh_count: u32,
max_refreshes: u32,
allow_refresh: bool,
idle_timeout_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionValidation {
Valid,
Expired,
IdleTimeout,
InvalidToken,
}
impl Session {
pub fn create(config: &SessionConfig) -> Result<(Zeroizing<String>, Self), SessionError> {
let (token, token_hash) = generate_token_with_hash(config.token_bytes)
.map_err(|_| SessionError::new(SessionErrorKind::RandomFailure))?;
let now = Timestamp::now();
let expires_at =
Timestamp::from_unix_secs(now.unix_epoch_secs().saturating_add(config.lifetime_secs));
let session = Self {
token_hash,
created_at: now,
expires_at,
last_activity: now,
refresh_count: 0,
max_refreshes: config.max_refreshes,
allow_refresh: config.allow_refresh,
idle_timeout_secs: config.idle_timeout_secs,
};
info!(
lifetime_secs = config.lifetime_secs,
idle_timeout_secs = config.idle_timeout_secs,
"session: created"
);
Ok((token, session))
}
#[must_use]
#[inline]
pub fn validate(&self, token: &str, now: &Timestamp) -> SessionValidation {
let token_valid = self.verify_token(token);
let expired = self.expires_at.is_expired(now);
let idle_deadline = Timestamp::from_unix_secs(
self.last_activity
.unix_epoch_secs()
.saturating_add(self.idle_timeout_secs),
);
let idle_expired = idle_deadline.is_expired(now);
if !token_valid {
warn!("session: validation failed (invalid token)");
SessionValidation::InvalidToken
} else if expired {
warn!("session: validation failed (expired)");
SessionValidation::Expired
} else if idle_expired {
warn!("session: validation failed (idle timeout)");
SessionValidation::IdleTimeout
} else {
debug!("session: validated successfully");
SessionValidation::Valid
}
}
pub fn refresh(
&mut self,
config: &SessionConfig,
now: &Timestamp,
) -> Result<Zeroizing<String>, SessionError> {
if !self.allow_refresh {
warn!("session: refresh rejected (not allowed)");
return Err(SessionError::new(SessionErrorKind::RefreshNotAllowed));
}
if self.refresh_count >= self.max_refreshes {
warn!("session: refresh rejected (max refreshes exceeded)");
return Err(SessionError::new(SessionErrorKind::MaxRefreshesExceeded));
}
if self.expires_at.is_expired(now) {
warn!("session: refresh rejected (expired)");
return Err(SessionError::new(SessionErrorKind::Expired));
}
let idle_deadline = Timestamp::from_unix_secs(
self.last_activity
.unix_epoch_secs()
.saturating_add(self.idle_timeout_secs),
);
if idle_deadline.is_expired(now) {
warn!("session: refresh rejected (idle timeout)");
return Err(SessionError::new(SessionErrorKind::IdleTimeout));
}
let (token, token_hash) = generate_token_with_hash(config.token_bytes)
.map_err(|_| SessionError::new(SessionErrorKind::RandomFailure))?;
self.token_hash = token_hash;
self.last_activity = *now;
self.refresh_count += 1;
debug!(
count = self.refresh_count,
max = self.max_refreshes,
"session: refreshed"
);
Ok(token)
}
#[must_use]
pub fn token_hash(&self) -> &[u8; 32] {
&self.token_hash
}
#[must_use]
pub fn created_at(&self) -> &Timestamp {
&self.created_at
}
#[must_use]
pub fn expires_at(&self) -> &Timestamp {
&self.expires_at
}
fn verify_token(&self, token: &str) -> bool {
constant_time_eq(&hash_token_for_lookup(token), &self.token_hash)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionErrorKind {
RandomFailure,
RefreshNotAllowed,
MaxRefreshesExceeded,
Expired,
IdleTimeout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionError {
kind: SessionErrorKind,
}
impl SessionError {
const fn new(kind: SessionErrorKind) -> Self {
Self { kind }
}
}
impl fmt::Display for SessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
SessionErrorKind::RandomFailure => {
write!(f, "session: random number generation failed")
}
SessionErrorKind::RefreshNotAllowed => {
write!(f, "session: refresh not allowed")
}
SessionErrorKind::MaxRefreshesExceeded => {
write!(f, "session: maximum refresh count exceeded")
}
SessionErrorKind::Expired => {
write!(f, "session: cannot refresh an expired session")
}
SessionErrorKind::IdleTimeout => {
write!(f, "session: cannot refresh an idle-timed-out session")
}
}
}
}
impl std::error::Error for SessionError {}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> SessionConfig {
SessionConfig::new()
.with_token_bytes(32)
.with_lifetime_secs(3600)
.with_idle_timeout_secs(1800)
.with_allow_refresh(true)
.with_max_refreshes(5)
}
#[test]
fn create_and_validate_valid_session() {
let config = test_config();
let (token, session) = Session::create(&config).unwrap();
let now = Timestamp::now();
assert_eq!(session.validate(&token, &now), SessionValidation::Valid);
}
#[test]
fn create_produces_non_empty_token() {
let config = test_config();
let (token, _session) = Session::create(&config).unwrap();
assert!(!token.is_empty());
assert_eq!(
token.len(),
config.token_bytes * 2,
"hex-encoded token length"
);
}
#[test]
fn validate_rejects_invalid_token() {
let config = test_config();
let (_token, session) = Session::create(&config).unwrap();
let now = Timestamp::now();
assert_eq!(
session.validate(
"0000000000000000000000000000000000000000000000000000000000000000",
&now
),
SessionValidation::InvalidToken,
);
}
#[test]
fn validate_rejects_malformed_token() {
let config = test_config();
let (_token, session) = Session::create(&config).unwrap();
let now = Timestamp::now();
assert_eq!(
session.validate("not-valid-hex!!!", &now),
SessionValidation::InvalidToken,
);
}
#[test]
fn validate_detects_expired_session() {
let config = test_config();
let (token, session) = Session::create(&config).unwrap();
let future = Timestamp::from_unix_secs(
session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
);
assert_eq!(
session.validate(&token, &future),
SessionValidation::Expired
);
}
#[test]
fn validate_detects_idle_timeout() {
let config = test_config()
.with_idle_timeout_secs(60)
.with_lifetime_secs(3600);
let (token, session) = Session::create(&config).unwrap();
let idle_expired = Timestamp::from_unix_secs(session.created_at().unix_epoch_secs() + 61);
assert_eq!(
session.validate(&token, &idle_expired),
SessionValidation::IdleTimeout,
);
}
#[test]
fn validate_invalid_token_wins_over_expiry() {
let config = test_config();
let (_token, session) = Session::create(&config).unwrap();
let future = Timestamp::from_unix_secs(
session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
);
assert_eq!(
session.validate(
"0000000000000000000000000000000000000000000000000000000000000000",
&future
),
SessionValidation::InvalidToken,
);
}
#[test]
fn validate_expiry_wins_over_idle() {
let config = test_config();
let (token, session) = Session::create(&config).unwrap();
let past_both = Timestamp::from_unix_secs(
session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
);
assert_eq!(
session.validate(&token, &past_both),
SessionValidation::Expired,
);
}
#[test]
fn refresh_produces_new_valid_token() {
let config = test_config();
let (old_token, mut session) = Session::create(&config).unwrap();
let now = Timestamp::now();
let new_token = session.refresh(&config, &now).unwrap();
assert_eq!(
session.validate(&old_token, &now),
SessionValidation::InvalidToken,
);
assert_eq!(session.validate(&new_token, &now), SessionValidation::Valid);
}
#[test]
fn refresh_respects_max_refreshes() {
let config = SessionConfig {
max_refreshes: 2,
..test_config()
};
let (_token, mut session) = Session::create(&config).unwrap();
let now = Timestamp::now();
session.refresh(&config, &now).unwrap();
session.refresh(&config, &now).unwrap();
assert_eq!(
session.refresh(&config, &now).unwrap_err(),
SessionError::new(SessionErrorKind::MaxRefreshesExceeded),
);
}
#[test]
fn refresh_rejected_when_not_allowed() {
let config = SessionConfig {
allow_refresh: false,
..test_config()
};
let (_token, mut session) = Session::create(&config).unwrap();
let now = Timestamp::now();
assert_eq!(
session.refresh(&config, &now).unwrap_err(),
SessionError::new(SessionErrorKind::RefreshNotAllowed),
);
}
#[test]
fn refresh_rejected_when_expired() {
let config = test_config();
let (_token, mut session) = Session::create(&config).unwrap();
let future = Timestamp::from_unix_secs(
session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
);
assert_eq!(
session.refresh(&config, &future).unwrap_err(),
SessionError::new(SessionErrorKind::Expired),
);
}
#[test]
fn refresh_rejected_when_idle_timed_out() {
let config = test_config()
.with_idle_timeout_secs(60)
.with_lifetime_secs(3600);
let (_token, mut session) = Session::create(&config).unwrap();
let idle = Timestamp::from_unix_secs(session.created_at().unix_epoch_secs() + 61);
assert_eq!(
session.refresh(&config, &idle).unwrap_err(),
SessionError::new(SessionErrorKind::IdleTimeout),
);
}
#[test]
fn accessors_return_expected_values() {
let config = test_config();
let (_token, session) = Session::create(&config).unwrap();
assert_eq!(session.token_hash().len(), 32);
assert!(session.created_at().unix_epoch_secs() > 0);
assert!(session.expires_at().unix_epoch_secs() > session.created_at().unix_epoch_secs());
}
#[test]
fn error_display_no_secrets() {
let errors = [
SessionError::new(SessionErrorKind::RandomFailure),
SessionError::new(SessionErrorKind::RefreshNotAllowed),
SessionError::new(SessionErrorKind::MaxRefreshesExceeded),
];
for err in &errors {
let msg = err.to_string();
assert!(
msg.starts_with("session:"),
"error should be prefixed: {msg}"
);
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(SessionError::new(SessionErrorKind::RandomFailure));
let _ = err.to_string();
}
#[test]
fn default_config_values() {
let config = SessionConfig::default();
assert_eq!(config.token_bytes, 32);
assert_eq!(config.lifetime_secs, 3600);
assert_eq!(config.idle_timeout_secs, 1800);
assert!(config.allow_refresh);
assert_eq!(config.max_refreshes, 5);
}
#[test]
fn config_builder_pattern() {
let config = SessionConfig::new()
.with_token_bytes(64)
.with_lifetime_secs(7200)
.with_idle_timeout_secs(900)
.with_allow_refresh(false)
.with_max_refreshes(10);
assert_eq!(config.token_bytes, 64);
assert_eq!(config.lifetime_secs, 7200);
assert_eq!(config.idle_timeout_secs, 900);
assert!(!config.allow_refresh);
assert_eq!(config.max_refreshes, 10);
}
#[test]
fn with_token_bytes_clamps_to_minimum() {
assert_eq!(SessionConfig::new().with_token_bytes(0).token_bytes(), 16);
assert_eq!(SessionConfig::new().with_token_bytes(1).token_bytes(), 16);
assert_eq!(SessionConfig::new().with_token_bytes(15).token_bytes(), 16);
assert_eq!(SessionConfig::new().with_token_bytes(16).token_bytes(), 16);
assert_eq!(SessionConfig::new().with_token_bytes(48).token_bytes(), 48);
}
}