use axum_session::{Key, SameSite as AxumSameSite, SessionConfig, SessionMode as AxumSessionMode}; use base64::Engine as _; use chrono::TimeDelta; use config::session::{SameSite, SessionConfig as AppSessionConfig, SessionMode}; /// `axum_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 { 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, 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 { 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()) }