Skip to main content

ironflow_auth/
jwt.rs

1//! JWT access and refresh token management.
2//!
3//! Access tokens are short-lived (default 15 min) and used for API requests.
4//! Refresh tokens are long-lived (default 7 days) and used to obtain new access tokens.
5//! Token types are enforced — a refresh token cannot be used as an access token.
6
7use chrono::Utc;
8use hex::encode;
9use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use uuid::Uuid;
13
14use crate::error::AuthError;
15
16const ACCESS_TOKEN_TYPE: &str = "access";
17const REFRESH_TOKEN_TYPE: &str = "refresh";
18
19/// JWT configuration.
20///
21/// # Examples
22///
23/// ```
24/// use ironflow_auth::jwt::JwtConfig;
25///
26/// let config = JwtConfig {
27///     secret: "my-secret-key".to_string(),
28///     access_token_ttl_secs: 900,
29///     refresh_token_ttl_secs: 604800,
30///     cookie_domain: None,
31///     cookie_secure: false,
32/// };
33/// ```
34#[derive(Debug, Clone)]
35pub struct JwtConfig {
36    /// HMAC secret for signing tokens.
37    pub secret: String,
38    /// Access token time-to-live in seconds (default: 900 = 15 min).
39    pub access_token_ttl_secs: i64,
40    /// Refresh token time-to-live in seconds (default: 604800 = 7 days).
41    pub refresh_token_ttl_secs: i64,
42    /// Optional cookie domain (e.g., `.example.com`).
43    pub cookie_domain: Option<String>,
44    /// Whether to set the `Secure` flag on cookies.
45    pub cookie_secure: bool,
46}
47
48/// Claims embedded in an access token.
49#[derive(Debug, Serialize, Deserialize, Clone)]
50pub struct AccessTokenClaims {
51    /// Subject (user_id).
52    pub sub: Uuid,
53    /// Unique token identifier.
54    pub jti: String,
55    /// Issued at (unix timestamp).
56    pub iat: i64,
57    /// Expiration (unix timestamp).
58    pub exp: i64,
59    /// Token type — always "access".
60    pub typ: String,
61    /// User ID.
62    pub user_id: Uuid,
63    /// Username.
64    pub username: String,
65    /// Admin flag.
66    pub is_admin: bool,
67    /// Session generation of the user when the token was issued.
68    #[serde(default)]
69    pub ver: i64,
70}
71
72/// Claims embedded in a refresh token.
73#[derive(Debug, Serialize, Deserialize, Clone)]
74pub struct RefreshTokenClaims {
75    /// Subject (user_id).
76    pub sub: Uuid,
77    /// Unique token identifier.
78    pub jti: String,
79    /// Issued at (unix timestamp).
80    pub iat: i64,
81    /// Expiration (unix timestamp).
82    pub exp: i64,
83    /// Token type — always "refresh".
84    pub typ: String,
85    /// User ID.
86    pub user_id: Uuid,
87    /// Username.
88    pub username: String,
89    /// Admin flag.
90    pub is_admin: bool,
91    /// Session generation of the user when the token was issued.
92    #[serde(default)]
93    pub ver: i64,
94}
95
96/// A signed access token (JWT string).
97pub struct AccessToken(pub String);
98
99impl AccessToken {
100    /// Create an access token for a user, at session generation 0.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`AuthError::Jwt`] if JWT encoding fails.
105    pub fn for_user(
106        user_id: Uuid,
107        username: &str,
108        is_admin: bool,
109        config: &JwtConfig,
110    ) -> Result<Self, AuthError> {
111        Self::for_user_with_version(user_id, username, is_admin, 0, config)
112    }
113
114    /// Create an access token for a user at a given session generation.
115    ///
116    /// `token_version` is the user's current `token_version`: the token is
117    /// rejected once the stored version moves past it.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`AuthError::Jwt`] if JWT encoding fails.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use ironflow_auth::jwt::{AccessToken, JwtConfig};
127    /// use uuid::Uuid;
128    /// # use ironflow_auth::error::AuthError;
129    ///
130    /// # fn example() -> Result<(), AuthError> {
131    /// let config = JwtConfig {
132    ///     secret: "my-secret-key".to_string(),
133    ///     access_token_ttl_secs: 900,
134    ///     refresh_token_ttl_secs: 604800,
135    ///     cookie_domain: None,
136    ///     cookie_secure: false,
137    /// };
138    /// let token = AccessToken::for_user_with_version(Uuid::now_v7(), "alice", false, 3, &config)?;
139    /// let claims = AccessToken::decode(&token.0, &config)?;
140    /// assert_eq!(claims.ver, 3);
141    /// # Ok(())
142    /// # }
143    /// # example().expect("example");
144    /// ```
145    pub fn for_user_with_version(
146        user_id: Uuid,
147        username: &str,
148        is_admin: bool,
149        token_version: i64,
150        config: &JwtConfig,
151    ) -> Result<Self, AuthError> {
152        let now = Utc::now().timestamp();
153        let claims = AccessTokenClaims {
154            sub: user_id,
155            jti: Uuid::now_v7().to_string(),
156            iat: now,
157            exp: now + config.access_token_ttl_secs,
158            typ: ACCESS_TOKEN_TYPE.to_string(),
159            user_id,
160            username: username.to_string(),
161            is_admin,
162            ver: token_version,
163        };
164        let token = jsonwebtoken::encode(
165            &Header::default(),
166            &claims,
167            &EncodingKey::from_secret(config.secret.as_bytes()),
168        )?;
169        Ok(Self(token))
170    }
171
172    /// Decode and validate an access token.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`AuthError::Jwt`] if the token is invalid, expired, or not an access token.
177    pub fn decode(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, AuthError> {
178        let token_data = jsonwebtoken::decode::<AccessTokenClaims>(
179            token,
180            &DecodingKey::from_secret(config.secret.as_bytes()),
181            &Validation::default(),
182        )?;
183        if token_data.claims.typ != ACCESS_TOKEN_TYPE {
184            return Err(AuthError::InvalidToken);
185        }
186        Ok(token_data.claims)
187    }
188}
189
190/// A signed refresh token (JWT string).
191pub struct RefreshToken(pub String);
192
193impl RefreshToken {
194    /// Create a refresh token for a user, at session generation 0.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`AuthError::Jwt`] if JWT encoding fails.
199    pub fn for_user(
200        user_id: Uuid,
201        username: &str,
202        is_admin: bool,
203        config: &JwtConfig,
204    ) -> Result<Self, AuthError> {
205        Self::for_user_with_version(user_id, username, is_admin, 0, config)
206    }
207
208    /// Create a refresh token for a user at a given session generation.
209    ///
210    /// `token_version` is the user's current `token_version`: the token is
211    /// rejected once the stored version moves past it.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`AuthError::Jwt`] if JWT encoding fails.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use ironflow_auth::jwt::{JwtConfig, RefreshToken};
221    /// use uuid::Uuid;
222    /// # use ironflow_auth::error::AuthError;
223    ///
224    /// # fn example() -> Result<(), AuthError> {
225    /// let config = JwtConfig {
226    ///     secret: "my-secret-key".to_string(),
227    ///     access_token_ttl_secs: 900,
228    ///     refresh_token_ttl_secs: 604800,
229    ///     cookie_domain: None,
230    ///     cookie_secure: false,
231    /// };
232    /// let token = RefreshToken::for_user_with_version(Uuid::now_v7(), "alice", false, 3, &config)?;
233    /// let claims = RefreshToken::decode(&token.0, &config)?;
234    /// assert_eq!(claims.ver, 3);
235    /// # Ok(())
236    /// # }
237    /// # example().expect("example");
238    /// ```
239    pub fn for_user_with_version(
240        user_id: Uuid,
241        username: &str,
242        is_admin: bool,
243        token_version: i64,
244        config: &JwtConfig,
245    ) -> Result<Self, AuthError> {
246        let now = Utc::now().timestamp();
247        let claims = RefreshTokenClaims {
248            sub: user_id,
249            jti: Uuid::now_v7().to_string(),
250            iat: now,
251            exp: now + config.refresh_token_ttl_secs,
252            typ: REFRESH_TOKEN_TYPE.to_string(),
253            user_id,
254            username: username.to_string(),
255            is_admin,
256            ver: token_version,
257        };
258        let token = jsonwebtoken::encode(
259            &Header::default(),
260            &claims,
261            &EncodingKey::from_secret(config.secret.as_bytes()),
262        )?;
263        Ok(Self(token))
264    }
265
266    /// Decode and validate a refresh token.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`AuthError::Jwt`] if the token is invalid, expired, or not a refresh token.
271    pub fn decode(token: &str, config: &JwtConfig) -> Result<RefreshTokenClaims, AuthError> {
272        let token_data = jsonwebtoken::decode::<RefreshTokenClaims>(
273            token,
274            &DecodingKey::from_secret(config.secret.as_bytes()),
275            &Validation::default(),
276        )?;
277        if token_data.claims.typ != REFRESH_TOKEN_TYPE {
278            return Err(AuthError::InvalidToken);
279        }
280        Ok(token_data.claims)
281    }
282}
283
284/// Hash a raw refresh token for storage.
285///
286/// Returns the lowercase hex SHA-256 of `raw`. Only this hash is persisted,
287/// so a leaked database row cannot be replayed as a token.
288///
289/// # Examples
290///
291/// ```
292/// use ironflow_auth::jwt::token_hash;
293///
294/// let hash = token_hash("some.jwt.value");
295/// assert_eq!(hash.len(), 64);
296/// assert_eq!(hash, token_hash("some.jwt.value"));
297/// ```
298pub fn token_hash(raw: &str) -> String {
299    encode(Sha256::digest(raw.as_bytes()))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn test_config() -> JwtConfig {
307        JwtConfig {
308            secret: "test-secret-key-for-unit-tests".to_string(),
309            access_token_ttl_secs: 900,
310            refresh_token_ttl_secs: 604800,
311            cookie_domain: None,
312            cookie_secure: false,
313        }
314    }
315
316    #[test]
317    fn access_token_round_trip() {
318        let config = test_config();
319        let user_id = Uuid::now_v7();
320        let token = AccessToken::for_user(user_id, "testuser", false, &config).unwrap();
321        let claims = AccessToken::decode(&token.0, &config).unwrap();
322
323        assert_eq!(claims.user_id, user_id);
324        assert_eq!(claims.username, "testuser");
325        assert!(!claims.is_admin);
326        assert_eq!(claims.sub, user_id);
327        assert_eq!(claims.typ, "access");
328    }
329
330    #[test]
331    fn access_token_admin_flag() {
332        let config = test_config();
333        let user_id = Uuid::now_v7();
334        let token = AccessToken::for_user(user_id, "admin", true, &config).unwrap();
335        let claims = AccessToken::decode(&token.0, &config).unwrap();
336
337        assert!(claims.is_admin);
338    }
339
340    #[test]
341    fn access_token_expiry_from_config() {
342        let config = test_config();
343        let user_id = Uuid::now_v7();
344        let before = Utc::now().timestamp();
345        let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
346        let claims = AccessToken::decode(&token.0, &config).unwrap();
347
348        assert!(claims.iat >= before);
349        assert_eq!(claims.exp - claims.iat, config.access_token_ttl_secs);
350    }
351
352    #[test]
353    fn decode_invalid_token_fails() {
354        let config = test_config();
355        let result = AccessToken::decode("not.a.valid.token", &config);
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn decode_tampered_token_fails() {
361        let config = test_config();
362        let user_id = Uuid::now_v7();
363        let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
364        let tampered = format!("{}x", &token.0[..token.0.len() - 1]);
365        assert!(AccessToken::decode(&tampered, &config).is_err());
366    }
367
368    #[test]
369    fn expired_token_fails() {
370        let config = test_config();
371        let user_id = Uuid::now_v7();
372        let claims = AccessTokenClaims {
373            sub: user_id,
374            jti: Uuid::now_v7().to_string(),
375            iat: Utc::now().timestamp() - 7200,
376            exp: Utc::now().timestamp() - 3600,
377            typ: "access".to_string(),
378            user_id,
379            username: "expired".to_string(),
380            is_admin: false,
381            ver: 0,
382        };
383        let token_str = jsonwebtoken::encode(
384            &Header::default(),
385            &claims,
386            &EncodingKey::from_secret(config.secret.as_bytes()),
387        )
388        .unwrap();
389        assert!(AccessToken::decode(&token_str, &config).is_err());
390    }
391
392    #[test]
393    fn refresh_token_round_trip() {
394        let config = test_config();
395        let user_id = Uuid::now_v7();
396        let token = RefreshToken::for_user(user_id, "testuser", false, &config).unwrap();
397        let claims = RefreshToken::decode(&token.0, &config).unwrap();
398
399        assert_eq!(claims.user_id, user_id);
400        assert_eq!(claims.username, "testuser");
401        assert_eq!(claims.typ, "refresh");
402        assert_eq!(claims.exp - claims.iat, config.refresh_token_ttl_secs);
403    }
404
405    #[test]
406    fn refresh_token_cannot_be_used_as_access() {
407        let config = test_config();
408        let user_id = Uuid::now_v7();
409        let refresh = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
410        assert!(AccessToken::decode(&refresh.0, &config).is_err());
411    }
412
413    #[test]
414    fn access_token_cannot_be_used_as_refresh() {
415        let config = test_config();
416        let user_id = Uuid::now_v7();
417        let access = AccessToken::for_user(user_id, "user", false, &config).unwrap();
418        assert!(RefreshToken::decode(&access.0, &config).is_err());
419    }
420
421    #[test]
422    fn decode_with_wrong_secret_fails() {
423        let config = test_config();
424        let user_id = Uuid::now_v7();
425        let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
426
427        let other_config = JwtConfig {
428            secret: "different-secret".to_string(),
429            ..test_config()
430        };
431        assert!(AccessToken::decode(&token.0, &other_config).is_err());
432    }
433
434    #[test]
435    fn refresh_token_decode_with_wrong_secret_fails() {
436        let config = test_config();
437        let user_id = Uuid::now_v7();
438        let token = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
439
440        let other_config = JwtConfig {
441            secret: "different-secret".to_string(),
442            ..test_config()
443        };
444        assert!(RefreshToken::decode(&token.0, &other_config).is_err());
445    }
446
447    #[test]
448    fn access_token_claims_serde_roundtrip() {
449        let user_id = Uuid::now_v7();
450        let claims = AccessTokenClaims {
451            sub: user_id,
452            jti: "jti-123".to_string(),
453            iat: 1000,
454            exp: 2000,
455            typ: "access".to_string(),
456            user_id,
457            username: "alice".to_string(),
458            is_admin: true,
459            ver: 0,
460        };
461        let json = serde_json::to_string(&claims).unwrap();
462        let decoded: AccessTokenClaims = serde_json::from_str(&json).unwrap();
463
464        assert_eq!(decoded.sub, user_id);
465        assert_eq!(decoded.jti, "jti-123");
466        assert_eq!(decoded.typ, "access");
467        assert_eq!(decoded.username, "alice");
468        assert!(decoded.is_admin);
469    }
470
471    #[test]
472    fn refresh_token_claims_serde_roundtrip() {
473        let user_id = Uuid::now_v7();
474        let claims = RefreshTokenClaims {
475            sub: user_id,
476            jti: "jti-456".to_string(),
477            iat: 1000,
478            exp: 2000,
479            typ: "refresh".to_string(),
480            user_id,
481            username: "bob".to_string(),
482            is_admin: false,
483            ver: 0,
484        };
485        let json = serde_json::to_string(&claims).unwrap();
486        let decoded: RefreshTokenClaims = serde_json::from_str(&json).unwrap();
487
488        assert_eq!(decoded.sub, user_id);
489        assert_eq!(decoded.typ, "refresh");
490        assert_eq!(decoded.username, "bob");
491        assert!(!decoded.is_admin);
492    }
493
494    #[test]
495    fn refresh_token_expiry_from_config() {
496        let config = test_config();
497        let user_id = Uuid::now_v7();
498        let before = Utc::now().timestamp();
499        let token = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
500        let claims = RefreshToken::decode(&token.0, &config).unwrap();
501
502        assert!(claims.iat >= before);
503        assert_eq!(claims.exp - claims.iat, config.refresh_token_ttl_secs);
504    }
505
506    #[test]
507    fn refresh_token_admin_flag() {
508        let config = test_config();
509        let user_id = Uuid::now_v7();
510        let token = RefreshToken::for_user(user_id, "admin", true, &config).unwrap();
511        let claims = RefreshToken::decode(&token.0, &config).unwrap();
512
513        assert!(claims.is_admin);
514    }
515
516    #[test]
517    fn access_token_unique_jti_per_token() {
518        let config = test_config();
519        let user_id = Uuid::now_v7();
520        let t1 = AccessToken::for_user(user_id, "user", false, &config).unwrap();
521        let t2 = AccessToken::for_user(user_id, "user", false, &config).unwrap();
522
523        let c1 = AccessToken::decode(&t1.0, &config).unwrap();
524        let c2 = AccessToken::decode(&t2.0, &config).unwrap();
525        assert_ne!(c1.jti, c2.jti);
526    }
527
528    #[test]
529    fn jwt_config_clone() {
530        let config = test_config();
531        let cloned = config.clone();
532        assert_eq!(cloned.secret, config.secret);
533        assert_eq!(cloned.access_token_ttl_secs, config.access_token_ttl_secs);
534        assert_eq!(cloned.refresh_token_ttl_secs, config.refresh_token_ttl_secs);
535        assert_eq!(cloned.cookie_domain, config.cookie_domain);
536        assert_eq!(cloned.cookie_secure, config.cookie_secure);
537    }
538
539    #[test]
540    fn jwt_config_with_cookie_domain() {
541        let config = JwtConfig {
542            cookie_domain: Some(".example.com".to_string()),
543            cookie_secure: true,
544            ..test_config()
545        };
546        assert_eq!(config.cookie_domain.as_deref(), Some(".example.com"));
547        assert!(config.cookie_secure);
548    }
549
550    #[test]
551    fn access_token_carries_version() {
552        let config = test_config();
553        let user_id = Uuid::now_v7();
554        let token = AccessToken::for_user_with_version(user_id, "user", true, 7, &config).unwrap();
555        let claims = AccessToken::decode(&token.0, &config).unwrap();
556
557        assert_eq!(claims.ver, 7);
558        assert_eq!(claims.user_id, user_id);
559        assert!(claims.is_admin);
560
561        let legacy = AccessToken::for_user(user_id, "user", false, &config).unwrap();
562        assert_eq!(AccessToken::decode(&legacy.0, &config).unwrap().ver, 0);
563    }
564
565    #[test]
566    fn refresh_token_carries_version() {
567        let config = test_config();
568        let user_id = Uuid::now_v7();
569        let token =
570            RefreshToken::for_user_with_version(user_id, "user", false, 4, &config).unwrap();
571        let claims = RefreshToken::decode(&token.0, &config).unwrap();
572
573        assert_eq!(claims.ver, 4);
574        assert_eq!(claims.user_id, user_id);
575
576        let legacy = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
577        assert_eq!(RefreshToken::decode(&legacy.0, &config).unwrap().ver, 0);
578    }
579
580    /// Claims as encoded before `ver` existed.
581    #[derive(Serialize)]
582    struct LegacyClaims {
583        sub: Uuid,
584        jti: String,
585        iat: i64,
586        exp: i64,
587        typ: String,
588        user_id: Uuid,
589        username: String,
590        is_admin: bool,
591    }
592
593    #[test]
594    fn token_without_ver_claim_decodes_as_zero() {
595        let config = test_config();
596        let user_id = Uuid::now_v7();
597        let now = Utc::now().timestamp();
598        let legacy = |typ: &str| {
599            jsonwebtoken::encode(
600                &Header::default(),
601                &LegacyClaims {
602                    sub: user_id,
603                    jti: Uuid::now_v7().to_string(),
604                    iat: now,
605                    exp: now + 600,
606                    typ: typ.to_string(),
607                    user_id,
608                    username: "legacy".to_string(),
609                    is_admin: false,
610                },
611                &EncodingKey::from_secret(config.secret.as_bytes()),
612            )
613            .unwrap()
614        };
615
616        let access = AccessToken::decode(&legacy("access"), &config).unwrap();
617        assert_eq!(access.ver, 0);
618        assert_eq!(access.username, "legacy");
619
620        let refresh = RefreshToken::decode(&legacy("refresh"), &config).unwrap();
621        assert_eq!(refresh.ver, 0);
622    }
623
624    #[test]
625    fn token_hash_is_stable_sha256_hex() {
626        let hash = token_hash("a.b.c");
627
628        assert_eq!(hash.len(), 64);
629        assert!(hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
630        assert_eq!(hash, token_hash("a.b.c"));
631        assert_ne!(hash, token_hash("a.b.d"));
632        // SHA-256 of the empty string.
633        assert_eq!(
634            token_hash(""),
635            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
636        );
637    }
638}