Skip to main content

backbone_auth/
token_generator.rs

1//! Consolidated token generation service
2//!
3//! Provides OTP generation, token hashing (SHA-256), refresh token generation,
4//! constant-time comparison, and JWT operations through a single service.
5
6use anyhow::Result;
7use rand::Rng;
8use sha2::{Digest, Sha256};
9use uuid::Uuid;
10use std::sync::Arc;
11
12use crate::jwt::{JwtService, Claims};
13use crate::traits::RefreshTokenClaims;
14
15/// Consolidated token generation and validation service.
16///
17/// Wraps `JwtService` for JWT operations and provides static methods
18/// for crypto operations that don't require JWT context.
19pub struct TokenGenerator {
20    jwt_service: Arc<JwtService>,
21}
22
23impl TokenGenerator {
24    /// Create a new TokenGenerator wrapping a shared JwtService.
25    pub fn new(jwt_service: Arc<JwtService>) -> Self {
26        Self { jwt_service }
27    }
28
29    // ── Static crypto methods (no JwtService needed) ──
30
31    /// Generate a 6-digit OTP code.
32    pub fn generate_otp() -> String {
33        let code: u32 = rand::thread_rng().gen_range(100_000..1_000_000);
34        format!("{:06}", code)
35    }
36
37    /// SHA-256 hash a token/OTP before storing in the database.
38    pub fn hash_token(token: &str) -> String {
39        let result = Sha256::digest(token.as_bytes());
40        result.iter().map(|b| format!("{:02x}", b)).collect()
41    }
42
43    /// Generate a new refresh token string (UUID v4).
44    pub fn generate_refresh_token() -> String {
45        Uuid::new_v4().to_string()
46    }
47
48    /// Constant-time string comparison to prevent timing side-channel attacks.
49    ///
50    /// The XOR-fold comparison runs in constant time for inputs of equal length.
51    /// The length check on line 1 does leak whether lengths differ via timing,
52    /// but this is acceptable because all call sites compare fixed-length values
53    /// (6-digit OTP codes or 64-char SHA-256 hex hashes).
54    ///
55    /// Do NOT use this for variable-length secret comparison.
56    pub fn constant_time_eq(a: &str, b: &str) -> bool {
57        if a.len() != b.len() {
58            return false;
59        }
60        a.bytes()
61            .zip(b.bytes())
62            .fold(0u8, |acc, (x, y)| acc | (x ^ y))
63            == 0
64    }
65
66    // ── JWT methods (delegate to internal JwtService) ──
67
68    /// Create an access token from claims.
69    pub fn create_access_token(&self, claims: &Claims) -> Result<String> {
70        self.jwt_service.create_token(claims)
71    }
72
73    /// Create a refresh token JWT from refresh claims.
74    pub fn create_refresh_token_jwt(&self, claims: &RefreshTokenClaims) -> Result<String> {
75        self.jwt_service.create_refresh_token(claims)
76    }
77
78    /// Validate an access token and return its claims.
79    pub fn validate_access_token(&self, token: &str) -> Result<Claims> {
80        self.jwt_service.validate_token(token)
81    }
82
83    /// Validate a refresh token JWT and return its claims.
84    pub fn validate_refresh_token(&self, token: &str) -> Result<RefreshTokenClaims> {
85        self.jwt_service.validate_refresh_token(token)
86    }
87
88    /// Decode an access token without expiry validation.
89    pub fn decode_access_token(&self, token: &str) -> Result<Claims> {
90        self.jwt_service.decode_token(token)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_generate_otp_length() {
100        let otp = TokenGenerator::generate_otp();
101        assert_eq!(otp.len(), 6);
102        assert!(otp.chars().all(|c| c.is_ascii_digit()));
103    }
104
105    #[test]
106    fn test_hash_token_deterministic() {
107        let hash1 = TokenGenerator::hash_token("test-token");
108        let hash2 = TokenGenerator::hash_token("test-token");
109        assert_eq!(hash1, hash2);
110        assert_eq!(hash1.len(), 64); // SHA-256 hex = 64 chars
111    }
112
113    #[test]
114    fn test_hash_token_different_inputs() {
115        let hash1 = TokenGenerator::hash_token("token-a");
116        let hash2 = TokenGenerator::hash_token("token-b");
117        assert_ne!(hash1, hash2);
118    }
119
120    #[test]
121    fn test_generate_refresh_token_is_uuid() {
122        let token = TokenGenerator::generate_refresh_token();
123        assert!(Uuid::parse_str(&token).is_ok());
124    }
125
126    #[test]
127    fn test_constant_time_eq_same() {
128        assert!(TokenGenerator::constant_time_eq("abc", "abc"));
129    }
130
131    #[test]
132    fn test_constant_time_eq_different() {
133        assert!(!TokenGenerator::constant_time_eq("abc", "abd"));
134    }
135
136    #[test]
137    fn test_constant_time_eq_different_length() {
138        assert!(!TokenGenerator::constant_time_eq("abc", "abcd"));
139    }
140
141    #[test]
142    fn test_jwt_roundtrip() {
143        let jwt = Arc::new(JwtService::new("test-secret-key"));
144        let gen = TokenGenerator::new(jwt);
145
146        let claims = Claims {
147            sub: "user-123".to_string(),
148            exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize,
149            iat: chrono::Utc::now().timestamp() as usize,
150            iss: "backbone".to_string(),
151        };
152
153        let token = gen.create_access_token(&claims).unwrap();
154        let decoded = gen.validate_access_token(&token).unwrap();
155        assert_eq!(decoded.sub, "user-123");
156    }
157}