//! JWT token generation and validation (HS256). //! //! This does NOT implement a domain port — it is used directly by the //! presentation layer's auth extractors. use domain::User; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; /// Minimum secret length for production (256 bits = 32 bytes). const MIN_SECRET_LENGTH: usize = 32; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- /// JWT configuration. #[derive(Debug, Clone)] pub struct JwtConfig { /// Secret key for HS256 signing/verification. pub secret: String, /// Expected issuer (for validation). pub issuer: Option, /// Expected audience (for validation). pub audience: Option, /// Access token expiry in hours (default: 24). pub expiry_hours: u64, /// Refresh token expiry in days (default: 30). pub refresh_expiry_days: u64, } impl JwtConfig { /// Create a new JWT config with validation. /// /// In production mode, this rejects secrets shorter than /// [`MIN_SECRET_LENGTH`] bytes. pub fn new( secret: String, issuer: Option, audience: Option, expiry_hours: Option, refresh_expiry_days: Option, is_production: bool, ) -> Result { if is_production && secret.len() < MIN_SECRET_LENGTH { return Err(JwtError::WeakSecret { min_length: MIN_SECRET_LENGTH, actual_length: secret.len(), }); } Ok(Self { secret, issuer, audience, expiry_hours: expiry_hours.unwrap_or(24), refresh_expiry_days: refresh_expiry_days.unwrap_or(30), }) } /// Create config without validation (for testing). pub fn new_unchecked(secret: String) -> Self { Self { secret, issuer: None, audience: None, expiry_hours: 24, refresh_expiry_days: 30, } } } // --------------------------------------------------------------------------- // Claims // --------------------------------------------------------------------------- fn default_token_type() -> String { "access".to_string() } /// JWT claims structure. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct JwtClaims { /// Subject — the user's unique identifier (user ID as string). pub sub: String, /// User's email address. pub email: String, /// Expiry timestamp (seconds since UNIX epoch). pub exp: usize, /// Issued-at timestamp (seconds since UNIX epoch). pub iat: usize, /// Issuer. #[serde(skip_serializing_if = "Option::is_none")] pub iss: Option, /// Audience. #[serde(skip_serializing_if = "Option::is_none")] pub aud: Option, /// Token type: `"access"` or `"refresh"`. Defaults to `"access"` for /// backward compatibility. #[serde(default = "default_token_type")] pub token_type: String, } // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- /// JWT-related errors. #[derive(Debug, thiserror::Error)] pub enum JwtError { #[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")] WeakSecret { min_length: usize, actual_length: usize, }, #[error("Token creation failed: {0}")] CreationFailed(#[from] jsonwebtoken::errors::Error), #[error("Token validation failed: {0}")] ValidationFailed(String), #[error("Token expired")] Expired, #[error("Invalid token format")] InvalidFormat, #[error("Missing configuration")] MissingConfig, } // --------------------------------------------------------------------------- // Validator / generator // --------------------------------------------------------------------------- /// JWT token validator and generator. #[derive(Clone)] pub struct JwtValidator { config: JwtConfig, encoding_key: EncodingKey, decoding_key: DecodingKey, validation: Validation, } impl JwtValidator { /// Create a new JWT validator with the given configuration. pub fn new(config: JwtConfig) -> Self { let encoding_key = EncodingKey::from_secret(config.secret.as_bytes()); let decoding_key = DecodingKey::from_secret(config.secret.as_bytes()); let mut validation = Validation::new(Algorithm::HS256); if let Some(ref issuer) = config.issuer { validation.set_issuer(&[issuer]); } if let Some(ref audience) = config.audience { validation.set_audience(&[audience]); } Self { config, encoding_key, decoding_key, validation, } } /// Create an access JWT token for the given user. pub fn create_token(&self, user: &User) -> Result { let now = now_secs(); let expiry = now + (self.config.expiry_hours as usize * 3600); let claims = JwtClaims { sub: user.id().to_string(), email: user.email().as_ref().to_string(), exp: expiry, iat: now, iss: self.config.issuer.clone(), aud: self.config.audience.clone(), token_type: "access".to_string(), }; encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key) .map_err(JwtError::CreationFailed) } /// Create a refresh JWT token for the given user (longer-lived). pub fn create_refresh_token(&self, user: &User) -> Result { let now = now_secs(); let expiry = now + (self.config.refresh_expiry_days as usize * 86400); let claims = JwtClaims { sub: user.id().to_string(), email: user.email().as_ref().to_string(), exp: expiry, iat: now, iss: self.config.issuer.clone(), aud: self.config.audience.clone(), token_type: "refresh".to_string(), }; encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key) .map_err(JwtError::CreationFailed) } /// Validate a JWT token and return the claims. pub fn validate_token(&self, token: &str) -> Result { let token_data = decode::(token, &self.decoding_key, &self.validation).map_err(|e| { match e.kind() { jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired, jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat, _ => JwtError::ValidationFailed(e.to_string()), } })?; Ok(token_data.claims) } /// Validate an access token — rejects refresh tokens. pub fn validate_access_token(&self, token: &str) -> Result { let claims = self.validate_token(token)?; if claims.token_type != "access" { return Err(JwtError::ValidationFailed( "Not an access token".to_string(), )); } Ok(claims) } /// Validate a refresh token — rejects access tokens. pub fn validate_refresh_token(&self, token: &str) -> Result { let claims = self.validate_token(token)?; if claims.token_type != "refresh" { return Err(JwtError::ValidationFailed( "Not a refresh token".to_string(), )); } Ok(claims) } /// Get the user ID (subject) from a token without full validation. /// /// Useful for logging/debugging — should not be trusted for auth decisions. pub fn decode_unverified(&self, token: &str) -> Result { let mut insecure = Validation::new(Algorithm::HS256); insecure.insecure_disable_signature_validation(); insecure.validate_exp = false; insecure.validate_aud = false; let token_data = decode::(token, &self.decoding_key, &insecure) .map_err(|_| JwtError::InvalidFormat)?; Ok(token_data.claims) } } impl std::fmt::Debug for JwtValidator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("JwtValidator") .field("issuer", &self.config.issuer) .field("audience", &self.config.audience) .field("expiry_hours", &self.config.expiry_hours) .finish_non_exhaustive() } } fn now_secs() -> usize { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs() as usize } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use domain::Email; fn test_user() -> User { let email = Email::new("test@example.com").unwrap(); User::new("test-subject", email) } #[test] fn create_and_validate_token() { let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string()); let validator = JwtValidator::new(config); let user = test_user(); let token = validator.create_token(&user).expect("create token"); let claims = validator.validate_token(&token).expect("validate token"); assert_eq!(claims.sub, user.id().to_string()); assert_eq!(claims.email, "test@example.com"); assert_eq!(claims.token_type, "access"); } #[test] fn refresh_token_round_trip() { let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string()); let validator = JwtValidator::new(config); let user = test_user(); let token = validator.create_refresh_token(&user).unwrap(); let claims = validator.validate_refresh_token(&token).unwrap(); assert_eq!(claims.token_type, "refresh"); // Access-only validation rejects it assert!(validator.validate_access_token(&token).is_err()); } #[test] fn weak_secret_rejected_in_production() { let result = JwtConfig::new("short".to_string(), None, None, None, None, true); assert!(matches!(result, Err(JwtError::WeakSecret { .. }))); } #[test] fn weak_secret_allowed_in_development() { let result = JwtConfig::new("short".to_string(), None, None, None, None, false); assert!(result.is_ok()); } #[test] fn invalid_token_rejected() { let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string()); let validator = JwtValidator::new(config); assert!(validator.validate_token("invalid.token.here").is_err()); } #[test] fn wrong_secret_rejected() { let v1 = JwtValidator::new(JwtConfig::new_unchecked( "secret-one-that-is-long-enough".to_string(), )); let v2 = JwtValidator::new(JwtConfig::new_unchecked( "secret-two-that-is-long-enough".to_string(), )); let user = test_user(); let token = v1.create_token(&user).unwrap(); assert!(v2.validate_token(&token).is_err()); } }