eba4fd1
use base64::Engine as _;
use chrono::TimeDelta;
use config::session::{SameSite, SessionConfig as AppSessionConfig, SessionMode};
use session::{Key, SameSite as AxumSameSite, SessionConfig, SessionMode as AxumSessionMode};
/// `session` keys contain a 32-byte signing key plus a 32-byte
/// encryption key; anything shorter is rejected by the `cookie` crate.
const MIN_KEY_LEN: usize = 64;
pub fn build_session_config(config: &AppSessionConfig) -> Result<SessionConfig, String> {
validate(config)?;
let mut session = SessionConfig::default();
if let Some(mode) = config.mode {
session = session.with_mode(match mode {
SessionMode::Manual => AxumSessionMode::Manual,
SessionMode::OptIn => AxumSessionMode::OptIn,
SessionMode::Persistent => AxumSessionMode::Persistent,
});
}
if let Some(name) = &config.cookie_name {
session = session.with_session_name(name.clone());
}
if let Some(domain) = &config.cookie_domain
&& !domain.is_empty()
{
session = session.with_cookie_domain(domain.clone());
}
if let Some(path) = &config.cookie_path {
session = session.with_cookie_path(path.clone());
}
if let Some(value) = config.cookie_http_only {
session = session.with_http_only(value);
}
if let Some(value) = config.cookie_secure {
session = session.with_secure(value);
}
if let Some(same_site) = config.cookie_same_site {
session = session.with_cookie_same_site(match same_site {
SameSite::Strict => AxumSameSite::Strict,
SameSite::Lax => AxumSameSite::Lax,
SameSite::None => AxumSameSite::None,
});
}
if let Some(secs) = config.cookie_max_age_secs {
// A zero max-age makes browsers delete the cookie on receipt, which
// breaks every session; validation rejects it before we get here.
session = session.with_max_age(Some(TimeDelta::seconds(secs)));
}
if let Some(value) = config.cookie_prefix_host {
session = session.with_prefix_with_host(value);
}
if let Some(value) = config.cookie_encode_ip_and_user_agent {
session = session.with_ip_and_user_agent(value);
}
if let Some(secs) = config.lifespan_secs {
session = session.with_lifetime(TimeDelta::seconds(secs));
}
if let Some(secs) = config.max_lifespan_secs {
session = session.with_max_lifetime(TimeDelta::seconds(secs));
}
if let Some(secs) = config.memory_lifespan_secs {
session = session.with_memory_lifetime(TimeDelta::seconds(secs));
}
if let Some(secs) = config.db_update_interval_secs {
session = session.with_db_update_interval(TimeDelta::seconds(secs));
}
if let Some(secs) = config.purge_database_update_secs {
session = session.with_purge_database_update(TimeDelta::seconds(secs));
}
if let Some(value) = config.always_save {
session = session.with_always_save(value);
}
if let Some(value) = config.enable_bloom_filter {
session = session.with_bloom_filter(value);
}
if let Some(name) = &config.table_name {
session = session.with_table_name(name.clone());
}
if let Some(key) = &config.key {
session = session.with_key(decode_key(key)?);
}
if let Some(key) = &config.database_key {
session = session.with_database_key(decode_key(key)?);
}
Ok(session)
}
fn validate(config: &AppSessionConfig) -> Result<(), String> {
if let Some(name) = &config.table_name {
if name.is_empty() {
return Err(
"table_name must not be empty: it prefixes session keys in Redis and an empty \
prefix would make scans/deletes target unrelated keys"
.into(),
);
}
for c in ['*', '?', '[', ']', '\\'] {
if name.contains(c) {
return Err(format!(
"table_name must not contain glob character '{c}': {name:?}"
));
}
}
}
let secure = config.cookie_secure.unwrap_or(false);
if config.cookie_same_site == Some(SameSite::None) && !secure {
return Err(
"cookie_same_site = none requires cookie_secure = true: browsers reject non-Secure \
cookies with SameSite=None"
.into(),
);
}
if config.cookie_prefix_host == Some(true) && !secure {
return Err(
"cookie_prefix_host = true requires cookie_secure = true: browsers reject non-Secure \
cookies with the __Host- prefix"
.into(),
);
}
if config.cookie_prefix_host == Some(true)
&& config
.cookie_domain
.as_deref()
.is_some_and(|domain| !domain.is_empty())
{
return Err(
"cookie_prefix_host = true forbids a cookie_domain: __Host- cookies must not carry \
a Domain attribute"
.into(),
);
}
if !secure {
tracing::warn!(
"session cookies are not marked Secure (cookie_secure is not true); enable it when \
serving over TLS in production"
);
}
check_duration("lifespan_secs", config.lifespan_secs, false)?;
check_duration("max_lifespan_secs", config.max_lifespan_secs, false)?;
check_duration("memory_lifespan_secs", config.memory_lifespan_secs, true)?;
check_duration("cookie_max_age_secs", config.cookie_max_age_secs, false)?;
check_duration(
"db_update_interval_secs",
config.db_update_interval_secs,
true,
)?;
check_duration(
"purge_database_update_secs",
config.purge_database_update_secs,
true,
)?;
if let (Some(lifespan), Some(max_lifespan)) = (config.lifespan_secs, config.max_lifespan_secs)
&& max_lifespan < lifespan
{
return Err(format!(
"max_lifespan_secs ({max_lifespan}) must be greater than or equal to \
lifespan_secs ({lifespan})"
));
}
if let Some(key) = &config.key {
check_key("key", key)?;
}
if let Some(key) = &config.database_key {
check_key("database_key", key)?;
}
Ok(())
}
fn check_duration(name: &str, secs: Option<i64>, allow_zero: bool) -> Result<(), String> {
if let Some(secs) = secs
&& (secs < 0 || (secs == 0 && !allow_zero))
{
return Err(format!("{name} must be positive, got {secs}"));
}
Ok(())
}
fn check_key(name: &str, value: &str) -> Result<(), String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|err| format!("invalid base64 session key for {name}: {err}"))?;
if bytes.len() < MIN_KEY_LEN {
return Err(format!(
"{name} is too short: expected at least {MIN_KEY_LEN} randomly generated bytes \
(base64-encoded), got {} bytes",
bytes.len()
));
}
if bytes.len() > MIN_KEY_LEN {
tracing::warn!(
"{name} is longer than {MIN_KEY_LEN} bytes; only the first {MIN_KEY_LEN} bytes \
will be used"
);
}
Ok(())
}
fn decode_key(value: &str) -> Result<Key, String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|err| format!("invalid base64 session key: {err}"))?;
Key::try_from(bytes.as_slice()).map_err(|err| err.to_string())
}