Skip to main content

ferrox_security/
lib.rs

1use argon2::{
2    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
3    Argon2,
4};
5use pasetors::keys::SymmetricKey;
6use pasetors::token::UntrustedToken;
7use pasetors::version4::V4;
8use pasetors::{claims::Claims, claims::ClaimsValidationRules, Local};
9use secrecy::{ExposeSecret, Secret};
10use serde::{Deserialize, Serialize};
11use time::{Duration, OffsetDateTime};
12use ferrox_errors::AppError;
13
14pub mod auth_middleware;
15pub mod dual_token;
16pub mod public_id;
17
18/// Hashes a password securely using Argon2.
19/// The input is wrapped in `Secret<String>` to guarantee it doesn't leak in logs.
20pub fn hash_password(password: Secret<String>) -> Result<String, AppError> {
21    let salt = SaltString::generate(&mut OsRng);
22    let argon2 = Argon2::default();
23
24    let password_hash = argon2
25        .hash_password(password.expose_secret().as_bytes(), &salt)
26        .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
27
28    Ok(password_hash.to_string())
29}
30
31/// Verifies a password against an Argon2 hash.
32pub fn verify_password(password: Secret<String>, hash: &str) -> Result<bool, AppError> {
33    let parsed_hash = PasswordHash::new(hash)
34        .map_err(|e| AppError::ValidationError(format!("Invalid hash format: {}", e)))?;
35
36    let argon2 = Argon2::default();
37    Ok(argon2
38        .verify_password(password.expose_secret().as_bytes(), &parsed_hash)
39        .is_ok())
40}
41
42/// A securely typed PASETO Auth Engine
43pub struct PasetoAuth {
44    key: SymmetricKey<V4>,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct AuthPayload {
49    pub user_id: String,
50    pub role: String,
51}
52
53impl PasetoAuth {
54    pub fn new(secret: Secret<String>) -> Result<Self, AppError> {
55        let key = SymmetricKey::<V4>::from(secret.expose_secret().as_bytes())
56            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
57        Ok(Self { key })
58    }
59
60    /// Generates a local (symmetric) PASETO v4 token
61    pub fn generate_token(&self, payload: &AuthPayload, duration: Duration) -> Result<String, AppError> {
62        let mut claims = Claims::new().map_err(|e| AppError::InternalServerError(Box::new(e)))?;
63        
64        // Add expiration
65        let exp = OffsetDateTime::now_utc() + duration;
66        let exp_iso = exp.format(&time::format_description::well_known::Rfc3339)
67            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
68        claims.expiration(&exp_iso).map_err(|e| AppError::InternalServerError(Box::new(e)))?;
69
70        // Add custom payload
71        let user_id_val = serde_json::to_value(&payload.user_id)
72            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
73        claims.add_additional("user_id", user_id_val)
74            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
75            
76        let role_val = serde_json::to_value(&payload.role)
77            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
78        claims.add_additional("role", role_val)
79            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
80
81        // Encrypt and sign
82        pasetors::local::encrypt(&self.key, &claims, None, Some(b"ferrox-auth-footer"))
83            .map_err(|e| AppError::InternalServerError(Box::new(e)))
84    }
85
86    /// Validates a PASETO v4 token and returns the payload if successful
87    pub fn validate_token(&self, token: &str) -> Result<AuthPayload, AppError> {
88        let validation_rules = ClaimsValidationRules::new();
89        let untrusted_token = UntrustedToken::<Local, V4>::try_from(token)
90            .map_err(|_| AppError::Unauthorized("Invalid token format".into()))?;
91
92        let claims = pasetors::local::decrypt(
93            &self.key,
94            &untrusted_token,
95            &validation_rules,
96            None,
97            Some(b"ferrox-auth-footer"),
98        )
99        .map_err(|_| AppError::Unauthorized("Token validation failed".into()))?;
100
101        let user_id = claims.get_claim("user_id")
102            .and_then(|v| v.as_str())
103            .ok_or_else(|| AppError::Unauthorized("Missing user_id".into()))?;
104        
105        let role = claims.get_claim("role")
106            .and_then(|v| v.as_str())
107            .ok_or_else(|| AppError::Unauthorized("Missing role".into()))?;
108
109        Ok(AuthPayload {
110            user_id: user_id.to_string(),
111            role: role.to_string(),
112        })
113    }
114}
115
116pub fn setup() {
117    println!("ferrox-security initialized: Argon2 Hashing and PASETO Authentication ready.");
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_argon2_hashing() {
126        let password = Secret::new("SuperSecureP@ssw0rd!".to_string());
127        
128        // Hash it
129        let hash = hash_password(password.clone()).unwrap();
130        assert!(hash.starts_with("$argon2"));
131
132        // Verify with correct password
133        let is_valid = verify_password(password, &hash).unwrap();
134        assert!(is_valid);
135
136        // Verify with wrong password
137        let wrong_password = Secret::new("WrongPassword!".to_string());
138        let is_valid_wrong = verify_password(wrong_password, &hash).unwrap();
139        assert!(!is_valid_wrong);
140    }
141
142    #[test]
143    fn test_paseto_token_lifecycle() {
144        // Must be exactly 32 bytes for V4 local
145        let secret = Secret::new("12345678901234567890123456789012".to_string());
146        let auth = PasetoAuth::new(secret).unwrap();
147
148        let payload = AuthPayload {
149            user_id: "user-123".into(),
150            role: "admin".into(),
151        };
152
153        // Generate token valid for 1 hour
154        let token = auth.generate_token(&payload, Duration::hours(1)).unwrap();
155        assert!(token.starts_with("v4.local.")); // PASETO V4 local format
156
157        // Validate token
158        let validated = auth.validate_token(&token).unwrap();
159        assert_eq!(validated.user_id, "user-123");
160        assert_eq!(validated.role, "admin");
161
162        // Validate bad token
163        let bad_token = "v4.local.bad_data_here";
164        assert!(auth.validate_token(bad_token).is_err());
165    }
166}