Skip to main content

allowthem_core/
jwt.rs

1use chrono::{Duration, Utc};
2use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::error::AuthError;
7use crate::types::{PermissionName, RoleName, User};
8
9/// Claims embedded in a generated JWT.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct Claims {
12    /// Subject — the user's UUID as a string.
13    pub sub: String,
14    /// User's email address.
15    pub email: String,
16    /// Role names assigned to the user.
17    pub roles: Vec<String>,
18    /// Permission names available to the user.
19    pub permissions: Vec<String>,
20    /// Expiry time (Unix timestamp seconds).
21    pub exp: i64,
22    /// Issued-at time (Unix timestamp seconds).
23    pub iat: i64,
24    /// JWT ID — UUIDv7, unique per token.
25    pub jti: String,
26}
27
28/// Configuration for JWT generation and validation.
29pub struct JwtConfig {
30    /// Symmetric secret used for HS256 signing.
31    pub secret: String,
32    /// How long generated tokens are valid.
33    pub expiry: Duration,
34    /// Optional issuer claim (`iss`). Not validated if `None`.
35    pub issuer: Option<String>,
36}
37
38impl JwtConfig {
39    pub fn new(secret: impl Into<String>, expiry: Duration) -> Self {
40        Self {
41            secret: secret.into(),
42            expiry,
43            issuer: None,
44        }
45    }
46}
47
48/// Generate an HS256-signed JWT for the given user.
49///
50/// The token contains the user's id, email, roles, and permissions as claims,
51/// plus standard `exp`, `iat`, and a unique `jti`.
52pub fn generate_token(
53    user: &User,
54    roles: &[RoleName],
55    permissions: &[PermissionName],
56    config: &JwtConfig,
57) -> Result<String, AuthError> {
58    let now = Utc::now();
59    let iat = now.timestamp();
60    let exp = (now + config.expiry).timestamp();
61
62    let claims = Claims {
63        sub: user.id.to_string(),
64        email: user.email.as_str().to_string(),
65        roles: roles.iter().map(|r| r.as_str().to_string()).collect(),
66        permissions: permissions.iter().map(|p| p.as_str().to_string()).collect(),
67        exp,
68        iat,
69        jti: Uuid::now_v7().to_string(),
70    };
71
72    let key = EncodingKey::from_secret(config.secret.as_bytes());
73    encode(&Header::new(Algorithm::HS256), &claims, &key).map_err(|e| AuthError::Jwt(e.to_string()))
74}
75
76/// Validate an HS256 JWT and return its parsed claims.
77///
78/// Returns `AuthError::Jwt` if the signature is invalid, the token is expired,
79/// or the token is otherwise malformed. Leeway is set to zero so expiry is
80/// checked exactly.
81pub fn validate_token(token: &str, config: &JwtConfig) -> Result<Claims, AuthError> {
82    let key = DecodingKey::from_secret(config.secret.as_bytes());
83    let mut validation = Validation::new(Algorithm::HS256);
84    validation.leeway = 0;
85    decode::<Claims>(token, &key, &validation)
86        .map(|data| data.claims)
87        .map_err(|e| AuthError::Jwt(e.to_string()))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::types::{Email, PermissionName, RoleName, User, UserId};
94
95    fn test_user() -> User {
96        User {
97            id: UserId::new(),
98            email: Email::new_unchecked("alice@example.com".to_string()),
99            username: None,
100            password_hash: None,
101            email_verified: true,
102            is_active: true,
103            created_at: Utc::now(),
104            updated_at: Utc::now(),
105            custom_data: None,
106        }
107    }
108
109    fn test_config() -> JwtConfig {
110        JwtConfig::new("test-secret-key-for-hs256", Duration::hours(1))
111    }
112
113    #[test]
114    fn test_generate_and_validate_round_trip() {
115        let user = test_user();
116        let roles = vec![RoleName::new("admin")];
117        let permissions = vec![PermissionName::new("read:posts")];
118        let config = test_config();
119
120        let token = generate_token(&user, &roles, &permissions, &config).expect("generate token");
121
122        let claims = validate_token(&token, &config).expect("validate token");
123
124        assert_eq!(claims.sub, user.id.to_string());
125        assert_eq!(claims.email, "alice@example.com");
126    }
127
128    #[test]
129    fn test_expired_token_returns_error() {
130        let user = test_user();
131        let config = test_config();
132
133        // Manually construct an already-expired token (exp in the past)
134        let now = Utc::now();
135        let claims = Claims {
136            sub: user.id.to_string(),
137            email: user.email.as_str().to_string(),
138            roles: vec![],
139            permissions: vec![],
140            exp: (now - Duration::hours(2)).timestamp(),
141            iat: (now - Duration::hours(3)).timestamp(),
142            jti: Uuid::now_v7().to_string(),
143        };
144        let key = EncodingKey::from_secret(config.secret.as_bytes());
145        let token =
146            encode(&Header::new(Algorithm::HS256), &claims, &key).expect("encode expired token");
147
148        let result = validate_token(&token, &config);
149        assert!(result.is_err(), "expired token must be rejected");
150        let err = result.unwrap_err().to_string();
151        assert!(err.contains("jwt"), "error should mention jwt");
152    }
153
154    #[test]
155    fn test_wrong_secret_returns_error() {
156        let user = test_user();
157        let config = test_config();
158        let token = generate_token(&user, &[], &[], &config).expect("generate token");
159
160        let wrong_config = JwtConfig::new("wrong-secret", Duration::hours(1));
161        let result = validate_token(&token, &wrong_config);
162        assert!(result.is_err(), "wrong secret must be rejected");
163    }
164
165    #[test]
166    fn test_claims_contain_roles_and_permissions() {
167        let user = test_user();
168        let roles = vec![RoleName::new("admin"), RoleName::new("editor")];
169        let permissions = vec![
170            PermissionName::new("read:posts"),
171            PermissionName::new("write:posts"),
172        ];
173        let config = test_config();
174
175        let token = generate_token(&user, &roles, &permissions, &config).expect("generate token");
176        let claims = validate_token(&token, &config).expect("validate token");
177
178        assert_eq!(claims.roles, vec!["admin", "editor"]);
179        assert_eq!(claims.permissions, vec!["read:posts", "write:posts"]);
180    }
181
182    #[test]
183    fn test_jti_is_unique_per_token() {
184        let user = test_user();
185        let config = test_config();
186
187        let token1 = generate_token(&user, &[], &[], &config).expect("generate token 1");
188        let token2 = generate_token(&user, &[], &[], &config).expect("generate token 2");
189
190        let claims1 = validate_token(&token1, &config).expect("validate token 1");
191        let claims2 = validate_token(&token2, &config).expect("validate token 2");
192
193        assert_ne!(
194            claims1.jti, claims2.jti,
195            "each token must have a unique jti"
196        );
197    }
198}