#![allow(dead_code)] use anyhow::Result; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use crate::config::JwtConfig; // Token claims #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { pub sub: String, pub exp: usize, pub iat: usize, pub user_id: String, pub email: String, pub token_version: i32, } impl Claims { /// Build access-token claims. `expiry` is the access-token lifetime and is /// taken from `JwtConfig` by callers (no longer hardcoded here). pub fn new(user_id: String, email: String, token_version: i32, expiry: Duration) -> Self { let now = Utc::now(); let exp = now + expiry; Self { sub: user_id.clone(), exp: exp.timestamp() as usize, iat: now.timestamp() as usize, user_id, email, token_version, } } } // Refresh token claims (longer expiry) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefreshClaims { pub sub: String, pub exp: usize, pub iat: usize, pub user_id: String, pub token_version: i32, } impl RefreshClaims { /// Build refresh-token claims. `expiry` is taken from `JwtConfig` by callers. pub fn new(user_id: String, token_version: i32, expiry: Duration) -> Self { let now = Utc::now(); let exp = now + expiry; Self { sub: user_id.clone(), exp: exp.timestamp() as usize, iat: now.timestamp() as usize, user_id, token_version, } } } /// JWT Service for token generation and validation. /// /// Pure crypto service: it signs/verifies tokens but does NOT track them. Token /// persistence (refresh-token storage, revocation) is handled by /// `RefreshTokenRepository` + the auth handlers; token-version staleness is /// enforced in the JWT middleware via `TokenVersionCache`. #[derive(Clone)] pub struct JwtService { config: JwtConfig, encoding_key: EncodingKey, decoding_key: DecodingKey, } impl JwtService { pub fn new(config: JwtConfig) -> Self { let encoding_key = EncodingKey::from_secret(config.secret.as_ref()); let decoding_key = DecodingKey::from_secret(config.secret.as_ref()); Self { config, encoding_key, decoding_key, } } /// Generate access and refresh tokens. Both expiries are derived from the /// configured `JwtConfig` so callers don't need to thread durations through. pub fn generate_tokens(&self, claims: Claims) -> Result<(String, String)> { let access_token = encode(&Header::default(), &claims, &self.encoding_key) .map_err(|e| anyhow::anyhow!("Failed to encode access token: {}", e))?; let refresh_expiry = Duration::days(self.config.refresh_token_expiry_days); let refresh_claims = RefreshClaims::new(claims.user_id.clone(), claims.token_version, refresh_expiry); let refresh_token = encode(&Header::default(), &refresh_claims, &self.encoding_key) .map_err(|e| anyhow::anyhow!("Failed to encode refresh token: {}", e))?; Ok((access_token, refresh_token)) } /// The configured refresh-token lifetime, as a chrono `Duration`. pub fn refresh_expiry(&self) -> Duration { Duration::days(self.config.refresh_token_expiry_days) } /// The configured access-token lifetime, as a chrono `Duration`. pub fn access_expiry(&self) -> Duration { Duration::minutes(self.config.access_token_expiry_minutes) } /// Validate access token (signature + expiry only). Callers are responsible /// for any `token_version` comparison against the user record. pub fn validate_token(&self, token: &str) -> Result { let token_data = decode::(token, &self.decoding_key, &Validation::default()) .map_err(|e| anyhow::anyhow!("Invalid token: {}", e))?; Ok(token_data.claims) } /// Validate refresh token (signature + expiry only). Callers must still /// confirm the token exists, is not revoked, and has not expired in the /// `RefreshTokenRepository`. pub fn validate_refresh_token(&self, token: &str) -> Result { let token_data = decode::(token, &self.decoding_key, &Validation::default()) .map_err(|e| anyhow::anyhow!("Invalid refresh token: {}", e))?; Ok(token_data.claims) } } #[cfg(test)] mod tests { use super::*; fn test_config() -> JwtConfig { JwtConfig { secret: "test-secret".to_string(), access_token_expiry_minutes: 15, refresh_token_expiry_days: 7, } } #[test] fn generate_then_validate_roundtrip() { let svc = JwtService::new(test_config()); let claims = Claims::new( "user-1".to_string(), "u@example.com".to_string(), 0, Duration::minutes(15), ); let (access, refresh) = svc.generate_tokens(claims).unwrap(); let access_claims = svc.validate_token(&access).unwrap(); assert_eq!(access_claims.user_id, "user-1"); assert_eq!(access_claims.token_version, 0); let refresh_claims = svc.validate_refresh_token(&refresh).unwrap(); assert_eq!(refresh_claims.user_id, "user-1"); } #[test] fn refresh_version_is_carried_through() { let svc = JwtService::new(test_config()); let claims = Claims::new( "user-2".to_string(), "u2@example.com".to_string(), 3, Duration::minutes(15), ); let (_access, refresh) = svc.generate_tokens(claims).unwrap(); let refresh_claims = svc.validate_refresh_token(&refresh).unwrap(); assert_eq!(refresh_claims.token_version, 3); } }