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        }
106    }
107
108    fn test_config() -> JwtConfig {
109        JwtConfig::new("test-secret-key-for-hs256", Duration::hours(1))
110    }
111
112    #[test]
113    fn test_generate_and_validate_round_trip() {
114        let user = test_user();
115        let roles = vec![RoleName::new("admin")];
116        let permissions = vec![PermissionName::new("read:posts")];
117        let config = test_config();
118
119        let token = generate_token(&user, &roles, &permissions, &config).expect("generate token");
120
121        let claims = validate_token(&token, &config).expect("validate token");
122
123        assert_eq!(claims.sub, user.id.to_string());
124        assert_eq!(claims.email, "alice@example.com");
125    }
126
127    #[test]
128    fn test_expired_token_returns_error() {
129        let user = test_user();
130        let config = test_config();
131
132        // Manually construct an already-expired token (exp in the past)
133        let now = Utc::now();
134        let claims = Claims {
135            sub: user.id.to_string(),
136            email: user.email.as_str().to_string(),
137            roles: vec![],
138            permissions: vec![],
139            exp: (now - Duration::hours(2)).timestamp(),
140            iat: (now - Duration::hours(3)).timestamp(),
141            jti: Uuid::now_v7().to_string(),
142        };
143        let key = EncodingKey::from_secret(config.secret.as_bytes());
144        let token =
145            encode(&Header::new(Algorithm::HS256), &claims, &key).expect("encode expired token");
146
147        let result = validate_token(&token, &config);
148        assert!(result.is_err(), "expired token must be rejected");
149        let err = result.unwrap_err().to_string();
150        assert!(err.contains("jwt"), "error should mention jwt");
151    }
152
153    #[test]
154    fn test_wrong_secret_returns_error() {
155        let user = test_user();
156        let config = test_config();
157        let token = generate_token(&user, &[], &[], &config).expect("generate token");
158
159        let wrong_config = JwtConfig::new("wrong-secret", Duration::hours(1));
160        let result = validate_token(&token, &wrong_config);
161        assert!(result.is_err(), "wrong secret must be rejected");
162    }
163
164    #[test]
165    fn test_claims_contain_roles_and_permissions() {
166        let user = test_user();
167        let roles = vec![RoleName::new("admin"), RoleName::new("editor")];
168        let permissions = vec![
169            PermissionName::new("read:posts"),
170            PermissionName::new("write:posts"),
171        ];
172        let config = test_config();
173
174        let token = generate_token(&user, &roles, &permissions, &config).expect("generate token");
175        let claims = validate_token(&token, &config).expect("validate token");
176
177        assert_eq!(claims.roles, vec!["admin", "editor"]);
178        assert_eq!(claims.permissions, vec!["read:posts", "write:posts"]);
179    }
180
181    #[test]
182    fn test_jti_is_unique_per_token() {
183        let user = test_user();
184        let config = test_config();
185
186        let token1 = generate_token(&user, &[], &[], &config).expect("generate token 1");
187        let token2 = generate_token(&user, &[], &[], &config).expect("generate token 2");
188
189        let claims1 = validate_token(&token1, &config).expect("validate token 1");
190        let claims2 = validate_token(&token2, &config).expect("validate token 2");
191
192        assert_ne!(
193            claims1.jti, claims2.jti,
194            "each token must have a unique jti"
195        );
196    }
197}