use cheers_core::{Claims, Codec, CodecError};
use cheers_server::PasetoV4Codec;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const DEFAULT_COOKIE_NAME: &str = "mesofact_session";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct User {
pub id: String,
#[serde(default)]
pub attrs: serde_json::Map<String, serde_json::Value>,
}
impl User {
fn from_claims(c: Claims) -> Self {
let mut attrs = serde_json::Map::new();
attrs.insert("device".into(), serde_json::Value::String(c.device.into_inner()));
attrs.insert(
"binding".into(),
serde_json::to_value(&c.binding).unwrap_or(serde_json::Value::Null),
);
attrs.insert("issued_at".into(), serde_json::json!(c.issued_at));
attrs.insert("expires_at".into(), serde_json::json!(c.expires_at));
Self { id: c.sub.into_inner(), attrs }
}
}
pub trait SessionResolver: Send + Sync {
fn resolve(&self, cookie_header: Option<&str>) -> Option<User>;
}
pub struct CookieSessionResolver {
cookie_name: String,
codec: Box<dyn Codec + Send + Sync>,
}
impl CookieSessionResolver {
pub fn new(cookie_name: impl Into<String>, secret: impl AsRef<[u8]>) -> Self {
let codec = PasetoV4Codec::new(&derive_key(secret.as_ref()))
.expect("a 32-byte key is always valid");
Self::with_codec(cookie_name, Box::new(codec))
}
pub fn with_codec(
cookie_name: impl Into<String>,
codec: Box<dyn Codec + Send + Sync>,
) -> Self {
Self { cookie_name: cookie_name.into(), codec }
}
pub fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
self.codec.mint(claims)
}
}
impl SessionResolver for CookieSessionResolver {
fn resolve(&self, cookie_header: Option<&str>) -> Option<User> {
let token = cookie_value(cookie_header?, &self.cookie_name)?;
let claims = self.codec.verify(token).ok()?;
Some(User::from_claims(claims))
}
}
fn derive_key(secret: &[u8]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(secret);
h.finalize().into()
}
fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
header.split(';').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k.trim() == name).then(|| v.trim())
})
}
#[cfg(test)]
mod tests {
use super::*;
use cheers_core::{DeviceBinding, DeviceId, UserId};
use std::time::{SystemTime, UNIX_EPOCH};
fn resolver() -> CookieSessionResolver {
CookieSessionResolver::new(DEFAULT_COOKIE_NAME, b"super-secret-key")
}
fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
}
fn claims(user_id: &str, expires_at: i64) -> Claims {
Claims::new(
UserId::new(user_id),
DeviceId::new("d1"),
DeviceBinding::Passkey,
now(),
expires_at,
)
}
#[test]
fn round_trips_a_signed_session() {
let r = resolver();
let token = r.mint(&claims("u42", now() + 3600)).unwrap();
let user = r.resolve(Some(&format!("mesofact_session={token}"))).unwrap();
assert_eq!(user.id, "u42");
assert_eq!(user.attrs.get("device").unwrap(), &serde_json::json!("d1"));
assert_eq!(
user.attrs.get("binding").unwrap(),
&serde_json::json!({ "kind": "passkey" })
);
}
#[test]
fn picks_the_named_cookie_out_of_many() {
let r = resolver();
let token = r.mint(&claims("u1", now() + 3600)).unwrap();
let header = format!("theme=dark; mesofact_session={token}; tz=utc");
assert_eq!(r.resolve(Some(&header)).unwrap().id, "u1");
}
#[test]
fn missing_cookie_resolves_to_none() {
assert!(resolver().resolve(None).is_none());
assert!(resolver().resolve(Some("theme=dark")).is_none());
}
#[test]
fn expired_token_resolves_to_none() {
let r = resolver();
let token = r.mint(&claims("u1", now() - 1)).unwrap();
assert!(r.resolve(Some(&format!("mesofact_session={token}"))).is_none());
}
#[test]
fn tampered_token_fails_verification() {
let r = resolver();
let token = r.mint(&claims("u1", now() + 3600)).unwrap();
let mut bytes = token.into_bytes();
let last = bytes.len() - 1;
bytes[last] ^= 0x01;
let forged = String::from_utf8(bytes).unwrap();
assert!(r.resolve(Some(&format!("mesofact_session={forged}"))).is_none());
}
#[test]
fn wrong_key_fails_verification() {
let signer = resolver();
let token = signer.mint(&claims("u1", now() + 3600)).unwrap();
let other = CookieSessionResolver::new(DEFAULT_COOKIE_NAME, b"different-key");
assert!(other.resolve(Some(&format!("mesofact_session={token}"))).is_none());
}
}