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
9pub struct AuthService {
14 jwt_secret: String,
15 access_expiry: i64,
16 refresh_expiry: i64,
17 leeway: u64,
20}
21
22impl AuthService {
23 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, }
36 }
37
38 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 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 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 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 pub fn verify_token(&self, token: &str) -> Result<Claims> {
128 self.verify_token_with_leeway(token, self.leeway)
129 }
130
131 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 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, 604800, )
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 assert_ne!(hash, password);
184
185 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 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 assert_eq!(hash1, hash2, "same token should hash to same value");
301
302 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 assert!(
337 claims.iat <= now && claims.iat >= now - 5,
338 "iat should be current timestamp"
339 );
340
341 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}