forge_core/auth/
claims.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use uuid::Uuid;
4
5/// JWT claims structure.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Claims {
8    /// Subject (user ID).
9    pub sub: String,
10    /// Issued at (Unix timestamp).
11    pub iat: i64,
12    /// Expiration time (Unix timestamp).
13    pub exp: i64,
14    /// User roles.
15    #[serde(default)]
16    pub roles: Vec<String>,
17    /// Custom claims.
18    #[serde(flatten)]
19    pub custom: HashMap<String, serde_json::Value>,
20}
21
22impl Claims {
23    /// Get the user ID as UUID.
24    pub fn user_id(&self) -> Option<Uuid> {
25        Uuid::parse_str(&self.sub).ok()
26    }
27
28    /// Check if the token is expired.
29    pub fn is_expired(&self) -> bool {
30        let now = chrono::Utc::now().timestamp();
31        self.exp < now
32    }
33
34    /// Check if the user has a role.
35    pub fn has_role(&self, role: &str) -> bool {
36        self.roles.iter().any(|r| r == role)
37    }
38
39    /// Get a custom claim value.
40    pub fn get_claim(&self, key: &str) -> Option<&serde_json::Value> {
41        self.custom.get(key)
42    }
43
44    /// Get the tenant ID if present in claims.
45    pub fn tenant_id(&self) -> Option<Uuid> {
46        self.custom
47            .get("tenant_id")
48            .and_then(|v| v.as_str())
49            .and_then(|s| Uuid::parse_str(s).ok())
50    }
51
52    /// Create a builder for constructing claims.
53    pub fn builder() -> ClaimsBuilder {
54        ClaimsBuilder::new()
55    }
56}
57
58/// Builder for JWT claims.
59#[derive(Debug, Default)]
60pub struct ClaimsBuilder {
61    sub: Option<String>,
62    roles: Vec<String>,
63    custom: HashMap<String, serde_json::Value>,
64    duration_secs: i64,
65}
66
67impl ClaimsBuilder {
68    /// Create a new builder.
69    pub fn new() -> Self {
70        Self {
71            sub: None,
72            roles: Vec::new(),
73            custom: HashMap::new(),
74            duration_secs: 3600, // 1 hour default
75        }
76    }
77
78    /// Set the subject (user ID).
79    pub fn subject(mut self, sub: impl Into<String>) -> Self {
80        self.sub = Some(sub.into());
81        self
82    }
83
84    /// Set the user ID from UUID.
85    pub fn user_id(mut self, id: Uuid) -> Self {
86        self.sub = Some(id.to_string());
87        self
88    }
89
90    /// Add a role.
91    pub fn role(mut self, role: impl Into<String>) -> Self {
92        self.roles.push(role.into());
93        self
94    }
95
96    /// Set multiple roles.
97    pub fn roles(mut self, roles: Vec<String>) -> Self {
98        self.roles = roles;
99        self
100    }
101
102    /// Add a custom claim.
103    pub fn claim(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
104        self.custom.insert(key.into(), value);
105        self
106    }
107
108    /// Set the tenant ID.
109    pub fn tenant_id(mut self, id: Uuid) -> Self {
110        self.custom
111            .insert("tenant_id".to_string(), serde_json::json!(id.to_string()));
112        self
113    }
114
115    /// Set token duration in seconds.
116    pub fn duration_secs(mut self, secs: i64) -> Self {
117        self.duration_secs = secs;
118        self
119    }
120
121    /// Build the claims.
122    pub fn build(self) -> Result<Claims, String> {
123        let sub = self.sub.ok_or("Subject is required")?;
124        let now = chrono::Utc::now().timestamp();
125
126        Ok(Claims {
127            sub,
128            iat: now,
129            exp: now + self.duration_secs,
130            roles: self.roles,
131            custom: self.custom,
132        })
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_claims_builder() {
142        let user_id = Uuid::new_v4();
143        let claims = Claims::builder()
144            .user_id(user_id)
145            .role("admin")
146            .role("user")
147            .claim("org_id", serde_json::json!("org-123"))
148            .duration_secs(7200)
149            .build()
150            .unwrap();
151
152        assert_eq!(claims.user_id(), Some(user_id));
153        assert!(claims.has_role("admin"));
154        assert!(claims.has_role("user"));
155        assert!(!claims.has_role("superadmin"));
156        assert_eq!(
157            claims.get_claim("org_id"),
158            Some(&serde_json::json!("org-123"))
159        );
160        assert!(!claims.is_expired());
161    }
162
163    #[test]
164    fn test_claims_expiration() {
165        let claims = Claims {
166            sub: "user-1".to_string(),
167            iat: 0,
168            exp: 1, // Expired timestamp
169            roles: vec![],
170            custom: HashMap::new(),
171        };
172
173        assert!(claims.is_expired());
174    }
175
176    #[test]
177    fn test_claims_serialization() {
178        let claims = Claims::builder()
179            .subject("user-1")
180            .role("admin")
181            .build()
182            .unwrap();
183
184        let json = serde_json::to_string(&claims).unwrap();
185        let deserialized: Claims = serde_json::from_str(&json).unwrap();
186
187        assert_eq!(deserialized.sub, claims.sub);
188        assert_eq!(deserialized.roles, claims.roles);
189    }
190}