use axum::http::StatusCode;
use axum_extra::extract::PrivateCookieJar;
use axum_extra::extract::cookie::Cookie;
use cookie::{SameSite, time};
use kellnr_common::account_name::UserName;
use kellnr_common::plain_token::PlainToken;
use kellnr_common::util::generate_rand_string;
use kellnr_http::error::ApiError;
use kellnr_http::state::AppStateData;
use kellnr_settings::constants::COOKIE_SESSION_ID;
use time::Duration;
use tracing::error;
pub(crate) async fn create_session_jar(
cookies: PrivateCookieJar,
app_state: &AppStateData,
username: &UserName,
) -> Result<PrivateCookieJar, ApiError> {
let session_token = PlainToken::from(generate_rand_string(32));
app_state
.db
.add_session_token(username, &session_token)
.await
.map_err(|e| {
error!("Failed to create session: {e}");
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?;
let secure = app_state.settings.origin.secure_cookies();
Ok(cookies.add(session_cookie(
COOKIE_SESSION_ID,
session_token.into_secret(),
app_state.settings.registry.session_age().cookie_max_age(),
secure,
)))
}
pub(crate) fn session_cookie(
name: &'static str,
value: String,
max_age: std::time::Duration,
secure: bool,
) -> Cookie<'static> {
let max_age = Duration::try_from(max_age).unwrap_or(Duration::MAX);
Cookie::build((name, value))
.max_age(max_age)
.same_site(SameSite::Strict)
.http_only(true)
.secure(secure)
.path("/")
.into()
}
#[cfg(test)]
mod session_cookie_tests {
use std::sync::Arc;
use axum::http::HeaderMap;
use axum_extra::extract::cookie::Key;
use kellnr_common::plain_token::PlainToken;
use kellnr_db::DbProvider;
use kellnr_http::test_support::sqlite_test_state;
use kellnr_settings::constants::MAX_SESSION_AGE;
use kellnr_settings::{Protocol, Settings};
use super::*;
async fn new_session_cookie(settings: Settings) -> Cookie<'static> {
let (state, db) = sqlite_test_state().await;
let state = AppStateData {
settings: Arc::new(settings),
..state
};
let jar = PrivateCookieJar::from_headers(&HeaderMap::new(), Key::generate());
let jar = create_session_jar(jar, &state, &UserName::new("admin").unwrap())
.await
.unwrap();
let cookie = jar.get(COOKIE_SESSION_ID).unwrap();
let session = db
.validate_session(&PlainToken::from(cookie.value()))
.await
.unwrap();
assert_eq!("admin", session.name.to_string());
cookie
}
async fn session_cookie_secure(protocol: Protocol, cookie_secure: Option<bool>) -> bool {
let mut settings = Settings::clone(&kellnr_http::test_support::test_state().settings);
settings.origin.protocol = protocol;
settings.origin.cookie_secure = cookie_secure;
new_session_cookie(settings).await.secure().unwrap_or(false)
}
async fn session_cookie_max_age(session_age_seconds: u64) -> Option<Duration> {
let mut settings = Settings::clone(&kellnr_http::test_support::test_state().settings);
settings.registry.session_age_seconds = session_age_seconds;
new_session_cookie(settings).await.max_age()
}
#[tokio::test]
async fn cookie_lives_as_long_as_the_session() {
assert_eq!(
session_cookie_max_age(3600).await,
Some(Duration::seconds(3600))
);
}
#[tokio::test]
async fn cookie_of_a_session_that_never_expires_is_kept() {
let max_age = Duration::try_from(MAX_SESSION_AGE).unwrap();
assert_eq!(session_cookie_max_age(0).await, Some(max_age));
assert_eq!(session_cookie_max_age(u64::MAX).await, Some(max_age));
}
#[tokio::test]
async fn secure_follows_protocol_by_default() {
assert!(session_cookie_secure(Protocol::Https, None).await);
assert!(!session_cookie_secure(Protocol::Http, None).await);
}
#[tokio::test]
async fn cookie_secure_setting_overrides_protocol() {
assert!(session_cookie_secure(Protocol::Http, Some(true)).await);
assert!(!session_cookie_secure(Protocol::Https, Some(false)).await);
}
}