Skip to main content

mesofact_core/proxy/
session.rs

1//! Auth & session contract — the Rust proxy resolves `req.user` before render.
2//!
3//! mesofact decodes the session cookie with **cheers-core**'s [`Codec`] (R009 /
4//! P11). The MVP placeholder HMAC implementation that used to live here was
5//! replaced by a path dependency on `cheers-core`, the shared auth contract used
6//! by every yah product. `CookieSessionResolver` reads a configurable cookie
7//! (default `mesofact_session`), hands the raw token to a `cheers_core::Codec`
8//! for verification, and maps the verified [`Claims`] onto mesofact's
9//! render-facing [`User`].
10//!
11//! **Default codec:** [`PasetoV4Codec`] (PASETO v4.local — encrypted *and*
12//! authenticated), the cheers-recommended default. mesofact is the SSR *origin*
13//! (it holds the symmetric key and verifies server-side; the render worker never
14//! sees the token, only the decoded `req.user`), so encrypted-claims /
15//! origin-only verification fits. The resolver is codec-agnostic
16//! (`Box<dyn Codec>`), so the edge-verifiable asymmetric verifier (cheers
17//! R019-F2) drops in later via [`CookieSessionResolver::with_codec`] without
18//! touching this file's callers.
19//!
20//! **`req.user.attrs` is preserved** (R009 decision): cheers `Claims` carries no
21//! opaque attribute bag, so the device id, binding, and token lifetimes are
22//! folded into `attrs` to keep mesofact's `{ id, attrs }` render contract
23//! intact. When cheers grows a first-class extensions field (coordinate with
24//! R019), surface it here instead.
25//!
26//! See `.yah/docs/architecture/mesofact.md` §"Auth & session contract".
27
28use cheers_core::{Claims, Codec, CodecError};
29// Concrete symmetric codec moved out of cheers-core into cheers-server by the
30// F6 crate split (cheers-core is now the keyless trait/identity surface).
31use cheers_server::PasetoV4Codec;
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35pub const DEFAULT_COOKIE_NAME: &str = "mesofact_session";
36
37/// Resolved identity handed to render on `req.user`. `attrs` is opaque to
38/// mesofact — populated from the verified cheers [`Claims`] (see
39/// [`User::from_claims`]); it rides through to the worker on `req.user`.
40#[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    /// Map verified cheers [`Claims`] onto the render-facing identity. cheers
49    /// has no opaque attribute bag, so the device binding + token lifetimes ride
50    /// through under `attrs` to preserve mesofact's `{ id, attrs }` render
51    /// contract (R009).
52    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
65/// Pluggable session resolution. Sync because cookie verification needs no I/O;
66/// a network-backed resolver (OAuth introspection) would add its own runtime.
67pub trait SessionResolver: Send + Sync {
68    /// Resolve identity from a raw `Cookie` header value (or `None` if absent).
69    /// Returns `None` for any unauthenticated outcome (missing / bad / expired).
70    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    /// Build a resolver from a raw secret of any length. The secret is hashed to
80    /// a 32-byte key (cheers codecs require exactly 32 bytes) and used to
81    /// construct the default [`PasetoV4Codec`]. Pre-launch there are no legacy
82    /// tokens, so this key-derivation has no backward-compat path.
83    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    /// Inject any [`cheers_core::Codec`] — used by tests and forward-looking for
90    /// the asymmetric edge verifier (cheers R019). The codec owns the wire
91    /// format and crypto; the resolver only does cookie extraction + claim
92    /// mapping.
93    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    /// Mint a token for the given claims — used by tests and any first-party
101    /// login endpoint that issues mesofact sessions directly.
102    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        // Codec verifies signature/AEAD *and* rejects expired tokens against the
111        // system clock; any failure → unauthenticated.
112        let claims = self.codec.verify(token).ok()?;
113        Some(User::from_claims(claims))
114    }
115}
116
117/// Derive a fixed 32-byte codec key from an arbitrary-length deploy secret.
118fn derive_key(secret: &[u8]) -> [u8; 32] {
119    let mut h = Sha256::new();
120    h.update(secret);
121    h.finalize().into()
122}
123
124/// Pull one cookie value out of a `Cookie:` header (`a=1; b=2`). Returns a slice
125/// of the header so no allocation happens on the hot path.
126fn 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        // Claims fold into attrs to preserve the `{ id, attrs }` render shape.
164        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        // Flip a byte in the ciphertext body; AEAD verification must reject it.
197        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}