Files
crank/crates/crank-community-auth/src/session_cookie.rs
T
github-ops 3814576d7d
Deploy / deploy (push) Failing after 8s
CI / Rust Checks (push) Successful in 5m3s
CI / UI Checks (push) Failing after 5s
CI / Frontend E2E (push) Has been skipped
CI / Deployment Manifests (push) Has been skipped
chore: publish clean community baseline
2026-06-18 08:48:33 +00:00

117 lines
3.5 KiB
Rust

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<SessionCookie, SessionCookieError> {
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('.'));
}
}