Something went wrong. Try again.
Something went wrong. Try again.
7.2 kB · 205 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206// SPDX-License-Identifier: AGPL-3.0-or-later
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};use hmac::{Hmac, KeyInit, Mac};use serde::{Deserialize, Serialize};use sha2::Sha256;use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
const MAX_AGE_SECONDS: u64 = 60 * 60 * 24 * 7;
#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Session { #[serde(rename = "userId")] pub user_id: String, #[serde(rename = "accessToken")] pub access_token: String, #[serde(rename = "createdAt")] pub created_at: u64,}
pub fn create_session(user_id: &str, access_token: &str, secret_key: &str) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock before Unix epoch") .as_secs(); let session = Session { user_id: user_id.to_owned(), access_token: access_token.to_owned(), created_at: now, }; let json = serde_json::to_string(&session).expect("session serialization cannot fail"); let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes()); sign_data(&encoded, secret_key)}
pub fn parse_session(cookie_value: &str, secret_key: &str) -> Option<Session> { let data = verify_signature(cookie_value, secret_key)?; let decoded_bytes = URL_SAFE_NO_PAD.decode(data.as_bytes()).ok()?; let decoded_str = String::from_utf8(decoded_bytes).ok()?; let session: Session = serde_json::from_str(&decoded_str).ok()?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock before Unix epoch") .as_secs(); if now.saturating_sub(session.created_at) > MAX_AGE_SECONDS { return None; } Some(session)}
fn sign_data(data: &str, secret_key: &str) -> String { let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size"); mac.update(data.as_bytes()); let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); format!("{data}.{signature}")}
fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a str> { let dot_index = signed_data.rfind('.')?; let data = &signed_data[..dot_index]; let provided_signature = &signed_data[dot_index + 1..]; let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size"); mac.update(data.as_bytes()); let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); let provided_bytes = URL_SAFE_NO_PAD.decode(provided_signature).ok()?; let expected_bytes = URL_SAFE_NO_PAD.decode(expected_signature.as_bytes()).ok()?; if provided_bytes.len() != expected_bytes.len() { return None; } let mut diff = 0u8; for (a, b) in provided_bytes.iter().zip(expected_bytes.iter()) { diff |= a ^ b; } if diff == 0 { Some(data) } else { None }}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";pub const SESSION_COOKIE_NAME: &str = "admin_session";pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
#[cfg(test)]mod tests { use super::*;
const TEST_SECRET: &str = "test-secret-key-for-unit-tests";
#[test] fn create_session_produces_valid_base64url_and_hmac() { let token = create_session("12345", "tok_abc", TEST_SECRET); let parts: Vec<&str> = token.rsplitn(2, '.').collect(); assert_eq!(parts.len(), 2, "session must contain a dot separator"); let payload = parts[1]; let sig = parts[0]; assert!( URL_SAFE_NO_PAD.decode(payload.as_bytes()).is_ok(), "payload must be valid base64url" ); assert!( URL_SAFE_NO_PAD.decode(sig.as_bytes()).is_ok(), "signature must be valid base64url" ); }
#[test] fn parse_session_decodes_created_session() { let token = create_session("99999", "tok_xyz", TEST_SECRET); let session = parse_session(&token, TEST_SECRET).expect("must parse"); assert_eq!(session.user_id, "99999"); assert_eq!(session.access_token, "tok_xyz"); assert!(session.created_at > 0); }
#[test] fn parse_session_rejects_tampered_signature() { let token = create_session("12345", "tok_abc", TEST_SECRET); let dot = token.rfind('.').unwrap(); let mut tampered = token.clone(); let replacement = if tampered.as_bytes()[dot + 1] == b'A' { 'B' } else { 'A' }; unsafe { tampered.as_bytes_mut()[dot + 1] = replacement as u8; } assert!( parse_session(&tampered, TEST_SECRET).is_none(), "tampered signature must be rejected" ); }
#[test] fn parse_session_rejects_wrong_secret() { let token = create_session("12345", "tok_abc", TEST_SECRET); assert!( parse_session(&token, "wrong-secret").is_none(), "wrong secret must be rejected" ); }
#[test] fn parse_session_rejects_expired() { let session = Session { user_id: "12345".to_owned(), access_token: "tok_old".to_owned(), created_at: 1_000_000, }; let json = serde_json::to_string(&session).unwrap(); let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes()); let signed = sign_data(&encoded, TEST_SECRET); assert!( parse_session(&signed, TEST_SECRET).is_none(), "expired session must be rejected" ); }
#[test] fn parse_session_returns_none_for_garbage() { assert!(parse_session("", TEST_SECRET).is_none()); assert!(parse_session("not-a-session", TEST_SECRET).is_none()); assert!(parse_session("abc.def.ghi", TEST_SECRET).is_none()); assert!(parse_session(".....", TEST_SECRET).is_none()); assert!(parse_session("\0\0\0", TEST_SECRET).is_none()); }
#[test] fn session_json_uses_camel_case() { let session = Session { user_id: "1".to_owned(), access_token: "t".to_owned(), created_at: 100, }; let json = serde_json::to_string(&session).unwrap(); assert!(json.contains("\"userId\""), "must use camelCase userId"); assert!( json.contains("\"accessToken\""), "must use camelCase accessToken" ); assert!( json.contains("\"createdAt\""), "must use camelCase createdAt" ); assert!(!json.contains("user_id"), "must not use snake_case"); }
#[test] fn round_trip_preserves_fields() { let token = create_session("67890", "tok_roundtrip", TEST_SECRET); let session = parse_session(&token, TEST_SECRET).expect("must parse"); assert_eq!(session.user_id, "67890"); assert_eq!(session.access_token, "tok_roundtrip"); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); assert!( now.saturating_sub(session.created_at) < 5, "created_at should be recent" ); }}