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//!
28//! @yah:ticket(R750-F2, "op_mesofact_session: hand the Rust-resolved User (cheers SessionResolver) into the SSR isolate")
29//! @yah:status(review)
30//! @yah:at(2026-10-07T21:52:46Z)
31//! @yah:assignee(agent:bundle-anthropic-ashguard)
32//! @yah:parent(R750)
33//! @yah:next("Tier: Warrior. Identity is already resolved Rust-side (R556-B13): SessionResolver::resolve(cookie_header) -> Option<User> at oss/mesofact/crates/mesofact-core/src/proxy/session.rs:70, User at :41. The isolate just needs it handed over — mesofact-ssr must NOT gain a cheers dependency.")
34//! @yah:next("Design: the SSR dispatch entry in mesofact-ssr (ssr.rs dispatch/invoke path) takes an optional resolved user (serde_json::Value or a small mesofact-ssr-owned SsrUser {id, attrs}) alongside the request, stores it in OpState per-dispatch; #[op2] op_mesofact_session() returns it (or null). The caller in mesofact-core (wherever the SSR route handler builds the request and has the cookie header + resolver) resolves and passes it. If a cookie_header-taking op is strictly required by the ticket text, implement it as op_mesofact_session(cookie_header) that looks up a resolver closure in OpState instead; prefer the pre-resolved shape — fewer moving parts.")
35//! @yah:next("JS side: expose it on the runtime API the TS package expects (check oss/mesofact/packages/mesofact-runtime/src for a session/user/auth export; if none exists, add `session()` / `currentUser()` to ssr_harness.js's globalThis.__mesofact_ssr context object and mirror the TS type in mesofact-runtime).")
36//! @yah:next("Add a unit test in mesofact-ssr that dispatches a tiny route reading the session and asserts both the user-present and null cases.")
37//! @yah:next("Return: single JSON object {ticket_id, status, commit_sha?, notes<=3 sentences with pass/fail counts vs baseline}. Full account goes in @yah:handoff. Git policy is defer: no commits; print the command you would have run.")
38//! @yah:verify("cargo test -p mesofact-ssr and cargo test -p mesofact-core pass (record counts vs baseline).")
39//! @yah:verify("cargo tree -p mesofact-ssr does not list cheers.")
40//! @yah:gotcha("Depends on MFT-R750-F1 landing the extension rewrite in ssr.rs first — register the op in the same extensions() fn. Do not edit js/ssr_runtime_shim.js (R820 header; T3's seam). Shared tree, git policy defer.")
41//! @yah:depends_on(MFT-R750-F1)
42//! @yah:files(oss/mesofact/crates/mesofact-core/src/proxy/session.rs)
43//! @yah:files(oss/mesofact/crates/mesofact-ssr/src/ssr.rs)
44//! @yah:files(oss/mesofact/crates/mesofact-ssr/js/ssr_harness.js)
45//! @yah:handoff("LANDED: DispatchRequest gains `#[serde(skip)] pub user: Option<serde_json::Value>`, which is mesofact-core's User as {id, attrs}. The isolate's Job::Dispatch puts it in OpState as DispatchSession before dispatch_harness and resets it to None afterwards. An isolate serves one dispatch at a time, so the user cannot leak into the next request. New src/ops_session.rs: sync #[op2] op_mesofact_session() returns the user or null, registered in extensions() next to mesofact_fetch. js/ssr_harness.js: __mesofact_ssr.currentUser(). mesofact-runtime: `SsrContext { currentUser(): User | null }` type in contract.ts, exported from index.ts. ssr_runtime_shim.js was not touched.")
46//! @yah:verify("cargo test -p mesofact-ssr: 11 passed, 0 failed (baseline 10, plus the new dispatch_hands_the_resolved_user_to_route_code test, which covers user-present, then null, and no leak across dispatches)")
47//! @yah:verify("cargo test -p mesofact-core: 77 passed (48+3+1+21+4), 0 failed; baseline measured before the change was also 77")
48//! @yah:verify("cargo check -p mesofact --features ssr --tests: EXIT 0")
49//! @yah:verify("cargo tree -p mesofact-ssr | grep -c cheers = 0")
50//! @yah:verify("packages/mesofact-runtime tsc --noEmit: exit 0")
51//! @yah:handoff("PRODUCTION CALLER WIRED (leader decision). proxy and serve are separate subcommands, i.e. separate processes, so they cannot share one resolver instance. They share the builder instead: the new mesofact_core::proxy::session::resolver_from_env(secret_env, cookie_name) is the body of proxy's old build_session_resolver, and cli/proxy.rs now delegates to it. `mesofact serve` gains the same --session-secret-env / --session-cookie flags with the same env vars (MESOFACT_SESSION_SECRET_ENV / MESOFACT_SESSION_COOKIE). with_session_resolver() attaches the resolver on both SSR paths: the bundle path after attach_bundle_ssr, and run_workload_modes. Server/ServerState hold `session: Option<Arc<dyn SessionResolver>>` (ssr builds; Server::with_session). serve_dynamic calls resolve_ssr_user(resolver, headers), which takes the Cookie header, runs SessionResolver::resolve and serializes the User to {id, attrs}, then passes the result to dispatch_to_ssr. DispatchRequest.user is set on every retry attempt. Not done in this ticket: serve still does not ENFORCE requires:[user]. The 401/redirect gate stays in the proxy router, and --trust-edge-auth is unchanged; the serve_policy_support doc was updated to say so.")
52//! @yah:verify("Server-level test ssr_dispatch_carries_the_resolved_user (crates/mesofact/src/server.rs) covers three cases: a verifying cookie gives {id:u_1, attrs:{}}, a missing cookie gives null, and no resolver gives null.")
53//! @yah:verify("cargo test -p mesofact --features ssr --no-fail-fast: lib 202 passed, 2 failed (baseline 201 passed, 2 failed). The 2 failures are the same cli::new version-pin tests in both runs (@mesofact/runtime 0.8.42 vs binary 0.8.43-pre.1) and are unrelated to this change. Integration tests: 5 + 2 passed.")
54//! @yah:verify("Re-run after the wiring: mesofact-ssr 11 passed; mesofact-core 77 passed; cargo check -p mesofact --tests with and without the ssr feature: EXIT 0. The one warning (resolve_mirror_key never used, default features) is in code this change did not touch.")
55
56use cheers_core::{Claims, Codec, CodecError};
57// Concrete symmetric codec moved out of cheers-core into cheers-server by the
58// F6 crate split (cheers-core is now the keyless trait/identity surface).
59use cheers_server::PasetoV4Codec;
60use serde::{Deserialize, Serialize};
61use sha2::{Digest, Sha256};
62
63pub const DEFAULT_COOKIE_NAME: &str = "mesofact_session";
64
65/// Resolved identity handed to render on `req.user`. `attrs` is opaque to
66/// mesofact — populated from the verified cheers [`Claims`] (see
67/// [`User::from_claims`]); it rides through to the worker on `req.user`.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct User {
70    pub id: String,
71    #[serde(default)]
72    pub attrs: serde_json::Map<String, serde_json::Value>,
73}
74
75impl User {
76    /// Map verified cheers [`Claims`] onto the render-facing identity. cheers
77    /// has no opaque attribute bag, so the device binding + token lifetimes ride
78    /// through under `attrs` to preserve mesofact's `{ id, attrs }` render
79    /// contract (R009).
80    fn from_claims(c: Claims) -> Self {
81        let mut attrs = serde_json::Map::new();
82        attrs.insert("device".into(), serde_json::Value::String(c.device.into_inner()));
83        attrs.insert(
84            "binding".into(),
85            serde_json::to_value(&c.binding).unwrap_or(serde_json::Value::Null),
86        );
87        attrs.insert("issued_at".into(), serde_json::json!(c.issued_at));
88        attrs.insert("expires_at".into(), serde_json::json!(c.expires_at));
89        Self { id: c.sub.into_inner(), attrs }
90    }
91}
92
93/// Pluggable session resolution. Sync because cookie verification needs no I/O;
94/// a network-backed resolver (OAuth introspection) would add its own runtime.
95pub trait SessionResolver: Send + Sync {
96    /// Resolve identity from a raw `Cookie` header value (or `None` if absent).
97    /// Returns `None` for any unauthenticated outcome (missing / bad / expired).
98    fn resolve(&self, cookie_header: Option<&str>) -> Option<User>;
99}
100
101pub struct CookieSessionResolver {
102    cookie_name: String,
103    codec: Box<dyn Codec + Send + Sync>,
104}
105
106impl CookieSessionResolver {
107    /// Build a resolver from a raw secret of any length. The secret is hashed to
108    /// a 32-byte key (cheers codecs require exactly 32 bytes) and used to
109    /// construct the default [`PasetoV4Codec`]. Pre-launch there are no legacy
110    /// tokens, so this key-derivation has no backward-compat path.
111    pub fn new(cookie_name: impl Into<String>, secret: impl AsRef<[u8]>) -> Self {
112        let codec = PasetoV4Codec::new(&derive_key(secret.as_ref()))
113            .expect("a 32-byte key is always valid");
114        Self::with_codec(cookie_name, Box::new(codec))
115    }
116
117    /// Inject any [`cheers_core::Codec`] — used by tests and forward-looking for
118    /// the asymmetric edge verifier (cheers R019). The codec owns the wire
119    /// format and crypto; the resolver only does cookie extraction + claim
120    /// mapping.
121    pub fn with_codec(
122        cookie_name: impl Into<String>,
123        codec: Box<dyn Codec + Send + Sync>,
124    ) -> Self {
125        Self { cookie_name: cookie_name.into(), codec }
126    }
127
128    /// Mint a token for the given claims — used by tests and any first-party
129    /// login endpoint that issues mesofact sessions directly.
130    pub fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
131        self.codec.mint(claims)
132    }
133}
134
135impl SessionResolver for CookieSessionResolver {
136    fn resolve(&self, cookie_header: Option<&str>) -> Option<User> {
137        let token = cookie_value(cookie_header?, &self.cookie_name)?;
138        // Codec verifies signature/AEAD *and* rejects expired tokens against the
139        // system clock; any failure → unauthenticated.
140        let claims = self.codec.verify(token).ok()?;
141        Some(User::from_claims(claims))
142    }
143}
144
145/// Build the cookie resolver from deploy config: `secret_env` names the env var
146/// holding the codec secret (`--session-secret-env`), `cookie_name` the cookie.
147/// Shared by `mesofact proxy` and `mesofact serve` (R750-F2) so both
148/// subcommands resolve sessions from one configuration surface. A
149/// configured-but-unset/empty env var is a deploy error: warn and run without
150/// sessions rather than crash (fails safe — routes see no user, not a forged one).
151pub fn resolver_from_env(
152    secret_env: Option<&str>,
153    cookie_name: &str,
154) -> Option<std::sync::Arc<dyn SessionResolver>> {
155    let env_name = secret_env?;
156    match std::env::var(env_name) {
157        Ok(secret) if !secret.is_empty() => {
158            tracing::info!(cookie = %cookie_name, "session resolver enabled");
159            Some(std::sync::Arc::new(CookieSessionResolver::new(
160                cookie_name.to_owned(),
161                secret.into_bytes(),
162            )))
163        }
164        _ => {
165            tracing::warn!(
166                env = %env_name,
167                "session secret env var is unset/empty — sessions disabled"
168            );
169            None
170        }
171    }
172}
173
174/// Derive a fixed 32-byte codec key from an arbitrary-length deploy secret.
175fn derive_key(secret: &[u8]) -> [u8; 32] {
176    let mut h = Sha256::new();
177    h.update(secret);
178    h.finalize().into()
179}
180
181/// Pull one cookie value out of a `Cookie:` header (`a=1; b=2`). Returns a slice
182/// of the header so no allocation happens on the hot path.
183fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
184    header.split(';').find_map(|pair| {
185        let (k, v) = pair.split_once('=')?;
186        (k.trim() == name).then(|| v.trim())
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use cheers_core::{DeviceBinding, DeviceId, UserId};
194    use std::time::{SystemTime, UNIX_EPOCH};
195
196    fn resolver() -> CookieSessionResolver {
197        CookieSessionResolver::new(DEFAULT_COOKIE_NAME, b"super-secret-key")
198    }
199
200    fn now() -> i64 {
201        SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
202    }
203
204    fn claims(user_id: &str, expires_at: i64) -> Claims {
205        Claims::new(
206            UserId::new(user_id),
207            DeviceId::new("d1"),
208            DeviceBinding::Passkey,
209            now(),
210            expires_at,
211        )
212    }
213
214    #[test]
215    fn round_trips_a_signed_session() {
216        let r = resolver();
217        let token = r.mint(&claims("u42", now() + 3600)).unwrap();
218        let user = r.resolve(Some(&format!("mesofact_session={token}"))).unwrap();
219        assert_eq!(user.id, "u42");
220        // Claims fold into attrs to preserve the `{ id, attrs }` render shape.
221        assert_eq!(user.attrs.get("device").unwrap(), &serde_json::json!("d1"));
222        assert_eq!(
223            user.attrs.get("binding").unwrap(),
224            &serde_json::json!({ "kind": "passkey" })
225        );
226    }
227
228    #[test]
229    fn picks_the_named_cookie_out_of_many() {
230        let r = resolver();
231        let token = r.mint(&claims("u1", now() + 3600)).unwrap();
232        let header = format!("theme=dark; mesofact_session={token}; tz=utc");
233        assert_eq!(r.resolve(Some(&header)).unwrap().id, "u1");
234    }
235
236    #[test]
237    fn missing_cookie_resolves_to_none() {
238        assert!(resolver().resolve(None).is_none());
239        assert!(resolver().resolve(Some("theme=dark")).is_none());
240    }
241
242    #[test]
243    fn expired_token_resolves_to_none() {
244        let r = resolver();
245        let token = r.mint(&claims("u1", now() - 1)).unwrap();
246        assert!(r.resolve(Some(&format!("mesofact_session={token}"))).is_none());
247    }
248
249    #[test]
250    fn tampered_token_fails_verification() {
251        let r = resolver();
252        let token = r.mint(&claims("u1", now() + 3600)).unwrap();
253        // Flip a byte in the ciphertext body; AEAD verification must reject it.
254        let mut bytes = token.into_bytes();
255        let last = bytes.len() - 1;
256        bytes[last] ^= 0x01;
257        let forged = String::from_utf8(bytes).unwrap();
258        assert!(r.resolve(Some(&format!("mesofact_session={forged}"))).is_none());
259    }
260
261    #[test]
262    fn wrong_key_fails_verification() {
263        let signer = resolver();
264        let token = signer.mint(&claims("u1", now() + 3600)).unwrap();
265        let other = CookieSessionResolver::new(DEFAULT_COOKIE_NAME, b"different-key");
266        assert!(other.resolve(Some(&format!("mesofact_session={token}"))).is_none());
267    }
268}