mesofact_core/proxy/
session.rs1use cheers_core::{Claims, Codec, CodecError};
29use cheers_server::PasetoV4Codec;
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35pub const DEFAULT_COOKIE_NAME: &str = "mesofact_session";
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct User {
42 pub id: String,
43 #[serde(default)]
44 pub attrs: serde_json::Map<String, serde_json::Value>,
45}
46
47impl User {
48 fn from_claims(c: Claims) -> Self {
53 let mut attrs = serde_json::Map::new();
54 attrs.insert("device".into(), serde_json::Value::String(c.device.into_inner()));
55 attrs.insert(
56 "binding".into(),
57 serde_json::to_value(&c.binding).unwrap_or(serde_json::Value::Null),
58 );
59 attrs.insert("issued_at".into(), serde_json::json!(c.issued_at));
60 attrs.insert("expires_at".into(), serde_json::json!(c.expires_at));
61 Self { id: c.sub.into_inner(), attrs }
62 }
63}
64
65pub trait SessionResolver: Send + Sync {
68 fn resolve(&self, cookie_header: Option<&str>) -> Option<User>;
71}
72
73pub struct CookieSessionResolver {
74 cookie_name: String,
75 codec: Box<dyn Codec + Send + Sync>,
76}
77
78impl CookieSessionResolver {
79 pub fn new(cookie_name: impl Into<String>, secret: impl AsRef<[u8]>) -> Self {
84 let codec = PasetoV4Codec::new(&derive_key(secret.as_ref()))
85 .expect("a 32-byte key is always valid");
86 Self::with_codec(cookie_name, Box::new(codec))
87 }
88
89 pub fn with_codec(
94 cookie_name: impl Into<String>,
95 codec: Box<dyn Codec + Send + Sync>,
96 ) -> Self {
97 Self { cookie_name: cookie_name.into(), codec }
98 }
99
100 pub fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
103 self.codec.mint(claims)
104 }
105}
106
107impl SessionResolver for CookieSessionResolver {
108 fn resolve(&self, cookie_header: Option<&str>) -> Option<User> {
109 let token = cookie_value(cookie_header?, &self.cookie_name)?;
110 let claims = self.codec.verify(token).ok()?;
113 Some(User::from_claims(claims))
114 }
115}
116
117fn derive_key(secret: &[u8]) -> [u8; 32] {
119 let mut h = Sha256::new();
120 h.update(secret);
121 h.finalize().into()
122}
123
124fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
127 header.split(';').find_map(|pair| {
128 let (k, v) = pair.split_once('=')?;
129 (k.trim() == name).then(|| v.trim())
130 })
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use cheers_core::{DeviceBinding, DeviceId, UserId};
137 use std::time::{SystemTime, UNIX_EPOCH};
138
139 fn resolver() -> CookieSessionResolver {
140 CookieSessionResolver::new(DEFAULT_COOKIE_NAME, b"super-secret-key")
141 }
142
143 fn now() -> i64 {
144 SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
145 }
146
147 fn claims(user_id: &str, expires_at: i64) -> Claims {
148 Claims::new(
149 UserId::new(user_id),
150 DeviceId::new("d1"),
151 DeviceBinding::Passkey,
152 now(),
153 expires_at,
154 )
155 }
156
157 #[test]
158 fn round_trips_a_signed_session() {
159 let r = resolver();
160 let token = r.mint(&claims("u42", now() + 3600)).unwrap();
161 let user = r.resolve(Some(&format!("mesofact_session={token}"))).unwrap();
162 assert_eq!(user.id, "u42");
163 assert_eq!(user.attrs.get("device").unwrap(), &serde_json::json!("d1"));
165 assert_eq!(
166 user.attrs.get("binding").unwrap(),
167 &serde_json::json!({ "kind": "passkey" })
168 );
169 }
170
171 #[test]
172 fn picks_the_named_cookie_out_of_many() {
173 let r = resolver();
174 let token = r.mint(&claims("u1", now() + 3600)).unwrap();
175 let header = format!("theme=dark; mesofact_session={token}; tz=utc");
176 assert_eq!(r.resolve(Some(&header)).unwrap().id, "u1");
177 }
178
179 #[test]
180 fn missing_cookie_resolves_to_none() {
181 assert!(resolver().resolve(None).is_none());
182 assert!(resolver().resolve(Some("theme=dark")).is_none());
183 }
184
185 #[test]
186 fn expired_token_resolves_to_none() {
187 let r = resolver();
188 let token = r.mint(&claims("u1", now() - 1)).unwrap();
189 assert!(r.resolve(Some(&format!("mesofact_session={token}"))).is_none());
190 }
191
192 #[test]
193 fn tampered_token_fails_verification() {
194 let r = resolver();
195 let token = r.mint(&claims("u1", now() + 3600)).unwrap();
196 let mut bytes = token.into_bytes();
198 let last = bytes.len() - 1;
199 bytes[last] ^= 0x01;
200 let forged = String::from_utf8(bytes).unwrap();
201 assert!(r.resolve(Some(&format!("mesofact_session={forged}"))).is_none());
202 }
203
204 #[test]
205 fn wrong_key_fails_verification() {
206 let signer = resolver();
207 let token = signer.mint(&claims("u1", now() + 3600)).unwrap();
208 let other = CookieSessionResolver::new(DEFAULT_COOKIE_NAME, b"different-key");
209 assert!(other.resolve(Some(&format!("mesofact_session={token}"))).is_none());
210 }
211}