auth_framework/protocols/
paseto.rs1use 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#[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#[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
47pub struct PasetoLocalManager {
49 config: PasetoConfig,
50 key: SymmetricKey<V4>,
51}
52
53impl PasetoLocalManager {
54 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 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 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 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
188pub 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}