Skip to main content

leash_sdk/
auth.rs

1//! Identity helpers — extract the authenticated [`LeashUser`] off a JWT.
2//!
3//! Mirrors `leash-sdk-ts/src/server/auth.ts`, `leash-sdk-python/leash/auth.py`,
4//! and `leash-sdk-go/auth.go`. JWT parsing is stdlib-only (no `jsonwebtoken`
5//! dependency) so the SDK stays slim and avoids OpenSSL transitively.
6
7use base64::engine::general_purpose::URL_SAFE_NO_PAD;
8use base64::Engine;
9use serde::{Deserialize, Serialize};
10
11use crate::errors::{LeashError, Result};
12use crate::request::LeashRequest;
13
14/// The cookie name set by the Leash platform.
15pub const LEASH_AUTH_COOKIE: &str = "leash-auth";
16
17/// Authenticated user extracted from a Leash JWT.
18///
19/// Returned by [`Auth::user`](crate::Auth::user) and the standalone
20/// [`get_leash_user`] helper.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct LeashUser {
23    /// Unique user identifier (`userId` claim, with `sub` fallback).
24    pub id: String,
25    /// Email address.
26    pub email: String,
27    /// Display name.
28    pub name: String,
29    /// Profile picture URL, when present in the JWT.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub picture: Option<String>,
32}
33
34/// Raw JWT claims set by the platform on the `leash-auth` cookie.
35#[derive(Debug, Default, Deserialize)]
36struct Claims {
37    #[serde(rename = "userId", default)]
38    user_id: Option<String>,
39    #[serde(default)]
40    sub: Option<String>,
41    #[serde(default)]
42    email: Option<String>,
43    #[serde(default)]
44    name: Option<String>,
45    #[serde(default)]
46    picture: Option<String>,
47    #[serde(default)]
48    exp: Option<i64>,
49}
50
51impl Claims {
52    fn into_user(self) -> Result<LeashUser> {
53        let id = self
54            .user_id
55            .or(self.sub)
56            .filter(|s| !s.is_empty())
57            .ok_or_else(|| LeashError::Unauthorized {
58                message: "leash-auth cookie is missing a user identifier.".to_string(),
59            })?;
60        Ok(LeashUser {
61            id,
62            email: self.email.unwrap_or_default(),
63            name: self.name.unwrap_or_default(),
64            picture: self.picture,
65        })
66    }
67}
68
69/// Decode a raw `leash-auth` JWT into a [`LeashUser`].
70///
71/// When `LEASH_JWT_SECRET` is set, the HS256 signature is verified. Without
72/// it, the SDK falls back to verify-disabled decoding so local development
73/// works without provisioning the secret. The expiry claim is always
74/// honoured — expired tokens are rejected regardless of signature mode.
75pub fn decode_user(token: &str) -> Result<LeashUser> {
76    let claims = decode_claims(token)?;
77    claims.into_user()
78}
79
80/// Read the request's `leash-auth` cookie and decode it.
81///
82/// Errors when the cookie is missing or the JWT is invalid. Use
83/// [`Auth::user`](crate::Auth::user) for a non-throwing variant.
84pub fn get_leash_user<R: LeashRequest>(req: R) -> Result<LeashUser> {
85    let token = req
86        .cookie(LEASH_AUTH_COOKIE)
87        .ok_or_else(|| LeashError::Unauthorized {
88            message: "No leash-auth cookie on the request.".to_string(),
89        })?;
90    decode_user(&token)
91}
92
93/// `true` when [`get_leash_user`] would succeed.
94pub fn is_authenticated<R: LeashRequest>(req: R) -> bool {
95    get_leash_user(req).is_ok()
96}
97
98// ---------------------------------------------------------------------------
99// Internal
100// ---------------------------------------------------------------------------
101
102fn decode_claims(token: &str) -> Result<Claims> {
103    let parts: Vec<&str> = token.split('.').collect();
104    if parts.len() != 3 {
105        return Err(LeashError::Unauthorized {
106            message: "Invalid leash-auth cookie: malformed JWT.".to_string(),
107        });
108    }
109
110    // Optional signature check.
111    if let Ok(secret) = std::env::var("LEASH_JWT_SECRET") {
112        if !secret.is_empty() {
113            verify_hs256(parts[0], parts[1], parts[2], &secret)?;
114        }
115    }
116
117    let payload_bytes =
118        URL_SAFE_NO_PAD
119            .decode(parts[1].as_bytes())
120            .map_err(|_| LeashError::Unauthorized {
121                message: "Invalid leash-auth cookie: payload not valid base64url.".to_string(),
122            })?;
123
124    let claims: Claims =
125        serde_json::from_slice(&payload_bytes).map_err(|_| LeashError::Unauthorized {
126            message: "Invalid leash-auth cookie: payload not valid JSON.".to_string(),
127        })?;
128
129    // Expiry check — even when signature verification is disabled.
130    if let Some(exp) = claims.exp {
131        let now = std::time::SystemTime::now()
132            .duration_since(std::time::UNIX_EPOCH)
133            .map(|d| d.as_secs() as i64)
134            .unwrap_or(0);
135        if exp > 0 && now > exp {
136            return Err(LeashError::Unauthorized {
137                message: "leash-auth cookie has expired.".to_string(),
138            });
139        }
140    }
141
142    Ok(claims)
143}
144
145fn verify_hs256(header_b64: &str, payload_b64: &str, sig_b64: &str, secret: &str) -> Result<()> {
146    // HS256 = HMAC-SHA256 over `header_b64.payload_b64`, signature in base64url-no-pad.
147    let expected =
148        URL_SAFE_NO_PAD
149            .decode(sig_b64.as_bytes())
150            .map_err(|_| LeashError::Unauthorized {
151                message: "Invalid leash-auth cookie: signature not valid base64url.".to_string(),
152            })?;
153
154    let signing_input = format!("{header_b64}.{payload_b64}");
155    let computed = hmac_sha256(secret.as_bytes(), signing_input.as_bytes());
156
157    if computed.len() != expected.len() {
158        return Err(LeashError::Unauthorized {
159            message: "Invalid leash-auth cookie: signature mismatch.".to_string(),
160        });
161    }
162    // Constant-time compare to avoid timing leaks.
163    let mut diff = 0u8;
164    for (a, b) in computed.iter().zip(expected.iter()) {
165        diff |= a ^ b;
166    }
167    if diff != 0 {
168        return Err(LeashError::Unauthorized {
169            message: "Invalid leash-auth cookie: signature mismatch.".to_string(),
170        });
171    }
172    Ok(())
173}
174
175// Minimal HMAC-SHA256 (RFC 2104) over SHA-256. Vendored to avoid pulling
176// `hmac` + `sha2` just for one decode call — the SDK already keeps deps thin.
177fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
178    const BLOCK: usize = 64;
179    let mut k = [0u8; BLOCK];
180    if key.len() > BLOCK {
181        let hashed = sha256(key);
182        k[..32].copy_from_slice(&hashed);
183    } else {
184        k[..key.len()].copy_from_slice(key);
185    }
186    let mut ipad = [0x36u8; BLOCK];
187    let mut opad = [0x5cu8; BLOCK];
188    for i in 0..BLOCK {
189        ipad[i] ^= k[i];
190        opad[i] ^= k[i];
191    }
192    let mut inner = Vec::with_capacity(BLOCK + msg.len());
193    inner.extend_from_slice(&ipad);
194    inner.extend_from_slice(msg);
195    let inner_hash = sha256(&inner);
196
197    let mut outer = Vec::with_capacity(BLOCK + 32);
198    outer.extend_from_slice(&opad);
199    outer.extend_from_slice(&inner_hash);
200    sha256(&outer)
201}
202
203// Tiny SHA-256 — pure Rust, no deps. Adapted from FIPS 180-4.
204fn sha256(msg: &[u8]) -> [u8; 32] {
205    const K: [u32; 64] = [
206        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
207        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
208        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
209        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
210        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
211        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
212        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
213        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
214        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
215        0xc67178f2,
216    ];
217    let mut h = [
218        0x6a09e667u32, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
219        0x5be0cd19,
220    ];
221
222    let bit_len = (msg.len() as u64).wrapping_mul(8);
223    let mut padded = Vec::with_capacity(msg.len() + 64);
224    padded.extend_from_slice(msg);
225    padded.push(0x80);
226    while padded.len() % 64 != 56 {
227        padded.push(0);
228    }
229    padded.extend_from_slice(&bit_len.to_be_bytes());
230
231    for block in padded.chunks(64) {
232        let mut w = [0u32; 64];
233        for i in 0..16 {
234            w[i] = u32::from_be_bytes([
235                block[i * 4],
236                block[i * 4 + 1],
237                block[i * 4 + 2],
238                block[i * 4 + 3],
239            ]);
240        }
241        for i in 16..64 {
242            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
243            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
244            w[i] = w[i - 16]
245                .wrapping_add(s0)
246                .wrapping_add(w[i - 7])
247                .wrapping_add(s1);
248        }
249        let mut a = h[0];
250        let mut b = h[1];
251        let mut c = h[2];
252        let mut d = h[3];
253        let mut e = h[4];
254        let mut f = h[5];
255        let mut g = h[6];
256        let mut hh = h[7];
257
258        for i in 0..64 {
259            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
260            let ch = (e & f) ^ (!e & g);
261            let t1 = hh
262                .wrapping_add(s1)
263                .wrapping_add(ch)
264                .wrapping_add(K[i])
265                .wrapping_add(w[i]);
266            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
267            let maj = (a & b) ^ (a & c) ^ (b & c);
268            let t2 = s0.wrapping_add(maj);
269            hh = g;
270            g = f;
271            f = e;
272            e = d.wrapping_add(t1);
273            d = c;
274            c = b;
275            b = a;
276            a = t1.wrapping_add(t2);
277        }
278        h[0] = h[0].wrapping_add(a);
279        h[1] = h[1].wrapping_add(b);
280        h[2] = h[2].wrapping_add(c);
281        h[3] = h[3].wrapping_add(d);
282        h[4] = h[4].wrapping_add(e);
283        h[5] = h[5].wrapping_add(f);
284        h[6] = h[6].wrapping_add(g);
285        h[7] = h[7].wrapping_add(hh);
286    }
287
288    let mut out = [0u8; 32];
289    for (i, word) in h.iter().enumerate() {
290        out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
291    }
292    out
293}
294
295// ---------------------------------------------------------------------------
296// Auth namespace — exposed off Leash::auth()
297// ---------------------------------------------------------------------------
298
299/// `leash.auth()` — non-throwing identity reads off the request that the
300/// client was constructed from.
301///
302/// The methods are `async` so the surface aligns with the rest of the SDK and
303/// stays flexible for a future remote-verify flow (today decode is purely
304/// in-memory).
305#[derive(Debug, Clone)]
306pub struct Auth {
307    pub(crate) cookie: Option<String>,
308}
309
310impl Auth {
311    /// Return the authenticated user, or `None` when not authenticated.
312    ///
313    /// Never errors on a missing/invalid cookie — handlers can branch
314    /// cleanly with `if user.is_none()`.
315    pub async fn user(&self) -> Result<Option<LeashUser>> {
316        let Some(cookie) = self.cookie.as_deref() else {
317            return Ok(None);
318        };
319        match decode_user(cookie) {
320            Ok(user) => Ok(Some(user)),
321            Err(_) => Ok(None),
322        }
323    }
324
325    /// `true` when [`Self::user`] would return `Some(_)`.
326    pub async fn is_authenticated(&self) -> Result<bool> {
327        Ok(self.user().await?.is_some())
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Tests
333// ---------------------------------------------------------------------------
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use serde_json::json;
339    use std::sync::Mutex;
340
341    // Process-global LEASH_JWT_SECRET — serialise tests that touch it.
342    static ENV_GUARD: Mutex<()> = Mutex::new(());
343
344    fn b64(input: &[u8]) -> String {
345        URL_SAFE_NO_PAD.encode(input)
346    }
347
348    fn make_token(payload: serde_json::Value, secret: &str) -> String {
349        let header = b64(br#"{"alg":"HS256","typ":"JWT"}"#);
350        let body = b64(payload.to_string().as_bytes());
351        let signing_input = format!("{header}.{body}");
352        let sig = hmac_sha256(secret.as_bytes(), signing_input.as_bytes());
353        let sig_b64 = b64(&sig);
354        format!("{header}.{body}.{sig_b64}")
355    }
356
357    #[test]
358    fn decode_user_no_secret_returns_user() {
359        let _g = ENV_GUARD.lock().unwrap();
360        std::env::remove_var("LEASH_JWT_SECRET");
361        let token = make_token(
362            json!({"userId":"u1","email":"a@b.c","name":"Al"}),
363            "anything",
364        );
365        let user = decode_user(&token).unwrap();
366        assert_eq!(user.id, "u1");
367        assert_eq!(user.email, "a@b.c");
368        assert_eq!(user.name, "Al");
369        assert_eq!(user.picture, None);
370    }
371
372    #[test]
373    fn decode_user_with_secret_verifies() {
374        let _g = ENV_GUARD.lock().unwrap();
375        std::env::set_var("LEASH_JWT_SECRET", "k");
376        let token = make_token(json!({"userId":"u1","email":"a@b","name":"x"}), "k");
377        let user = decode_user(&token).unwrap();
378        assert_eq!(user.id, "u1");
379        std::env::remove_var("LEASH_JWT_SECRET");
380    }
381
382    #[test]
383    fn decode_user_rejects_bad_signature() {
384        let _g = ENV_GUARD.lock().unwrap();
385        std::env::set_var("LEASH_JWT_SECRET", "correct");
386        let token = make_token(json!({"userId":"u1","email":"a@b","name":"x"}), "wrong");
387        let err = decode_user(&token).unwrap_err();
388        assert!(err.is_unauthorized());
389        std::env::remove_var("LEASH_JWT_SECRET");
390    }
391
392    #[test]
393    fn decode_user_rejects_expired_token() {
394        let _g = ENV_GUARD.lock().unwrap();
395        std::env::remove_var("LEASH_JWT_SECRET");
396        let token = make_token(
397            json!({"userId":"u1","email":"a@b","name":"x","exp": 1}),
398            "k",
399        );
400        let err = decode_user(&token).unwrap_err();
401        assert!(err.is_unauthorized());
402        assert!(matches!(err, LeashError::Unauthorized { ref message } if message.contains("expired")));
403    }
404
405    #[test]
406    fn decode_user_falls_back_to_sub_when_userid_absent() {
407        let _g = ENV_GUARD.lock().unwrap();
408        std::env::remove_var("LEASH_JWT_SECRET");
409        let token = make_token(json!({"sub":"u2","email":"x@y"}), "k");
410        let user = decode_user(&token).unwrap();
411        assert_eq!(user.id, "u2");
412        assert_eq!(user.email, "x@y");
413    }
414
415    #[test]
416    fn decode_user_errors_when_no_identifier() {
417        let _g = ENV_GUARD.lock().unwrap();
418        std::env::remove_var("LEASH_JWT_SECRET");
419        let token = make_token(json!({"email":"x@y","name":"n"}), "k");
420        let err = decode_user(&token).unwrap_err();
421        assert!(err.is_unauthorized());
422    }
423
424    #[test]
425    fn malformed_jwt_rejected() {
426        let _g = ENV_GUARD.lock().unwrap();
427        std::env::remove_var("LEASH_JWT_SECRET");
428        assert!(decode_user("not.a.jwt-tripartite").is_err());
429        assert!(decode_user("only-one-part").is_err());
430    }
431
432    #[tokio::test]
433    async fn auth_namespace_returns_none_when_no_cookie() {
434        let auth = Auth { cookie: None };
435        assert!(auth.user().await.unwrap().is_none());
436        assert!(!auth.is_authenticated().await.unwrap());
437    }
438
439    #[tokio::test]
440    async fn auth_namespace_returns_user_for_valid_cookie() {
441        let auth = {
442            let _g = ENV_GUARD.lock().unwrap();
443            std::env::remove_var("LEASH_JWT_SECRET");
444            let token = make_token(json!({"userId":"abc","email":"e","name":"n"}), "k");
445            Auth { cookie: Some(token) }
446            // Drop the guard before the await — the guard is non-Send and would
447            // otherwise be held across .await (clippy::await_holding_lock).
448        };
449        let user = auth.user().await.unwrap().unwrap();
450        assert_eq!(user.id, "abc");
451        assert!(auth.is_authenticated().await.unwrap());
452    }
453
454    #[tokio::test]
455    async fn auth_namespace_swallows_bad_cookie() {
456        let auth = {
457            let _g = ENV_GUARD.lock().unwrap();
458            std::env::remove_var("LEASH_JWT_SECRET");
459            Auth {
460                cookie: Some("garbage".into()),
461            }
462        };
463        // No error — just None.
464        assert!(auth.user().await.unwrap().is_none());
465        assert!(!auth.is_authenticated().await.unwrap());
466    }
467
468    #[test]
469    fn get_leash_user_reads_from_request() {
470        let _g = ENV_GUARD.lock().unwrap();
471        std::env::remove_var("LEASH_JWT_SECRET");
472        let token = make_token(json!({"userId":"u1","email":"e","name":"n"}), "k");
473        let req = http::Request::builder()
474            .header("cookie", format!("leash-auth={token}"))
475            .body(())
476            .unwrap();
477        let user = get_leash_user(&req).unwrap();
478        assert_eq!(user.id, "u1");
479    }
480
481    #[test]
482    fn is_authenticated_returns_false_for_no_cookie() {
483        let req = http::Request::builder().body(()).unwrap();
484        assert!(!is_authenticated(&req));
485    }
486}