use std::fmt;
use std::time::Duration;
use secrecy::{ExposeSecret, SecretSlice};
use tower_sessions::cookie::Key;
use tower_sessions::{Expiry, SessionManagerLayer};
use crate::auth::{SessionBuildError, SessionConfigError, SigningKeyReason};
pub type SessionLayer<Store> = SessionManagerLayer<Store, tower_sessions::service::SignedCookie>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
pub(crate) fn as_tower(&self) -> tower_sessions::cookie::SameSite {
match self {
Self::Strict => tower_sessions::cookie::SameSite::Strict,
Self::Lax => tower_sessions::cookie::SameSite::Lax,
Self::None => tower_sessions::cookie::SameSite::None,
}
}
}
#[derive(Clone)]
pub struct SessionConfig {
cookie_name: String,
same_site: SameSite,
secure: bool,
http_only: bool,
path: String,
domain: Option<String>,
max_age: Duration,
absolute_max_age: Duration,
signing_key: SecretSlice<u8>,
}
impl SessionConfig {
pub fn new(signing_key: &[u8]) -> Result<Self, SessionConfigError> {
if signing_key.len() != 64 {
return Err(SessionConfigError::InvalidSigningKey {
reason: SigningKeyReason::WrongLength,
});
}
Ok(Self {
cookie_name: "__Host-id".to_string(),
same_site: SameSite::Strict,
secure: true,
http_only: true,
path: "/".to_string(),
domain: None,
max_age: Duration::from_secs(60 * 60 * 24 * 14),
absolute_max_age: Duration::from_secs(60 * 60 * 24 * 30),
signing_key: SecretSlice::from(signing_key.to_vec()),
})
}
pub fn dev(signing_key: &[u8]) -> Result<Self, SessionConfigError> {
if signing_key.len() != 64 {
return Err(SessionConfigError::InvalidSigningKey {
reason: SigningKeyReason::WrongLength,
});
}
Ok(Self {
cookie_name: "arcature-id".to_string(),
same_site: SameSite::Strict,
secure: false,
http_only: true,
path: "/".to_string(),
domain: None,
max_age: Duration::from_secs(60 * 60 * 24 * 14),
absolute_max_age: Duration::from_secs(60 * 60 * 24 * 30),
signing_key: SecretSlice::from(signing_key.to_vec()),
})
}
#[must_use]
pub fn with_cookie_name(mut self, name: impl Into<String>) -> Self {
self.cookie_name = name.into();
self
}
#[must_use]
pub fn with_same_site(mut self, same_site: SameSite) -> Self {
self.same_site = same_site;
self
}
#[must_use]
pub fn with_secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
#[must_use]
pub fn with_http_only(mut self, http_only: bool) -> Self {
self.http_only = http_only;
self
}
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = path.into();
self
}
#[must_use]
pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
self.domain = Some(domain.into());
self
}
#[must_use]
pub fn with_max_age(mut self, max_age: Duration) -> Self {
self.max_age = max_age;
self
}
#[must_use]
pub fn with_absolute_max_age(mut self, absolute_max_age: Duration) -> Self {
self.absolute_max_age = absolute_max_age;
self
}
#[must_use]
pub fn absolute_max_age(&self) -> Duration {
self.absolute_max_age
}
pub(crate) fn cookie_name(&self) -> &str {
&self.cookie_name
}
pub(crate) fn same_site(&self) -> SameSite {
self.same_site
}
pub(crate) fn secure(&self) -> bool {
self.secure
}
pub(crate) fn http_only(&self) -> bool {
self.http_only
}
pub(crate) fn path(&self) -> &str {
&self.path
}
pub(crate) fn domain(&self) -> Option<&str> {
self.domain.as_deref()
}
pub(crate) fn max_age(&self) -> Duration {
self.max_age
}
pub(crate) fn signing_key(&self) -> &[u8] {
self.signing_key.expose_secret()
}
pub(crate) fn validate(&self) -> Result<(), SessionConfigError> {
if self.cookie_name.is_empty() {
return Err(SessionConfigError::EmptyCookieAttribute { attribute: "name" });
}
if self.path.is_empty() {
return Err(SessionConfigError::EmptyCookieAttribute { attribute: "path" });
}
if self.max_age.is_zero() {
return Err(SessionConfigError::ZeroDuration { field: "max_age" });
}
if self.absolute_max_age.is_zero() {
return Err(SessionConfigError::ZeroDuration {
field: "absolute_max_age",
});
}
if self.signing_key().len() != 64 {
return Err(SessionConfigError::InvalidSigningKey {
reason: SigningKeyReason::WrongLength,
});
}
if !self.secure && self.cookie_name.starts_with("__Host-") {
return Err(SessionConfigError::InsecureHostPrefixedCookie {
cookie_name: self.cookie_name.clone(),
});
}
Ok(())
}
pub fn into_layer<Store>(self, store: Store) -> Result<SessionLayer<Store>, SessionBuildError>
where
Store: tower_sessions::SessionStore,
{
self.validate().map_err(SessionBuildError::new)?;
Ok(assemble_layer(self, store))
}
}
impl fmt::Debug for SessionConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SessionConfig")
.field("cookie_name", &self.cookie_name)
.field("same_site", &self.same_site)
.field("secure", &self.secure)
.field("http_only", &self.http_only)
.field("path", &self.path)
.field("domain", &self.domain)
.field("max_age_secs", &self.max_age.as_secs())
.field("absolute_max_secs", &self.absolute_max_age.as_secs())
.field("signing_key", &"<redacted 64-byte secret>")
.finish()
}
}
fn assemble_layer<Store: tower_sessions::SessionStore>(
config: SessionConfig,
store: Store,
) -> SessionLayer<Store> {
let key = Key::from(config.signing_key());
let max_age_secs: i64 = config.max_age().as_secs().try_into().unwrap_or(i64::MAX);
let expiry = Expiry::OnInactivity(time::Duration::seconds(max_age_secs));
let layer = SessionManagerLayer::new(store)
.with_name(config.cookie_name().to_string())
.with_same_site(config.same_site().as_tower())
.with_secure(config.secure())
.with_http_only(config.http_only())
.with_path(config.path().to_string())
.with_expiry(expiry)
.with_signed(key);
match config.domain() {
Some(domain) => layer.with_domain(domain.to_string()),
None => layer,
}
}
#[derive(Clone)]
pub struct SessionKey {
inner: SecretSlice<u8>,
}
impl SessionKey {
pub fn generate() -> Result<Self, SessionConfigError> {
let mut bytes = vec![0u8; 64];
getrandom::fill(&mut bytes).map_err(|_| SessionConfigError::InvalidSigningKey {
reason: SigningKeyReason::WrongLength,
})?;
Ok(Self {
inner: SecretSlice::from(bytes),
})
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SessionConfigError> {
if bytes.len() != 64 {
return Err(SessionConfigError::InvalidSigningKey {
reason: SigningKeyReason::WrongLength,
});
}
Ok(Self {
inner: SecretSlice::from(bytes.to_vec()),
})
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
self.inner.expose_secret()
}
}
impl fmt::Debug for SessionKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "SessionKey(<redacted 64-byte key>)")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tower_sessions_memory_store::MemoryStore;
fn fresh_config() -> SessionConfig {
SessionConfig::new(&[0u8; 64]).expect("valid key")
}
#[test]
fn defaults_are_secure() {
let config = fresh_config();
assert_eq!(config.cookie_name(), "__Host-id");
assert_eq!(config.same_site(), SameSite::Strict);
assert!(config.secure());
assert!(config.http_only());
assert_eq!(config.path(), "/");
assert!(config.domain().is_none());
assert_eq!(config.max_age(), Duration::from_secs(60 * 60 * 24 * 14));
assert_eq!(
config.absolute_max_age(),
Duration::from_secs(60 * 60 * 24 * 30)
);
assert!(config.absolute_max_age() > config.max_age());
}
#[test]
fn with_absolute_max_age_overrides_default() {
let config = fresh_config().with_absolute_max_age(Duration::from_secs(60));
assert_eq!(config.absolute_max_age(), Duration::from_secs(60));
}
#[test]
fn dev_defaults_are_for_plain_http() {
let key = SessionKey::generate().expect("rng");
let config = SessionConfig::dev(key.as_bytes()).expect("valid key");
assert_eq!(config.cookie_name(), "arcature-id");
assert!(!config.secure(), "dev Secure defaults to false");
assert!(config.http_only());
assert_eq!(config.path(), "/");
assert!(config.domain().is_none());
}
#[test]
fn debug_redacts_signing_key() {
let config = SessionConfig::new(&[0xAB; 64]).expect("valid key");
let debug = format!("{config:?}");
assert!(debug.contains("<redacted"));
assert!(
!debug.contains("abab"),
"hex key bytes must not leak: {debug}"
);
assert!(
!debug.contains("171"),
"decimal key bytes must not leak: {debug}"
);
}
#[test]
fn rejects_wrong_key_length() {
assert!(matches!(
SessionConfig::new(&[0u8; 32]),
Err(SessionConfigError::InvalidSigningKey { .. })
));
}
#[test]
fn rejects_empty_name() {
let config = fresh_config().with_cookie_name("");
assert!(config.into_layer(MemoryStore::default()).is_err());
}
#[test]
fn rejects_zero_max_age() {
let config = fresh_config().with_max_age(Duration::ZERO);
assert!(config.into_layer(MemoryStore::default()).is_err());
}
#[test]
fn rejects_zero_absolute_max_age() {
let config = fresh_config().with_absolute_max_age(Duration::ZERO);
assert!(config.into_layer(MemoryStore::default()).is_err());
}
#[test]
fn production_cookie_name_is_host_prefixed() {
let config = fresh_config();
assert_eq!(config.cookie_name(), "__Host-id");
assert!(config.cookie_name().starts_with("__Host-"));
assert!(config.secure());
}
#[test]
fn rejects_host_prefixed_cookie_with_secure_false() {
let config = fresh_config().with_secure(false);
let result = config.into_layer(MemoryStore::default());
assert!(result.is_err(), "__Host-id + Secure=false must be rejected");
}
#[test]
fn accepts_non_host_cookie_with_secure_false() {
let config = fresh_config().with_cookie_name("sid").with_secure(false);
assert!(config.into_layer(MemoryStore::default()).is_ok());
}
#[test]
fn key_generate_produces_64_bytes() {
let key = SessionKey::generate().expect("rng");
assert_eq!(key.as_bytes().len(), 64);
}
#[test]
fn key_from_bytes_rejects_wrong_length() {
assert!(matches!(
SessionKey::from_bytes(&[0u8; 32]),
Err(SessionConfigError::InvalidSigningKey { .. })
));
assert!(SessionKey::from_bytes(&[0u8; 64]).is_ok());
}
#[test]
fn key_debug_redacts() {
let key = SessionKey::from_bytes(&[0xf0; 64]).expect("64 bytes");
let debug = format!("{key:?}");
assert!(debug.contains("redacted"));
assert!(!debug.contains("f0"), "Debug leaked individual key byte");
}
#[test]
fn key_clone_preserves_bytes() {
let key = SessionKey::generate().expect("rng");
let clone = key.clone();
assert_eq!(key.as_bytes(), clone.as_bytes());
}
}