use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::UserSessionId; use rand::RngCore; use sha2::{Digest, Sha256}; use time::{Duration, OffsetDateTime}; pub const SESSION_COOKIE_NAME: &str = "crank_session"; #[derive(Clone)] pub struct SessionCookie { pub session_id: UserSessionId, pub value: String, pub expires_at: OffsetDateTime, } #[derive(Debug, thiserror::Error)] pub enum SessionCookieError { #[error("failed to compute session expiration")] InvalidExpiration, } pub fn create_session_cookie(session_ttl_hours: i64) -> Result { let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); let mut secret_bytes = [0_u8; 32]; rand::thread_rng().fill_bytes(&mut secret_bytes); let secret = URL_SAFE_NO_PAD.encode(secret_bytes); let expires_at = OffsetDateTime::now_utc() .checked_add(Duration::hours(session_ttl_hours)) .ok_or(SessionCookieError::InvalidExpiration)?; Ok(SessionCookie { session_id, value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()), expires_at, }) } pub fn hash_session_secret( session_id: &UserSessionId, session_value: &str, session_secret: &str, ) -> String { let mut digest = Sha256::new(); digest.update(session_id.as_str().as_bytes()); digest.update(b":"); digest.update(session_value.as_bytes()); digest.update(b":"); digest.update(session_secret.as_bytes()); URL_SAFE_NO_PAD.encode(digest.finalize()) } pub fn session_cookie(token: &str, cookie_secure: bool, session_ttl_hours: i64) -> Cookie<'static> { Cookie::build((SESSION_COOKIE_NAME, token.to_owned())) .http_only(true) .same_site(SameSite::Lax) .secure(cookie_secure) .path("/") .max_age(Duration::hours(session_ttl_hours)) .build() } pub fn cleared_session_cookie(cookie_secure: bool) -> Cookie<'static> { Cookie::build((SESSION_COOKIE_NAME, String::new())) .http_only(true) .same_site(SameSite::Lax) .secure(cookie_secure) .path("/") .max_age(Duration::seconds(0)) .build() } pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> { let cookie = jar.get(SESSION_COOKIE_NAME)?; let value = cookie.value(); let (session_id, session_value) = value.split_once('.')?; Some((UserSessionId::new(session_id), session_value.to_owned())) } #[cfg(test)] mod tests { use axum_extra::extract::cookie::CookieJar; use super::{ SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token, session_cookie, }; #[test] fn extracts_session_token_from_cookie_jar() { let token = "sess_01.secret-value"; let jar = CookieJar::new().add(session_cookie(token, false, 24)); let (session_id, secret) = extract_session_token(&jar).unwrap(); assert_eq!(session_id.as_str(), "sess_01"); assert_eq!(secret, "secret-value"); } #[test] fn clears_session_cookie() { let cookie = cleared_session_cookie(false); assert_eq!(cookie.name(), SESSION_COOKIE_NAME); assert_eq!(cookie.value(), ""); } #[test] fn creates_session_cookie_with_generated_values() { let session = create_session_cookie(24).unwrap(); assert!(session.session_id.as_str().starts_with("sess_")); assert!(session.value.contains('.')); } }