use chrono::{Duration, Utc};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AuthError;
use crate::types::{PermissionName, RoleName, User};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub email: String,
pub roles: Vec<String>,
pub permissions: Vec<String>,
pub exp: i64,
pub iat: i64,
pub jti: String,
}
pub struct JwtConfig {
pub secret: String,
pub expiry: Duration,
pub issuer: Option<String>,
}
impl JwtConfig {
pub fn new(secret: impl Into<String>, expiry: Duration) -> Self {
Self {
secret: secret.into(),
expiry,
issuer: None,
}
}
}
pub fn generate_token(
user: &User,
roles: &[RoleName],
permissions: &[PermissionName],
config: &JwtConfig,
) -> Result<String, AuthError> {
let now = Utc::now();
let iat = now.timestamp();
let exp = (now + config.expiry).timestamp();
let claims = Claims {
sub: user.id.to_string(),
email: user.email.as_str().to_string(),
roles: roles.iter().map(|r| r.as_str().to_string()).collect(),
permissions: permissions.iter().map(|p| p.as_str().to_string()).collect(),
exp,
iat,
jti: Uuid::now_v7().to_string(),
};
let key = EncodingKey::from_secret(config.secret.as_bytes());
encode(&Header::new(Algorithm::HS256), &claims, &key).map_err(|e| AuthError::Jwt(e.to_string()))
}
pub fn validate_token(token: &str, config: &JwtConfig) -> Result<Claims, AuthError> {
let key = DecodingKey::from_secret(config.secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 0;
decode::<Claims>(token, &key, &validation)
.map(|data| data.claims)
.map_err(|e| AuthError::Jwt(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Email, PermissionName, RoleName, User, UserId};
fn test_user() -> User {
User {
id: UserId::new(),
email: Email::new_unchecked("alice@example.com".to_string()),
username: None,
password_hash: None,
email_verified: true,
is_active: true,
created_at: Utc::now(),
updated_at: Utc::now(),
custom_data: None,
}
}
fn test_config() -> JwtConfig {
JwtConfig::new("test-secret-key-for-hs256", Duration::hours(1))
}
#[test]
fn test_generate_and_validate_round_trip() {
let user = test_user();
let roles = vec![RoleName::new("admin")];
let permissions = vec![PermissionName::new("read:posts")];
let config = test_config();
let token = generate_token(&user, &roles, &permissions, &config).expect("generate token");
let claims = validate_token(&token, &config).expect("validate token");
assert_eq!(claims.sub, user.id.to_string());
assert_eq!(claims.email, "alice@example.com");
}
#[test]
fn test_expired_token_returns_error() {
let user = test_user();
let config = test_config();
let now = Utc::now();
let claims = Claims {
sub: user.id.to_string(),
email: user.email.as_str().to_string(),
roles: vec![],
permissions: vec![],
exp: (now - Duration::hours(2)).timestamp(),
iat: (now - Duration::hours(3)).timestamp(),
jti: Uuid::now_v7().to_string(),
};
let key = EncodingKey::from_secret(config.secret.as_bytes());
let token =
encode(&Header::new(Algorithm::HS256), &claims, &key).expect("encode expired token");
let result = validate_token(&token, &config);
assert!(result.is_err(), "expired token must be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("jwt"), "error should mention jwt");
}
#[test]
fn test_wrong_secret_returns_error() {
let user = test_user();
let config = test_config();
let token = generate_token(&user, &[], &[], &config).expect("generate token");
let wrong_config = JwtConfig::new("wrong-secret", Duration::hours(1));
let result = validate_token(&token, &wrong_config);
assert!(result.is_err(), "wrong secret must be rejected");
}
#[test]
fn test_claims_contain_roles_and_permissions() {
let user = test_user();
let roles = vec![RoleName::new("admin"), RoleName::new("editor")];
let permissions = vec![
PermissionName::new("read:posts"),
PermissionName::new("write:posts"),
];
let config = test_config();
let token = generate_token(&user, &roles, &permissions, &config).expect("generate token");
let claims = validate_token(&token, &config).expect("validate token");
assert_eq!(claims.roles, vec!["admin", "editor"]);
assert_eq!(claims.permissions, vec!["read:posts", "write:posts"]);
}
#[test]
fn test_jti_is_unique_per_token() {
let user = test_user();
let config = test_config();
let token1 = generate_token(&user, &[], &[], &config).expect("generate token 1");
let token2 = generate_token(&user, &[], &[], &config).expect("generate token 2");
let claims1 = validate_token(&token1, &config).expect("validate token 1");
let claims2 = validate_token(&token2, &config).expect("validate token 2");
assert_ne!(
claims1.jti, claims2.jti,
"each token must have a unique jti"
);
}
}