Skip to main content

modo/auth/session/jwt/
claims.rs

1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3use serde::{Deserialize, Serialize};
4
5/// JWT claim payload used by the system auth flow.
6///
7/// Contains the seven registered JWT claims (`iss`, `sub`, `aud`, `exp`, `nbf`,
8/// `iat`, `jti`). All fields are optional — `None` values are omitted from the
9/// serialized token.
10///
11/// Custom auth flows that need extra payload fields should define their own
12/// struct and pass it directly to [`JwtEncoder::encode<T>`] /
13/// [`JwtDecoder::decode<T>`].
14///
15/// # Example
16///
17/// ```rust,ignore
18/// use modo::auth::session::jwt::Claims;
19///
20/// let claims = Claims::new()
21///     .with_sub("user_123")
22///     .with_aud("my-app")
23///     .with_iat_now()
24///     .with_exp_in(std::time::Duration::from_secs(3600));
25/// ```
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Claims {
28    /// Issuer (`iss`).
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub iss: Option<String>,
31    /// Subject (`sub`) — typically the user identifier.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub sub: Option<String>,
34    /// Audience (`aud`).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub aud: Option<String>,
37    /// Expiration time (`exp`) as a Unix timestamp in seconds.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub exp: Option<u64>,
40    /// Not-before time (`nbf`) as a Unix timestamp in seconds.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub nbf: Option<u64>,
43    /// Issued-at time (`iat`) as a Unix timestamp in seconds.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub iat: Option<u64>,
46    /// JWT ID (`jti`) — unique identifier for the token.
47    #[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    /// Creates a new `Claims` with all registered fields set to `None`.
60    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    /// Sets the issuer (`iss`) claim.
73    pub fn with_iss(mut self, iss: impl Into<String>) -> Self {
74        self.iss = Some(iss.into());
75        self
76    }
77
78    /// Sets the subject (`sub`) claim.
79    pub fn with_sub(mut self, sub: impl Into<String>) -> Self {
80        self.sub = Some(sub.into());
81        self
82    }
83
84    /// Sets the audience (`aud`) claim.
85    pub fn with_aud(mut self, aud: impl Into<String>) -> Self {
86        self.aud = Some(aud.into());
87        self
88    }
89
90    /// Sets the expiration time (`exp`) as an absolute Unix timestamp in seconds.
91    pub fn with_exp(mut self, exp: u64) -> Self {
92        self.exp = Some(exp);
93        self
94    }
95
96    /// Sets the expiration time (`exp`) relative to the current time.
97    pub fn with_exp_in(mut self, duration: Duration) -> Self {
98        self.exp = Some(now_secs() + duration.as_secs());
99        self
100    }
101
102    /// Sets the not-before time (`nbf`) as an absolute Unix timestamp in seconds.
103    pub fn with_nbf(mut self, nbf: u64) -> Self {
104        self.nbf = Some(nbf);
105        self
106    }
107
108    /// Sets the issued-at time (`iat`) to the current time.
109    pub fn with_iat_now(mut self) -> Self {
110        self.iat = Some(now_secs());
111        self
112    }
113
114    /// Sets the JWT ID (`jti`).
115    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
116        self.jti = Some(jti.into());
117        self
118    }
119
120    /// Returns `true` if the token has an `exp` claim that is in the past.
121    /// Returns `false` when `exp` is absent.
122    pub fn is_expired(&self) -> bool {
123        match self.exp {
124            Some(exp) => now_secs() > exp,
125            None => false,
126        }
127    }
128
129    /// Returns `true` if the token has an `nbf` claim that is in the future.
130    /// Returns `false` when `nbf` is absent.
131    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    /// Returns the subject claim (`sub`) as a string slice, if present.
139    pub fn subject(&self) -> Option<&str> {
140        self.sub.as_deref()
141    }
142
143    /// Returns the JWT ID (`jti`) as a string slice, if present.
144    pub fn token_id(&self) -> Option<&str> {
145        self.jti.as_deref()
146    }
147
148    /// Returns the issuer claim (`iss`) as a string slice, if present.
149    pub fn issuer(&self) -> Option<&str> {
150        self.iss.as_deref()
151    }
152
153    /// Returns the audience claim (`aud`) as a string slice, if present.
154    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}