Skip to main content

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)]
137#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_claims_builder() {
143        let user_id = Uuid::new_v4();
144        let claims = Claims::builder()
145            .user_id(user_id)
146            .role("admin")
147            .role("user")
148            .claim("org_id", serde_json::json!("org-123"))
149            .duration_secs(7200)
150            .build()
151            .unwrap();
152
153        assert_eq!(claims.user_id(), Some(user_id));
154        assert!(claims.has_role("admin"));
155        assert!(claims.has_role("user"));
156        assert!(!claims.has_role("superadmin"));
157        assert_eq!(
158            claims.get_claim("org_id"),
159            Some(&serde_json::json!("org-123"))
160        );
161        assert!(!claims.is_expired());
162    }
163
164    #[test]
165    fn test_claims_expiration() {
166        let claims = Claims {
167            sub: "user-1".to_string(),
168            iat: 0,
169            exp: 1, // Expired timestamp
170            roles: vec![],
171            custom: HashMap::new(),
172        };
173
174        assert!(claims.is_expired());
175    }
176
177    #[test]
178    fn test_claims_serialization() {
179        let claims = Claims::builder()
180            .subject("user-1")
181            .role("admin")
182            .build()
183            .unwrap();
184
185        let json = serde_json::to_string(&claims).unwrap();
186        let deserialized: Claims = serde_json::from_str(&json).unwrap();
187
188        assert_eq!(deserialized.sub, claims.sub);
189        assert_eq!(deserialized.roles, claims.roles);
190    }
191}