use crate::types::LeashError;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
const LEASH_AUTH_COOKIE: &str = "leash-auth";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LeashUser {
#[serde(rename = "userId")]
pub id: String,
pub email: String,
pub name: String,
#[serde(default)]
pub picture: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Claims {
#[serde(rename = "userId")]
user_id: String,
email: String,
name: String,
#[serde(default)]
picture: Option<String>,
}
impl From<Claims> for LeashUser {
fn from(c: Claims) -> Self {
Self {
id: c.user_id,
email: c.email,
name: c.name,
picture: c.picture,
}
}
}
pub fn get_leash_user(cookie_header: &str) -> Result<LeashUser, LeashError> {
let token = parse_cookie(cookie_header, LEASH_AUTH_COOKIE).ok_or_else(|| {
LeashError::ApiError {
message: "leash-auth cookie not found".to_string(),
code: Some("missing_cookie".to_string()),
}
})?;
get_leash_user_from_cookie(token)
}
pub fn get_leash_user_from_cookie(token: &str) -> Result<LeashUser, LeashError> {
let claims = decode_jwt(token)?;
Ok(LeashUser::from(claims))
}
pub fn is_authenticated(cookie_header: &str) -> bool {
get_leash_user(cookie_header).is_ok()
}
pub fn is_authenticated_from_cookie(token: &str) -> bool {
get_leash_user_from_cookie(token).is_ok()
}
fn parse_cookie<'a>(header: &'a str, name: &str) -> Option<&'a str> {
for pair in header.split(';') {
let pair = pair.trim();
if let Some(rest) = pair.strip_prefix(name) {
if let Some(value) = rest.strip_prefix('=') {
let value = value.trim();
if !value.is_empty() {
return Some(value);
}
}
}
}
None
}
fn decode_jwt(token: &str) -> Result<Claims, LeashError> {
match std::env::var("LEASH_JWT_SECRET") {
Ok(secret) if !secret.is_empty() => {
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.required_spec_claims.clear();
validation.validate_exp = false;
let data = decode::<Claims>(token, &key, &validation).map_err(|e| {
LeashError::ApiError {
message: format!("JWT verification failed: {e}"),
code: Some("invalid_token".to_string()),
}
})?;
Ok(data.claims)
}
_ => {
let mut validation = Validation::new(Algorithm::HS256);
validation.insecure_disable_signature_validation();
validation.required_spec_claims.clear();
validation.validate_exp = false;
let key = DecodingKey::from_secret(b"");
let data = decode::<Claims>(token, &key, &validation).map_err(|e| {
LeashError::ApiError {
message: format!("JWT decode failed: {e}"),
code: Some("invalid_token".to_string()),
}
})?;
Ok(data.claims)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::Serialize;
use std::sync::Mutex;
static ENV_GUARD: Mutex<()> = Mutex::new(());
#[derive(Serialize)]
struct TestClaims {
#[serde(rename = "userId")]
user_id: String,
email: String,
name: String,
picture: Option<String>,
}
fn make_token(claims: &TestClaims, secret: &str) -> String {
encode(
&Header::default(),
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
fn sample_claims() -> TestClaims {
TestClaims {
user_id: "usr_123".to_string(),
email: "alice@example.com".to_string(),
name: "Alice".to_string(),
picture: Some("https://img.example.com/alice.png".to_string()),
}
}
#[test]
fn valid_token_returns_user() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let claims = sample_claims();
let token = make_token(&claims, "any-secret");
let header = format!("other=foo; leash-auth={token}; session=bar");
let user = get_leash_user(&header).unwrap();
assert_eq!(user.id, "usr_123");
assert_eq!(user.email, "alice@example.com");
assert_eq!(user.name, "Alice");
assert_eq!(
user.picture,
Some("https://img.example.com/alice.png".to_string())
);
}
#[test]
fn missing_cookie_returns_error() {
let header = "session=abc; other=xyz";
let err = get_leash_user(header).unwrap_err();
assert!(err.to_string().contains("leash-auth cookie not found"));
}
#[test]
fn empty_header_returns_error() {
let err = get_leash_user("").unwrap_err();
assert!(err.to_string().contains("leash-auth cookie not found"));
}
#[test]
fn invalid_token_returns_error() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let header = "leash-auth=not-a-jwt";
let err = get_leash_user(header).unwrap_err();
assert!(err.to_string().contains("JWT decode failed"));
}
#[test]
fn no_secret_decodes_without_verification() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let claims = sample_claims();
let token = make_token(&claims, "some-random-secret");
let user = get_leash_user_from_cookie(&token).unwrap();
assert_eq!(user.id, "usr_123");
assert_eq!(user.email, "alice@example.com");
}
#[test]
fn with_secret_verifies_signature() {
let _g = ENV_GUARD.lock().unwrap();
let secret = "test-secret-key";
std::env::set_var("LEASH_JWT_SECRET", secret);
let claims = sample_claims();
let token = make_token(&claims, secret);
let user = get_leash_user_from_cookie(&token).unwrap();
assert_eq!(user.id, "usr_123");
std::env::remove_var("LEASH_JWT_SECRET");
}
#[test]
fn with_secret_rejects_wrong_signature() {
let _g = ENV_GUARD.lock().unwrap();
let secret = "correct-secret";
std::env::set_var("LEASH_JWT_SECRET", secret);
let claims = sample_claims();
let token = make_token(&claims, "wrong-secret");
let err = get_leash_user_from_cookie(&token).unwrap_err();
assert!(err.to_string().contains("JWT verification failed"));
std::env::remove_var("LEASH_JWT_SECRET");
}
#[test]
fn get_leash_user_from_cookie_works_with_raw_token() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let claims = sample_claims();
let token = make_token(&claims, "secret");
let user = get_leash_user_from_cookie(&token).unwrap();
assert_eq!(user.id, "usr_123");
assert_eq!(user.name, "Alice");
}
#[test]
fn picture_is_optional() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let claims = TestClaims {
user_id: "usr_456".to_string(),
email: "bob@example.com".to_string(),
name: "Bob".to_string(),
picture: None,
};
let token = make_token(&claims, "s");
let user = get_leash_user_from_cookie(&token).unwrap();
assert_eq!(user.id, "usr_456");
assert_eq!(user.picture, None);
}
#[test]
fn is_authenticated_returns_true_for_valid_cookie() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let claims = sample_claims();
let token = make_token(&claims, "any-secret");
let header = format!("leash-auth={token}");
assert!(is_authenticated(&header));
}
#[test]
fn is_authenticated_returns_false_for_missing_cookie() {
assert!(!is_authenticated("session=abc"));
}
#[test]
fn is_authenticated_from_cookie_returns_true_for_valid_token() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
let claims = sample_claims();
let token = make_token(&claims, "any-secret");
assert!(is_authenticated_from_cookie(&token));
}
#[test]
fn is_authenticated_from_cookie_returns_false_for_invalid_token() {
let _g = ENV_GUARD.lock().unwrap();
std::env::remove_var("LEASH_JWT_SECRET");
assert!(!is_authenticated_from_cookie("not-a-jwt"));
}
#[test]
fn parse_cookie_handles_edge_cases() {
assert_eq!(parse_cookie("leash-auth=tok123", "leash-auth"), Some("tok123"));
assert_eq!(
parse_cookie("a=1 ; leash-auth=tok123 ; b=2", "leash-auth"),
Some("tok123")
);
assert_eq!(parse_cookie("other=val", "leash-auth"), None);
assert_eq!(parse_cookie("leash-auth-extra=val", "leash-auth"), None);
}
}