1use chrono::Utc;
8use hex::encode;
9use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use uuid::Uuid;
13
14use crate::error::AuthError;
15
16const ACCESS_TOKEN_TYPE: &str = "access";
17const REFRESH_TOKEN_TYPE: &str = "refresh";
18
19#[derive(Debug, Clone)]
35pub struct JwtConfig {
36 pub secret: String,
38 pub access_token_ttl_secs: i64,
40 pub refresh_token_ttl_secs: i64,
42 pub cookie_domain: Option<String>,
44 pub cookie_secure: bool,
46}
47
48#[derive(Debug, Serialize, Deserialize, Clone)]
50pub struct AccessTokenClaims {
51 pub sub: Uuid,
53 pub jti: String,
55 pub iat: i64,
57 pub exp: i64,
59 pub typ: String,
61 pub user_id: Uuid,
63 pub username: String,
65 pub is_admin: bool,
67 #[serde(default)]
69 pub ver: i64,
70}
71
72#[derive(Debug, Serialize, Deserialize, Clone)]
74pub struct RefreshTokenClaims {
75 pub sub: Uuid,
77 pub jti: String,
79 pub iat: i64,
81 pub exp: i64,
83 pub typ: String,
85 pub user_id: Uuid,
87 pub username: String,
89 pub is_admin: bool,
91 #[serde(default)]
93 pub ver: i64,
94}
95
96pub struct AccessToken(pub String);
98
99impl AccessToken {
100 pub fn for_user(
106 user_id: Uuid,
107 username: &str,
108 is_admin: bool,
109 config: &JwtConfig,
110 ) -> Result<Self, AuthError> {
111 Self::for_user_with_version(user_id, username, is_admin, 0, config)
112 }
113
114 pub fn for_user_with_version(
146 user_id: Uuid,
147 username: &str,
148 is_admin: bool,
149 token_version: i64,
150 config: &JwtConfig,
151 ) -> Result<Self, AuthError> {
152 let now = Utc::now().timestamp();
153 let claims = AccessTokenClaims {
154 sub: user_id,
155 jti: Uuid::now_v7().to_string(),
156 iat: now,
157 exp: now + config.access_token_ttl_secs,
158 typ: ACCESS_TOKEN_TYPE.to_string(),
159 user_id,
160 username: username.to_string(),
161 is_admin,
162 ver: token_version,
163 };
164 let token = jsonwebtoken::encode(
165 &Header::default(),
166 &claims,
167 &EncodingKey::from_secret(config.secret.as_bytes()),
168 )?;
169 Ok(Self(token))
170 }
171
172 pub fn decode(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, AuthError> {
178 let token_data = jsonwebtoken::decode::<AccessTokenClaims>(
179 token,
180 &DecodingKey::from_secret(config.secret.as_bytes()),
181 &Validation::default(),
182 )?;
183 if token_data.claims.typ != ACCESS_TOKEN_TYPE {
184 return Err(AuthError::InvalidToken);
185 }
186 Ok(token_data.claims)
187 }
188}
189
190pub struct RefreshToken(pub String);
192
193impl RefreshToken {
194 pub fn for_user(
200 user_id: Uuid,
201 username: &str,
202 is_admin: bool,
203 config: &JwtConfig,
204 ) -> Result<Self, AuthError> {
205 Self::for_user_with_version(user_id, username, is_admin, 0, config)
206 }
207
208 pub fn for_user_with_version(
240 user_id: Uuid,
241 username: &str,
242 is_admin: bool,
243 token_version: i64,
244 config: &JwtConfig,
245 ) -> Result<Self, AuthError> {
246 let now = Utc::now().timestamp();
247 let claims = RefreshTokenClaims {
248 sub: user_id,
249 jti: Uuid::now_v7().to_string(),
250 iat: now,
251 exp: now + config.refresh_token_ttl_secs,
252 typ: REFRESH_TOKEN_TYPE.to_string(),
253 user_id,
254 username: username.to_string(),
255 is_admin,
256 ver: token_version,
257 };
258 let token = jsonwebtoken::encode(
259 &Header::default(),
260 &claims,
261 &EncodingKey::from_secret(config.secret.as_bytes()),
262 )?;
263 Ok(Self(token))
264 }
265
266 pub fn decode(token: &str, config: &JwtConfig) -> Result<RefreshTokenClaims, AuthError> {
272 let token_data = jsonwebtoken::decode::<RefreshTokenClaims>(
273 token,
274 &DecodingKey::from_secret(config.secret.as_bytes()),
275 &Validation::default(),
276 )?;
277 if token_data.claims.typ != REFRESH_TOKEN_TYPE {
278 return Err(AuthError::InvalidToken);
279 }
280 Ok(token_data.claims)
281 }
282}
283
284pub fn token_hash(raw: &str) -> String {
299 encode(Sha256::digest(raw.as_bytes()))
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn test_config() -> JwtConfig {
307 JwtConfig {
308 secret: "test-secret-key-for-unit-tests".to_string(),
309 access_token_ttl_secs: 900,
310 refresh_token_ttl_secs: 604800,
311 cookie_domain: None,
312 cookie_secure: false,
313 }
314 }
315
316 #[test]
317 fn access_token_round_trip() {
318 let config = test_config();
319 let user_id = Uuid::now_v7();
320 let token = AccessToken::for_user(user_id, "testuser", false, &config).unwrap();
321 let claims = AccessToken::decode(&token.0, &config).unwrap();
322
323 assert_eq!(claims.user_id, user_id);
324 assert_eq!(claims.username, "testuser");
325 assert!(!claims.is_admin);
326 assert_eq!(claims.sub, user_id);
327 assert_eq!(claims.typ, "access");
328 }
329
330 #[test]
331 fn access_token_admin_flag() {
332 let config = test_config();
333 let user_id = Uuid::now_v7();
334 let token = AccessToken::for_user(user_id, "admin", true, &config).unwrap();
335 let claims = AccessToken::decode(&token.0, &config).unwrap();
336
337 assert!(claims.is_admin);
338 }
339
340 #[test]
341 fn access_token_expiry_from_config() {
342 let config = test_config();
343 let user_id = Uuid::now_v7();
344 let before = Utc::now().timestamp();
345 let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
346 let claims = AccessToken::decode(&token.0, &config).unwrap();
347
348 assert!(claims.iat >= before);
349 assert_eq!(claims.exp - claims.iat, config.access_token_ttl_secs);
350 }
351
352 #[test]
353 fn decode_invalid_token_fails() {
354 let config = test_config();
355 let result = AccessToken::decode("not.a.valid.token", &config);
356 assert!(result.is_err());
357 }
358
359 #[test]
360 fn decode_tampered_token_fails() {
361 let config = test_config();
362 let user_id = Uuid::now_v7();
363 let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
364 let tampered = format!("{}x", &token.0[..token.0.len() - 1]);
365 assert!(AccessToken::decode(&tampered, &config).is_err());
366 }
367
368 #[test]
369 fn expired_token_fails() {
370 let config = test_config();
371 let user_id = Uuid::now_v7();
372 let claims = AccessTokenClaims {
373 sub: user_id,
374 jti: Uuid::now_v7().to_string(),
375 iat: Utc::now().timestamp() - 7200,
376 exp: Utc::now().timestamp() - 3600,
377 typ: "access".to_string(),
378 user_id,
379 username: "expired".to_string(),
380 is_admin: false,
381 ver: 0,
382 };
383 let token_str = jsonwebtoken::encode(
384 &Header::default(),
385 &claims,
386 &EncodingKey::from_secret(config.secret.as_bytes()),
387 )
388 .unwrap();
389 assert!(AccessToken::decode(&token_str, &config).is_err());
390 }
391
392 #[test]
393 fn refresh_token_round_trip() {
394 let config = test_config();
395 let user_id = Uuid::now_v7();
396 let token = RefreshToken::for_user(user_id, "testuser", false, &config).unwrap();
397 let claims = RefreshToken::decode(&token.0, &config).unwrap();
398
399 assert_eq!(claims.user_id, user_id);
400 assert_eq!(claims.username, "testuser");
401 assert_eq!(claims.typ, "refresh");
402 assert_eq!(claims.exp - claims.iat, config.refresh_token_ttl_secs);
403 }
404
405 #[test]
406 fn refresh_token_cannot_be_used_as_access() {
407 let config = test_config();
408 let user_id = Uuid::now_v7();
409 let refresh = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
410 assert!(AccessToken::decode(&refresh.0, &config).is_err());
411 }
412
413 #[test]
414 fn access_token_cannot_be_used_as_refresh() {
415 let config = test_config();
416 let user_id = Uuid::now_v7();
417 let access = AccessToken::for_user(user_id, "user", false, &config).unwrap();
418 assert!(RefreshToken::decode(&access.0, &config).is_err());
419 }
420
421 #[test]
422 fn decode_with_wrong_secret_fails() {
423 let config = test_config();
424 let user_id = Uuid::now_v7();
425 let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
426
427 let other_config = JwtConfig {
428 secret: "different-secret".to_string(),
429 ..test_config()
430 };
431 assert!(AccessToken::decode(&token.0, &other_config).is_err());
432 }
433
434 #[test]
435 fn refresh_token_decode_with_wrong_secret_fails() {
436 let config = test_config();
437 let user_id = Uuid::now_v7();
438 let token = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
439
440 let other_config = JwtConfig {
441 secret: "different-secret".to_string(),
442 ..test_config()
443 };
444 assert!(RefreshToken::decode(&token.0, &other_config).is_err());
445 }
446
447 #[test]
448 fn access_token_claims_serde_roundtrip() {
449 let user_id = Uuid::now_v7();
450 let claims = AccessTokenClaims {
451 sub: user_id,
452 jti: "jti-123".to_string(),
453 iat: 1000,
454 exp: 2000,
455 typ: "access".to_string(),
456 user_id,
457 username: "alice".to_string(),
458 is_admin: true,
459 ver: 0,
460 };
461 let json = serde_json::to_string(&claims).unwrap();
462 let decoded: AccessTokenClaims = serde_json::from_str(&json).unwrap();
463
464 assert_eq!(decoded.sub, user_id);
465 assert_eq!(decoded.jti, "jti-123");
466 assert_eq!(decoded.typ, "access");
467 assert_eq!(decoded.username, "alice");
468 assert!(decoded.is_admin);
469 }
470
471 #[test]
472 fn refresh_token_claims_serde_roundtrip() {
473 let user_id = Uuid::now_v7();
474 let claims = RefreshTokenClaims {
475 sub: user_id,
476 jti: "jti-456".to_string(),
477 iat: 1000,
478 exp: 2000,
479 typ: "refresh".to_string(),
480 user_id,
481 username: "bob".to_string(),
482 is_admin: false,
483 ver: 0,
484 };
485 let json = serde_json::to_string(&claims).unwrap();
486 let decoded: RefreshTokenClaims = serde_json::from_str(&json).unwrap();
487
488 assert_eq!(decoded.sub, user_id);
489 assert_eq!(decoded.typ, "refresh");
490 assert_eq!(decoded.username, "bob");
491 assert!(!decoded.is_admin);
492 }
493
494 #[test]
495 fn refresh_token_expiry_from_config() {
496 let config = test_config();
497 let user_id = Uuid::now_v7();
498 let before = Utc::now().timestamp();
499 let token = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
500 let claims = RefreshToken::decode(&token.0, &config).unwrap();
501
502 assert!(claims.iat >= before);
503 assert_eq!(claims.exp - claims.iat, config.refresh_token_ttl_secs);
504 }
505
506 #[test]
507 fn refresh_token_admin_flag() {
508 let config = test_config();
509 let user_id = Uuid::now_v7();
510 let token = RefreshToken::for_user(user_id, "admin", true, &config).unwrap();
511 let claims = RefreshToken::decode(&token.0, &config).unwrap();
512
513 assert!(claims.is_admin);
514 }
515
516 #[test]
517 fn access_token_unique_jti_per_token() {
518 let config = test_config();
519 let user_id = Uuid::now_v7();
520 let t1 = AccessToken::for_user(user_id, "user", false, &config).unwrap();
521 let t2 = AccessToken::for_user(user_id, "user", false, &config).unwrap();
522
523 let c1 = AccessToken::decode(&t1.0, &config).unwrap();
524 let c2 = AccessToken::decode(&t2.0, &config).unwrap();
525 assert_ne!(c1.jti, c2.jti);
526 }
527
528 #[test]
529 fn jwt_config_clone() {
530 let config = test_config();
531 let cloned = config.clone();
532 assert_eq!(cloned.secret, config.secret);
533 assert_eq!(cloned.access_token_ttl_secs, config.access_token_ttl_secs);
534 assert_eq!(cloned.refresh_token_ttl_secs, config.refresh_token_ttl_secs);
535 assert_eq!(cloned.cookie_domain, config.cookie_domain);
536 assert_eq!(cloned.cookie_secure, config.cookie_secure);
537 }
538
539 #[test]
540 fn jwt_config_with_cookie_domain() {
541 let config = JwtConfig {
542 cookie_domain: Some(".example.com".to_string()),
543 cookie_secure: true,
544 ..test_config()
545 };
546 assert_eq!(config.cookie_domain.as_deref(), Some(".example.com"));
547 assert!(config.cookie_secure);
548 }
549
550 #[test]
551 fn access_token_carries_version() {
552 let config = test_config();
553 let user_id = Uuid::now_v7();
554 let token = AccessToken::for_user_with_version(user_id, "user", true, 7, &config).unwrap();
555 let claims = AccessToken::decode(&token.0, &config).unwrap();
556
557 assert_eq!(claims.ver, 7);
558 assert_eq!(claims.user_id, user_id);
559 assert!(claims.is_admin);
560
561 let legacy = AccessToken::for_user(user_id, "user", false, &config).unwrap();
562 assert_eq!(AccessToken::decode(&legacy.0, &config).unwrap().ver, 0);
563 }
564
565 #[test]
566 fn refresh_token_carries_version() {
567 let config = test_config();
568 let user_id = Uuid::now_v7();
569 let token =
570 RefreshToken::for_user_with_version(user_id, "user", false, 4, &config).unwrap();
571 let claims = RefreshToken::decode(&token.0, &config).unwrap();
572
573 assert_eq!(claims.ver, 4);
574 assert_eq!(claims.user_id, user_id);
575
576 let legacy = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
577 assert_eq!(RefreshToken::decode(&legacy.0, &config).unwrap().ver, 0);
578 }
579
580 #[derive(Serialize)]
582 struct LegacyClaims {
583 sub: Uuid,
584 jti: String,
585 iat: i64,
586 exp: i64,
587 typ: String,
588 user_id: Uuid,
589 username: String,
590 is_admin: bool,
591 }
592
593 #[test]
594 fn token_without_ver_claim_decodes_as_zero() {
595 let config = test_config();
596 let user_id = Uuid::now_v7();
597 let now = Utc::now().timestamp();
598 let legacy = |typ: &str| {
599 jsonwebtoken::encode(
600 &Header::default(),
601 &LegacyClaims {
602 sub: user_id,
603 jti: Uuid::now_v7().to_string(),
604 iat: now,
605 exp: now + 600,
606 typ: typ.to_string(),
607 user_id,
608 username: "legacy".to_string(),
609 is_admin: false,
610 },
611 &EncodingKey::from_secret(config.secret.as_bytes()),
612 )
613 .unwrap()
614 };
615
616 let access = AccessToken::decode(&legacy("access"), &config).unwrap();
617 assert_eq!(access.ver, 0);
618 assert_eq!(access.username, "legacy");
619
620 let refresh = RefreshToken::decode(&legacy("refresh"), &config).unwrap();
621 assert_eq!(refresh.ver, 0);
622 }
623
624 #[test]
625 fn token_hash_is_stable_sha256_hex() {
626 let hash = token_hash("a.b.c");
627
628 assert_eq!(hash.len(), 64);
629 assert!(hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
630 assert_eq!(hash, token_hash("a.b.c"));
631 assert_ne!(hash, token_hash("a.b.d"));
632 assert_eq!(
634 token_hash(""),
635 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
636 );
637 }
638}