1use 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
14pub const LEASH_AUTH_COOKIE: &str = "leash-auth";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct LeashUser {
23 pub id: String,
25 pub email: String,
27 pub name: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub picture: Option<String>,
32}
33
34#[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
69pub fn decode_user(token: &str) -> Result<LeashUser> {
76 let claims = decode_claims(token)?;
77 claims.into_user()
78}
79
80pub 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
93pub fn is_authenticated<R: LeashRequest>(req: R) -> bool {
95 get_leash_user(req).is_ok()
96}
97
98fn 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 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 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 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 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
175fn 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
203fn 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#[derive(Debug, Clone)]
306pub struct Auth {
307 pub(crate) cookie: Option<String>,
308}
309
310impl Auth {
311 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 pub async fn is_authenticated(&self) -> Result<bool> {
327 Ok(self.user().await?.is_some())
328 }
329}
330
331#[cfg(test)]
336mod tests {
337 use super::*;
338 use serde_json::json;
339 use std::sync::Mutex;
340
341 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 };
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 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}