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