Skip to main content

ares/auth/
jwt.rs

1use crate::types::{AppError, Claims, Result, TokenResponse};
2use argon2::{
3    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
4    Argon2,
5};
6use chrono::{Duration, Utc};
7use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
8
9/// Authentication service for JWT token management and password hashing.
10///
11/// Provides secure password hashing using Argon2id and JWT token
12/// generation/verification using HS256.
13pub struct AuthService {
14    jwt_secret: String,
15    access_expiry: i64,
16    refresh_expiry: i64,
17    /// Leeway in seconds for token expiration validation (default: 60)
18    /// This accounts for clock skew between servers.
19    leeway: u64,
20}
21
22impl AuthService {
23    /// Creates a new AuthService with the given configuration.
24    ///
25    /// # Arguments
26    /// * `jwt_secret` - Secret key for signing JWTs (should be at least 32 chars)
27    /// * `access_expiry` - Access token validity in seconds
28    /// * `refresh_expiry` - Refresh token validity in seconds
29    pub fn new(jwt_secret: String, access_expiry: i64, refresh_expiry: i64) -> Self {
30        Self {
31            jwt_secret,
32            access_expiry,
33            refresh_expiry,
34            leeway: 60, // Default 60-second leeway for clock skew
35        }
36    }
37
38    /// Creates a new AuthService with custom leeway for token validation.
39    ///
40    /// # Arguments
41    /// * `jwt_secret` - Secret key for signing JWTs (should be at least 32 chars)
42    /// * `access_expiry` - Access token validity in seconds
43    /// * `refresh_expiry` - Refresh token validity in seconds
44    /// * `leeway` - Leeway in seconds for expiration checks (0 for strict)
45    pub fn with_leeway(
46        jwt_secret: String,
47        access_expiry: i64,
48        refresh_expiry: i64,
49        leeway: u64,
50    ) -> Self {
51        Self {
52            jwt_secret,
53            access_expiry,
54            refresh_expiry,
55            leeway,
56        }
57    }
58
59    /// Hashes a password using Argon2id.
60    ///
61    /// Returns a PHC-formatted hash string.
62    pub fn hash_password(&self, password: &str) -> Result<String> {
63        let salt = SaltString::generate(&mut OsRng);
64        let argon2 = Argon2::default();
65
66        argon2
67            .hash_password(password.as_bytes(), &salt)
68            .map(|hash| hash.to_string())
69            .map_err(|e| AppError::Auth(format!("Failed to hash password: {}", e)))
70    }
71
72    /// Verifies a password against an Argon2 hash.
73    pub fn verify_password(&self, password: &str, hash: &str) -> Result<bool> {
74        let parsed_hash = PasswordHash::new(hash)
75            .map_err(|e| AppError::Auth(format!("Invalid password hash: {}", e)))?;
76
77        Ok(Argon2::default()
78            .verify_password(password.as_bytes(), &parsed_hash)
79            .is_ok())
80    }
81
82    /// Generates access and refresh tokens for a user.
83    pub fn generate_tokens(&self, user_id: &str, email: &str) -> Result<TokenResponse> {
84        let access_token = self.generate_access_token(user_id, email)?;
85        let refresh_token = self.generate_refresh_token(user_id, email)?;
86
87        Ok(TokenResponse {
88            access_token,
89            refresh_token,
90            expires_in: self.access_expiry,
91        })
92    }
93
94    fn generate_access_token(&self, user_id: &str, email: &str) -> Result<String> {
95        let claims = Claims {
96            sub: user_id.to_string(),
97            email: email.to_string(),
98            exp: (Utc::now() + Duration::seconds(self.access_expiry)).timestamp() as usize,
99            iat: Utc::now().timestamp() as usize,
100        };
101
102        encode(
103            &Header::new(Algorithm::HS256),
104            &claims,
105            &EncodingKey::from_secret(self.jwt_secret.as_bytes()),
106        )
107        .map_err(|e| AppError::Auth(format!("Failed to generate token: {}", e)))
108    }
109
110    fn generate_refresh_token(&self, user_id: &str, email: &str) -> Result<String> {
111        let claims = Claims {
112            sub: user_id.to_string(),
113            email: email.to_string(),
114            exp: (Utc::now() + Duration::seconds(self.refresh_expiry)).timestamp() as usize,
115            iat: Utc::now().timestamp() as usize,
116        };
117
118        encode(
119            &Header::new(Algorithm::HS256),
120            &claims,
121            &EncodingKey::from_secret(self.jwt_secret.as_bytes()),
122        )
123        .map_err(|e| AppError::Auth(format!("Failed to generate refresh token: {}", e)))
124    }
125
126    /// Verifies a JWT token and returns the claims.
127    pub fn verify_token(&self, token: &str) -> Result<Claims> {
128        self.verify_token_with_leeway(token, self.leeway)
129    }
130
131    /// Verifies a JWT token with a custom leeway (in seconds) for expiration checks.
132    ///
133    /// The leeway accounts for clock skew between servers. Default is 60 seconds.
134    /// Use leeway of 0 for strict expiration checking (e.g., in tests).
135    pub fn verify_token_with_leeway(&self, token: &str, leeway: u64) -> Result<Claims> {
136        let mut validation = Validation::new(Algorithm::HS256);
137        validation.leeway = leeway;
138
139        decode::<Claims>(
140            token,
141            &DecodingKey::from_secret(self.jwt_secret.as_bytes()),
142            &validation,
143        )
144        .map(|data| data.claims)
145        .map_err(|e| AppError::Auth(format!("Invalid token: {}", e)))
146    }
147
148    /// Hashes a token using SHA256 for secure storage.
149    pub fn hash_token(&self, token: &str) -> String {
150        use sha2::{Digest, Sha256};
151        let mut hasher = Sha256::new();
152        hasher.update(token.as_bytes());
153        let result = hasher.finalize();
154        result
155            .iter()
156            .map(|b| format!("{:02x}", b))
157            .collect::<String>()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn create_test_service() -> AuthService {
166        AuthService::new(
167            "test-secret-key-that-is-at-least-32-chars".to_string(),
168            900,    // 15 minutes
169            604800, // 7 days
170        )
171    }
172
173    #[test]
174    fn test_password_hashing() {
175        let service = create_test_service();
176        let password = "test_password_123";
177
178        let hash = service
179            .hash_password(password)
180            .expect("should hash password");
181
182        // Hash should not equal the original password
183        assert_ne!(hash, password);
184
185        // Hash should be in PHC format (starts with $argon2)
186        assert!(hash.starts_with("$argon2"), "hash should be in PHC format");
187    }
188
189    #[test]
190    fn test_password_verification_success() {
191        let service = create_test_service();
192        let password = "secure_password_456";
193
194        let hash = service
195            .hash_password(password)
196            .expect("should hash password");
197        let is_valid = service
198            .verify_password(password, &hash)
199            .expect("should verify");
200
201        assert!(is_valid, "correct password should verify successfully");
202    }
203
204    #[test]
205    fn test_password_verification_failure() {
206        let service = create_test_service();
207        let password = "correct_password";
208        let wrong_password = "wrong_password";
209
210        let hash = service
211            .hash_password(password)
212            .expect("should hash password");
213        let is_valid = service
214            .verify_password(wrong_password, &hash)
215            .expect("should verify");
216
217        assert!(!is_valid, "wrong password should fail verification");
218    }
219
220    #[test]
221    fn test_token_generation() {
222        let service = create_test_service();
223        let user_id = "user-123";
224        let email = "test@example.com";
225
226        let tokens = service
227            .generate_tokens(user_id, email)
228            .expect("should generate tokens");
229
230        assert!(
231            !tokens.access_token.is_empty(),
232            "access token should not be empty"
233        );
234        assert!(
235            !tokens.refresh_token.is_empty(),
236            "refresh token should not be empty"
237        );
238        assert_eq!(
239            tokens.expires_in, 900,
240            "expires_in should match configured access expiry"
241        );
242
243        // Tokens should be different
244        assert_ne!(
245            tokens.access_token, tokens.refresh_token,
246            "access and refresh tokens should differ"
247        );
248    }
249
250    #[test]
251    fn test_token_verification_success() {
252        let service = create_test_service();
253        let user_id = "user-456";
254        let email = "user@test.com";
255
256        let tokens = service
257            .generate_tokens(user_id, email)
258            .expect("should generate tokens");
259        let claims = service
260            .verify_token(&tokens.access_token)
261            .expect("should verify token");
262
263        assert_eq!(claims.sub, user_id, "subject should match user_id");
264        assert_eq!(claims.email, email, "email should match");
265    }
266
267    #[test]
268    fn test_token_verification_invalid_token() {
269        let service = create_test_service();
270
271        let result = service.verify_token("invalid.token.here");
272
273        assert!(result.is_err(), "invalid token should fail verification");
274    }
275
276    #[test]
277    fn test_token_verification_wrong_secret() {
278        let service1 =
279            AuthService::new("secret-one-that-is-32-chars-long".to_string(), 900, 604800);
280        let service2 =
281            AuthService::new("secret-two-that-is-32-chars-long".to_string(), 900, 604800);
282
283        let tokens = service1
284            .generate_tokens("user-789", "test@example.com")
285            .expect("should generate");
286        let result = service2.verify_token(&tokens.access_token);
287
288        assert!(result.is_err(), "token from different secret should fail");
289    }
290
291    #[test]
292    fn test_hash_token() {
293        let service = create_test_service();
294        let token = "some-refresh-token";
295
296        let hash1 = service.hash_token(token);
297        let hash2 = service.hash_token(token);
298
299        // Same token should produce same hash
300        assert_eq!(hash1, hash2, "same token should hash to same value");
301
302        // Hash should be a hex string (64 chars for SHA256)
303        assert_eq!(hash1.len(), 64, "SHA256 hash should be 64 hex characters");
304        assert!(
305            hash1.chars().all(|c| c.is_ascii_hexdigit()),
306            "hash should be hex"
307        );
308    }
309
310    #[test]
311    fn test_hash_token_different_inputs() {
312        let service = create_test_service();
313
314        let hash1 = service.hash_token("token-a");
315        let hash2 = service.hash_token("token-b");
316
317        assert_ne!(
318            hash1, hash2,
319            "different tokens should have different hashes"
320        );
321    }
322
323    #[test]
324    fn test_claims_expiration() {
325        let service = create_test_service();
326        let tokens = service
327            .generate_tokens("user", "user@example.com")
328            .expect("should generate");
329        let claims = service
330            .verify_token(&tokens.access_token)
331            .expect("should verify");
332
333        let now = chrono::Utc::now().timestamp() as usize;
334
335        // iat should be around now
336        assert!(
337            claims.iat <= now && claims.iat >= now - 5,
338            "iat should be current timestamp"
339        );
340
341        // exp should be iat + access_expiry (900 seconds)
342        let expected_exp = claims.iat + 900;
343        assert!(
344            claims.exp >= expected_exp - 5 && claims.exp <= expected_exp + 5,
345            "exp should be iat + 900 seconds"
346        );
347    }
348}