//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification. use serde::{Deserialize, Serialize}; /// Standard JWT claims with optional session binding. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JwtClaims { pub sub: String, pub exp: u64, pub iat: u64, /// Token purpose: `"access"` or `"refresh"`. /// /// Prevents an access token from being replayed as a refresh token /// (which would otherwise extend a short-lived credential into the /// 7-day refresh window). #[serde(rename = "typ")] pub token_type: TokenType, #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, } /// JWT token purpose. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum TokenType { Access, Refresh, } impl JwtClaims { pub fn new(sub: String, exp: u64, token_type: TokenType, session_id: Option) -> Self { let iat = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); Self { sub, exp, iat, token_type, session_id, } } } /// Sign a set of claims into a JWT string using HS256. pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result { let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()); let token = jsonwebtoken::encode(&header, &claims, &key)?; Ok(token) } /// Verify a JWT string and return its claims. pub fn verify_token(secret: &str, token: &str) -> anyhow::Result { let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); validation.validate_exp = true; validation.required_spec_claims = ["sub", "exp", "iat"] .iter() .map(|&s| s.to_string()) .collect(); let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()); let token_data = jsonwebtoken::decode::(token, &key, &validation)?; Ok(token_data.claims) } #[cfg(test)] mod tests { use super::*; fn claims(exp_secs_from_now: u64, token_type: TokenType) -> JwtClaims { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); JwtClaims::new( "user-1".to_string(), now + exp_secs_from_now, token_type, None, ) } #[test] fn access_and_refresh_tokens_roundtrip() { let secret = "test-secret"; let access = create_token(secret, claims(3600, TokenType::Access)).unwrap(); let refresh = create_token(secret, claims(604800, TokenType::Refresh)).unwrap(); let acc = verify_token(secret, &access).unwrap(); assert_eq!(acc.token_type, TokenType::Access); let refr = verify_token(secret, &refresh).unwrap(); assert_eq!(refr.token_type, TokenType::Refresh); } #[test] fn token_type_is_distinct() { let secret = "test-secret"; let access = create_token(secret, claims(3600, TokenType::Access)).unwrap(); let claims = verify_token(secret, &access).unwrap(); assert_ne!(claims.token_type, TokenType::Refresh); } #[test] fn expired_token_is_rejected() { let secret = "test-secret"; // exp well in the past (beyond the library's default 60s leeway) → // verification must fail. let past = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() .saturating_sub(120); let expired = JwtClaims::new("user-1".to_string(), past, TokenType::Access, None); let token = create_token(secret, expired).unwrap(); assert!(verify_token(secret, &token).is_err()); } }