use std::sync::Arc;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::shared::credential_store::AsyncCredentialStore;
const COOKIE_NAME: &str = "session";
const DEFAULT_SESSION_DURATION_SECS: i64 = 86_400;
#[derive(Serialize, Deserialize)]
pub struct SessionPayload {
pub id: String,
pub uid: String,
pub exp: i64,
}
pub struct WasmSessionManager<S: AsyncCredentialStore> {
store: Arc<S>,
signing_key: [u8; 32],
}
impl<S: AsyncCredentialStore> WasmSessionManager<S> {
pub const COOKIE_NAME: &'static str = COOKIE_NAME;
pub const DEFAULT_SESSION_DURATION_SECS: i64 = DEFAULT_SESSION_DURATION_SECS;
pub fn new(store: Arc<S>, signing_key: [u8; 32]) -> Self {
Self { store, signing_key }
}
pub async fn create_session(&self, user_id: &str) -> Result<String, String> {
let id = uuid::Uuid::new_v4().to_string();
let exp = chrono::Utc::now().timestamp() + DEFAULT_SESSION_DURATION_SECS;
let payload = SessionPayload { id: id.clone(), uid: user_id.to_string(), exp };
let json = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
let token = Self::create_token(&self.signing_key, json.as_bytes());
self.store
.set_async(&format!("session:{id}"), payload)
.await
.map_err(|e| format!("D1 insert failed: {e:?}"))?;
Ok(token)
}
pub async fn validate_session(&self, token: &str) -> Result<String, String> {
let payload = Self::verify_token(&self.signing_key, token)?;
let stored: Option<SessionPayload> = self
.store
.get_async(&format!("session:{}", payload.id))
.await
.map_err(|e| format!("D1 query failed: {e:?}"))?;
if stored.is_none() {
return Err("session not found".to_string());
}
Ok(payload.uid)
}
pub async fn revoke_session(&self, token: &str) -> Result<(), String> {
let payload = match Self::verify_token(&self.signing_key, token) {
Ok(p) => p,
Err(_) => return Ok(()),
};
self.store
.delete_async(&format!("session:{}", payload.id))
.await
.map_err(|e| format!("D1 delete failed: {e:?}"))?;
Ok(())
}
pub fn session_cookie(token: &str) -> String {
format!(
"{}={}; Path=/; HttpOnly; Max-Age={}; SameSite=Lax",
COOKIE_NAME,
token,
DEFAULT_SESSION_DURATION_SECS
)
}
pub fn clear_cookie() -> String {
format!("{}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax", COOKIE_NAME)
}
fn create_token(key: &[u8; 32], payload_json: &[u8]) -> String {
let encoded = base64_encode(payload_json);
let sig = Self::sign_payload(key, payload_json);
format!("{encoded}.{sig}")
}
fn sign_payload(key: &[u8; 32], payload_json: &[u8]) -> String {
let sig = hmac_sha256(key, payload_json);
base64_encode(&sig)
}
fn verify_token(key: &[u8; 32], token: &str) -> Result<SessionPayload, String> {
let dot = token.rfind('.').ok_or_else(|| "invalid token format".to_string())?;
let encoded = &token[..dot];
let sig = &token[dot + 1..];
let payload_bytes = base64_decode(encoded)?;
let expected_sig = Self::sign_payload(key, &payload_bytes);
if sig != expected_sig {
return Err("invalid signature".to_string());
}
let payload: SessionPayload =
serde_json::from_slice(&payload_bytes).map_err(|e| e.to_string())?;
let now = chrono::Utc::now().timestamp();
if payload.exp < now {
return Err("session expired".to_string());
}
Ok(payload)
}
}
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
const BLOCK_SIZE: usize = 64;
let mut key_block = [0u8; BLOCK_SIZE];
if key.len() > BLOCK_SIZE {
let hashed = sha256(key);
key_block[..32].copy_from_slice(&hashed);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK_SIZE];
let mut opad = [0x5cu8; BLOCK_SIZE];
for i in 0..BLOCK_SIZE {
ipad[i] ^= key_block[i];
opad[i] ^= key_block[i];
}
let inner = {
let mut h = Sha256::new();
h.update(&ipad);
h.update(message);
h.finalize()
};
let mut h = Sha256::new();
h.update(&opad);
h.update(&inner);
h.finalize().into()
}
fn sha256(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}
fn base64_encode(data: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(data)
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
URL_SAFE_NO_PAD.decode(s).map_err(|e| e.to_string())
}