modo/auth/session/jwt/
claims.rs1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Claims {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub iss: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub sub: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub aud: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub exp: Option<u64>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub nbf: Option<u64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub iat: Option<u64>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub jti: Option<String>,
49}
50
51fn now_secs() -> u64 {
52 SystemTime::now()
53 .duration_since(UNIX_EPOCH)
54 .expect("system clock before UNIX epoch")
55 .as_secs()
56}
57
58impl Claims {
59 pub fn new() -> Self {
61 Self {
62 iss: None,
63 sub: None,
64 aud: None,
65 exp: None,
66 nbf: None,
67 iat: None,
68 jti: None,
69 }
70 }
71
72 pub fn with_iss(mut self, iss: impl Into<String>) -> Self {
74 self.iss = Some(iss.into());
75 self
76 }
77
78 pub fn with_sub(mut self, sub: impl Into<String>) -> Self {
80 self.sub = Some(sub.into());
81 self
82 }
83
84 pub fn with_aud(mut self, aud: impl Into<String>) -> Self {
86 self.aud = Some(aud.into());
87 self
88 }
89
90 pub fn with_exp(mut self, exp: u64) -> Self {
92 self.exp = Some(exp);
93 self
94 }
95
96 pub fn with_exp_in(mut self, duration: Duration) -> Self {
98 self.exp = Some(now_secs() + duration.as_secs());
99 self
100 }
101
102 pub fn with_nbf(mut self, nbf: u64) -> Self {
104 self.nbf = Some(nbf);
105 self
106 }
107
108 pub fn with_iat_now(mut self) -> Self {
110 self.iat = Some(now_secs());
111 self
112 }
113
114 pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
116 self.jti = Some(jti.into());
117 self
118 }
119
120 pub fn is_expired(&self) -> bool {
123 match self.exp {
124 Some(exp) => now_secs() > exp,
125 None => false,
126 }
127 }
128
129 pub fn is_not_yet_valid(&self) -> bool {
132 match self.nbf {
133 Some(nbf) => now_secs() < nbf,
134 None => false,
135 }
136 }
137
138 pub fn subject(&self) -> Option<&str> {
140 self.sub.as_deref()
141 }
142
143 pub fn token_id(&self) -> Option<&str> {
145 self.jti.as_deref()
146 }
147
148 pub fn issuer(&self) -> Option<&str> {
150 self.iss.as_deref()
151 }
152
153 pub fn audience(&self) -> Option<&str> {
155 self.aud.as_deref()
156 }
157}
158
159impl Default for Claims {
160 fn default() -> Self {
161 Self::new()
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn new_sets_all_fields_none() {
171 let claims = Claims::new();
172 assert!(claims.iss.is_none());
173 assert!(claims.sub.is_none());
174 assert!(claims.aud.is_none());
175 assert!(claims.exp.is_none());
176 assert!(claims.nbf.is_none());
177 assert!(claims.iat.is_none());
178 assert!(claims.jti.is_none());
179 }
180
181 #[test]
182 fn builders_set_fields() {
183 let claims = Claims::new()
184 .with_sub("user_1")
185 .with_iss("my-app")
186 .with_aud("api")
187 .with_exp(9999999999)
188 .with_nbf(1000000000)
189 .with_jti("token-id");
190 assert_eq!(claims.subject(), Some("user_1"));
191 assert_eq!(claims.issuer(), Some("my-app"));
192 assert_eq!(claims.audience(), Some("api"));
193 assert_eq!(claims.exp, Some(9999999999));
194 assert_eq!(claims.nbf, Some(1000000000));
195 assert_eq!(claims.token_id(), Some("token-id"));
196 }
197
198 #[test]
199 fn with_exp_in_sets_future_timestamp() {
200 let claims = Claims::new().with_exp_in(Duration::from_secs(3600));
201 let exp = claims.exp.unwrap();
202 let now = now_secs();
203 assert!(exp >= now + 3599 && exp <= now + 3601);
204 }
205
206 #[test]
207 fn with_iat_now_sets_current_timestamp() {
208 let claims = Claims::new().with_iat_now();
209 let iat = claims.iat.unwrap();
210 let now = now_secs();
211 assert!(iat >= now - 1 && iat <= now + 1);
212 }
213
214 #[test]
215 fn is_expired_returns_false_for_future_exp() {
216 let claims = Claims::new().with_exp(now_secs() + 3600);
217 assert!(!claims.is_expired());
218 }
219
220 #[test]
221 fn is_expired_returns_true_for_past_exp() {
222 let claims = Claims::new().with_exp(now_secs() - 1);
223 assert!(claims.is_expired());
224 }
225
226 #[test]
227 fn is_expired_returns_false_when_no_exp() {
228 let claims = Claims::new();
229 assert!(!claims.is_expired());
230 }
231
232 #[test]
233 fn is_not_yet_valid_returns_true_for_future_nbf() {
234 let claims = Claims::new().with_nbf(now_secs() + 3600);
235 assert!(claims.is_not_yet_valid());
236 }
237
238 #[test]
239 fn is_not_yet_valid_returns_false_for_past_nbf() {
240 let claims = Claims::new().with_nbf(now_secs() - 1);
241 assert!(!claims.is_not_yet_valid());
242 }
243
244 #[test]
245 fn serialization_skips_none_fields() {
246 let claims = Claims::new().with_sub("user_1");
247 let json = serde_json::to_value(&claims).unwrap();
248 assert!(json.get("sub").is_some());
249 assert!(json.get("iss").is_none());
250 assert!(json.get("exp").is_none());
251 }
252
253 #[test]
254 fn deserialization_roundtrip() {
255 let original = Claims::new()
256 .with_sub("user_1")
257 .with_exp(9999999999)
258 .with_iss("my-app");
259 let json = serde_json::to_string(&original).unwrap();
260 let decoded: Claims = serde_json::from_str(&json).unwrap();
261 assert_eq!(decoded.sub, original.sub);
262 assert_eq!(decoded.exp, original.exp);
263 assert_eq!(decoded.iss, original.iss);
264 }
265
266 #[test]
267 fn default_matches_new() {
268 let a = Claims::new();
269 let b = Claims::default();
270 assert_eq!(
271 serde_json::to_string(&a).unwrap(),
272 serde_json::to_string(&b).unwrap()
273 );
274 }
275}