Skip to main content

auth_framework/protocols/
paseto.rs

1//! PASETO (Platform-Agnostic Security Tokens) v4 implementation.
2//!
3//! Provides local (symmetric, encrypted) PASETO v4 token operations
4//! as a secure alternative to JWT.
5
6use crate::errors::{AuthError, Result};
7use pasetors::claims::{Claims, ClaimsValidationRules};
8use pasetors::footer::Footer;
9use pasetors::keys::{Generate, SymmetricKey};
10use pasetors::token::UntrustedToken;
11use pasetors::version4::V4;
12use pasetors::{Local, local};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::time::Duration;
16
17/// Configuration for PASETO token operations.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PasetoConfig {
20    pub issuer: String,
21    pub token_lifetime: Duration,
22    pub audience: Option<String>,
23    pub footer: Option<String>,
24}
25
26impl Default for PasetoConfig {
27    fn default() -> Self {
28        Self {
29            issuer: "auth-framework".to_string(),
30            token_lifetime: Duration::from_secs(3600),
31            audience: None,
32            footer: None,
33        }
34    }
35}
36
37/// Decoded PASETO token payload.
38#[derive(Debug, Clone)]
39pub struct PasetoToken {
40    pub subject: String,
41    pub issuer: String,
42    pub token_id: Option<String>,
43    pub audience: Option<String>,
44    pub custom_claims: HashMap<String, String>,
45}
46
47/// PASETO v4.local token manager (symmetric encryption).
48pub struct PasetoLocalManager {
49    config: PasetoConfig,
50    key: SymmetricKey<V4>,
51}
52
53impl PasetoLocalManager {
54    /// Create a new manager by generating a fresh random key.
55    pub fn new(config: PasetoConfig) -> Result<Self> {
56        let key = SymmetricKey::<V4>::generate()
57            .map_err(|e| AuthError::crypto(format!("Failed to generate PASETO key: {e}")))?;
58        Ok(Self { config, key })
59    }
60
61    /// Create from existing raw 32-byte key material.
62    pub fn from_key_bytes(config: PasetoConfig, key_bytes: &[u8; 32]) -> Result<Self> {
63        let key = SymmetricKey::<V4>::from(key_bytes)
64            .map_err(|e| AuthError::crypto(format!("Invalid PASETO key: {e}")))?;
65        Ok(Self { config, key })
66    }
67
68    /// Issue an encrypted PASETO v4.local token.
69    pub fn issue_token(
70        &self,
71        subject: &str,
72        additional_claims: Option<&HashMap<String, String>>,
73    ) -> Result<String> {
74        if subject.is_empty() {
75            return Err(AuthError::validation("Subject cannot be empty"));
76        }
77
78        let mut claims = Claims::new()
79            .map_err(|e| AuthError::crypto(format!("Failed to create claims: {e}")))?;
80
81        claims
82            .subject(subject)
83            .map_err(|e| AuthError::crypto(format!("Failed to set subject: {e}")))?;
84        claims
85            .issuer(&self.config.issuer)
86            .map_err(|e| AuthError::crypto(format!("Failed to set issuer: {e}")))?;
87        claims
88            .token_identifier(&uuid::Uuid::new_v4().to_string())
89            .map_err(|e| AuthError::crypto(format!("Failed to set jti: {e}")))?;
90
91        if let Some(ref aud) = self.config.audience {
92            claims
93                .audience(aud)
94                .map_err(|e| AuthError::crypto(format!("Failed to set audience: {e}")))?;
95        }
96
97        if let Some(extra) = additional_claims {
98            for (key, value) in extra {
99                claims
100                    .add_additional(key, value.clone())
101                    .map_err(|e| AuthError::crypto(format!("Failed to add claim '{key}': {e}")))?;
102            }
103        }
104
105        let footer = match self.config.footer.as_deref() {
106            Some(f) => {
107                let mut ft = Footer::new();
108                ft.add_additional("data", f)
109                    .map_err(|e| AuthError::crypto(format!("Invalid PASETO footer: {e}")))?;
110                Some(ft)
111            }
112            None => None,
113        };
114
115        local::encrypt(&self.key, &claims, footer.as_ref(), None)
116            .map_err(|e| AuthError::crypto(format!("PASETO encryption failed: {e}")))
117    }
118
119    /// Decrypt and validate a PASETO v4.local token.
120    pub fn validate_token(&self, token: &str) -> Result<PasetoToken> {
121        if !token.starts_with("v4.local.") {
122            return Err(AuthError::validation("Not a v4.local PASETO token"));
123        }
124
125        let validation_rules = ClaimsValidationRules::new();
126        let untrusted = UntrustedToken::<Local, V4>::try_from(token)
127            .map_err(|e| AuthError::validation(format!("Invalid PASETO token format: {e}")))?;
128
129        let footer = match self.config.footer.as_deref() {
130            Some(f) => {
131                let mut ft = Footer::new();
132                ft.add_additional("data", f)
133                    .map_err(|e| AuthError::validation(format!("Invalid PASETO footer: {e}")))?;
134                Some(ft)
135            }
136            None => None,
137        };
138
139        let trusted = local::decrypt(
140            &self.key,
141            &untrusted,
142            &validation_rules,
143            footer.as_ref(),
144            None,
145        )
146        .map_err(|e| AuthError::validation(format!("PASETO decryption/validation failed: {e}")))?;
147
148        let payload = trusted
149            .payload_claims()
150            .ok_or_else(|| AuthError::validation("PASETO token has no claims"))?;
151
152        let subject = payload
153            .get_claim("sub")
154            .and_then(|v| v.as_str())
155            .unwrap_or_default()
156            .to_string();
157        let issuer = payload
158            .get_claim("iss")
159            .and_then(|v| v.as_str())
160            .unwrap_or_default()
161            .to_string();
162        let token_id = payload
163            .get_claim("jti")
164            .and_then(|v| v.as_str())
165            .map(|s| s.to_string());
166        let audience = payload
167            .get_claim("aud")
168            .and_then(|v| v.as_str())
169            .map(|s| s.to_string());
170
171        if !self.config.issuer.is_empty() && issuer != self.config.issuer {
172            return Err(AuthError::validation(format!(
173                "Issuer mismatch: expected '{}', got '{}'",
174                self.config.issuer, issuer
175            )));
176        }
177
178        Ok(PasetoToken {
179            subject,
180            issuer,
181            token_id,
182            audience,
183            custom_claims: HashMap::new(),
184        })
185    }
186}
187
188/// Generate a new random 32-byte key for PASETO v4.local, returned as hex.
189pub fn generate_local_key_hex() -> Result<String> {
190    let key = SymmetricKey::<V4>::generate()
191        .map_err(|e| AuthError::crypto(format!("Failed to generate key: {e}")))?;
192    Ok(hex::encode(key.as_bytes()))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn default_manager() -> PasetoLocalManager {
200        PasetoLocalManager::new(PasetoConfig::default()).unwrap()
201    }
202
203    #[test]
204    fn test_issue_and_validate_token() {
205        let mgr = default_manager();
206        let token = mgr.issue_token("user-42", None).unwrap();
207        assert!(token.starts_with("v4.local."));
208        let decoded = mgr.validate_token(&token).unwrap();
209        assert_eq!(decoded.subject, "user-42");
210        assert_eq!(decoded.issuer, "auth-framework");
211        assert!(decoded.token_id.is_some());
212    }
213
214    #[test]
215    fn test_issue_with_custom_claims() {
216        let mgr = default_manager();
217        let mut extra = HashMap::new();
218        extra.insert("role".to_string(), "admin".to_string());
219        let token = mgr.issue_token("user-1", Some(&extra)).unwrap();
220        let decoded = mgr.validate_token(&token).unwrap();
221        assert_eq!(decoded.subject, "user-1");
222    }
223
224    #[test]
225    fn test_issue_with_audience() {
226        let config = PasetoConfig {
227            audience: Some("https://api.example.com".to_string()),
228            ..PasetoConfig::default()
229        };
230        let mgr = PasetoLocalManager::new(config).unwrap();
231        let token = mgr.issue_token("user-1", None).unwrap();
232        let decoded = mgr.validate_token(&token).unwrap();
233        assert_eq!(decoded.audience.as_deref(), Some("https://api.example.com"));
234    }
235
236    #[test]
237    fn test_reject_empty_subject() {
238        let mgr = default_manager();
239        assert!(mgr.issue_token("", None).is_err());
240    }
241
242    #[test]
243    fn test_reject_wrong_prefix() {
244        let mgr = default_manager();
245        assert!(mgr.validate_token("v4.public.garbage").is_err());
246    }
247
248    #[test]
249    fn test_reject_tampered_token() {
250        let mgr = default_manager();
251        let token = mgr.issue_token("user-1", None).unwrap();
252        let tampered = format!("{}tampered", token);
253        assert!(mgr.validate_token(&tampered).is_err());
254    }
255
256    #[test]
257    fn test_different_keys_reject() {
258        let mgr1 = default_manager();
259        let mgr2 = default_manager();
260        let token = mgr1.issue_token("user-1", None).unwrap();
261        assert!(mgr2.validate_token(&token).is_err());
262    }
263
264    #[test]
265    fn test_from_key_bytes_roundtrip() {
266        let key_bytes = [42u8; 32];
267        let config = PasetoConfig::default();
268        let mgr = PasetoLocalManager::from_key_bytes(config.clone(), &key_bytes).unwrap();
269        let token = mgr.issue_token("user-1", None).unwrap();
270        let mgr2 = PasetoLocalManager::from_key_bytes(config, &key_bytes).unwrap();
271        let decoded = mgr2.validate_token(&token).unwrap();
272        assert_eq!(decoded.subject, "user-1");
273    }
274
275    #[test]
276    fn test_issuer_mismatch_rejected() {
277        let key_bytes = [99u8; 32];
278        let cfg_a = PasetoConfig {
279            issuer: "server-a".to_string(),
280            ..PasetoConfig::default()
281        };
282        let cfg_b = PasetoConfig {
283            issuer: "server-b".to_string(),
284            ..PasetoConfig::default()
285        };
286        let mgr_a = PasetoLocalManager::from_key_bytes(cfg_a, &key_bytes).unwrap();
287        let mgr_b = PasetoLocalManager::from_key_bytes(cfg_b, &key_bytes).unwrap();
288        let token = mgr_a.issue_token("user-1", None).unwrap();
289        assert!(mgr_b.validate_token(&token).is_err());
290    }
291
292    #[test]
293    fn test_with_footer() {
294        let config = PasetoConfig {
295            footer: Some("key-id:v1".to_string()),
296            ..PasetoConfig::default()
297        };
298        let mgr = PasetoLocalManager::new(config).unwrap();
299        let token = mgr.issue_token("user-1", None).unwrap();
300        let decoded = mgr.validate_token(&token).unwrap();
301        assert_eq!(decoded.subject, "user-1");
302    }
303
304    #[test]
305    fn test_footer_mismatch_rejected() {
306        let key_bytes = [77u8; 32];
307        let cfg1 = PasetoConfig {
308            footer: Some("footer-a".to_string()),
309            ..PasetoConfig::default()
310        };
311        let cfg2 = PasetoConfig {
312            footer: Some("footer-b".to_string()),
313            ..PasetoConfig::default()
314        };
315        let mgr1 = PasetoLocalManager::from_key_bytes(cfg1, &key_bytes).unwrap();
316        let mgr2 = PasetoLocalManager::from_key_bytes(cfg2, &key_bytes).unwrap();
317        let token = mgr1.issue_token("user-1", None).unwrap();
318        assert!(mgr2.validate_token(&token).is_err());
319    }
320
321    #[test]
322    fn test_generate_local_key_hex() {
323        let key1 = generate_local_key_hex().unwrap();
324        let key2 = generate_local_key_hex().unwrap();
325        assert_eq!(key1.len(), 64);
326        assert_ne!(key1, key2);
327    }
328
329    #[test]
330    fn test_default_config() {
331        let config = PasetoConfig::default();
332        assert_eq!(config.issuer, "auth-framework");
333        assert_eq!(config.token_lifetime, Duration::from_secs(3600));
334        assert!(config.audience.is_none());
335        assert!(config.footer.is_none());
336    }
337}