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 jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::error::AuthError;
13
14const ACCESS_TOKEN_TYPE: &str = "access";
15const REFRESH_TOKEN_TYPE: &str = "refresh";
16
17/// JWT configuration.
18///
19/// # Examples
20///
21/// ```
22/// use ironflow_auth::jwt::JwtConfig;
23///
24/// let config = JwtConfig {
25///     secret: "my-secret-key".to_string(),
26///     access_token_ttl_secs: 900,
27///     refresh_token_ttl_secs: 604800,
28///     cookie_domain: None,
29///     cookie_secure: false,
30/// };
31/// ```
32#[derive(Debug, Clone)]
33pub struct JwtConfig {
34    /// HMAC secret for signing tokens.
35    pub secret: String,
36    /// Access token time-to-live in seconds (default: 900 = 15 min).
37    pub access_token_ttl_secs: i64,
38    /// Refresh token time-to-live in seconds (default: 604800 = 7 days).
39    pub refresh_token_ttl_secs: i64,
40    /// Optional cookie domain (e.g., `.example.com`).
41    pub cookie_domain: Option<String>,
42    /// Whether to set the `Secure` flag on cookies.
43    pub cookie_secure: bool,
44}
45
46/// Claims embedded in an access token.
47#[derive(Debug, Serialize, Deserialize, Clone)]
48pub struct AccessTokenClaims {
49    /// Subject (user_id).
50    pub sub: Uuid,
51    /// Unique token identifier.
52    pub jti: String,
53    /// Issued at (unix timestamp).
54    pub iat: i64,
55    /// Expiration (unix timestamp).
56    pub exp: i64,
57    /// Token type — always "access".
58    pub typ: String,
59    /// User ID.
60    pub user_id: Uuid,
61    /// Username.
62    pub username: String,
63    /// Admin flag.
64    pub is_admin: bool,
65}
66
67/// Claims embedded in a refresh token.
68#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct RefreshTokenClaims {
70    /// Subject (user_id).
71    pub sub: Uuid,
72    /// Unique token identifier.
73    pub jti: String,
74    /// Issued at (unix timestamp).
75    pub iat: i64,
76    /// Expiration (unix timestamp).
77    pub exp: i64,
78    /// Token type — always "refresh".
79    pub typ: String,
80    /// User ID.
81    pub user_id: Uuid,
82    /// Username.
83    pub username: String,
84    /// Admin flag.
85    pub is_admin: bool,
86}
87
88/// A signed access token (JWT string).
89pub struct AccessToken(pub String);
90
91impl AccessToken {
92    /// Create an access token for a user.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`AuthError::Jwt`] if JWT encoding fails.
97    pub fn for_user(
98        user_id: Uuid,
99        username: &str,
100        is_admin: bool,
101        config: &JwtConfig,
102    ) -> Result<Self, AuthError> {
103        let now = Utc::now().timestamp();
104        let claims = AccessTokenClaims {
105            sub: user_id,
106            jti: Uuid::now_v7().to_string(),
107            iat: now,
108            exp: now + config.access_token_ttl_secs,
109            typ: ACCESS_TOKEN_TYPE.to_string(),
110            user_id,
111            username: username.to_string(),
112            is_admin,
113        };
114        let token = jsonwebtoken::encode(
115            &Header::default(),
116            &claims,
117            &EncodingKey::from_secret(config.secret.as_bytes()),
118        )?;
119        Ok(Self(token))
120    }
121
122    /// Decode and validate an access token.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`AuthError::Jwt`] if the token is invalid, expired, or not an access token.
127    pub fn decode(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, AuthError> {
128        let token_data = jsonwebtoken::decode::<AccessTokenClaims>(
129            token,
130            &DecodingKey::from_secret(config.secret.as_bytes()),
131            &Validation::default(),
132        )?;
133        if token_data.claims.typ != ACCESS_TOKEN_TYPE {
134            return Err(AuthError::InvalidToken);
135        }
136        Ok(token_data.claims)
137    }
138}
139
140/// A signed refresh token (JWT string).
141pub struct RefreshToken(pub String);
142
143impl RefreshToken {
144    /// Create a refresh token for a user.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`AuthError::Jwt`] if JWT encoding fails.
149    pub fn for_user(
150        user_id: Uuid,
151        username: &str,
152        is_admin: bool,
153        config: &JwtConfig,
154    ) -> Result<Self, AuthError> {
155        let now = Utc::now().timestamp();
156        let claims = RefreshTokenClaims {
157            sub: user_id,
158            jti: Uuid::now_v7().to_string(),
159            iat: now,
160            exp: now + config.refresh_token_ttl_secs,
161            typ: REFRESH_TOKEN_TYPE.to_string(),
162            user_id,
163            username: username.to_string(),
164            is_admin,
165        };
166        let token = jsonwebtoken::encode(
167            &Header::default(),
168            &claims,
169            &EncodingKey::from_secret(config.secret.as_bytes()),
170        )?;
171        Ok(Self(token))
172    }
173
174    /// Decode and validate a refresh token.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`AuthError::Jwt`] if the token is invalid, expired, or not a refresh token.
179    pub fn decode(token: &str, config: &JwtConfig) -> Result<RefreshTokenClaims, AuthError> {
180        let token_data = jsonwebtoken::decode::<RefreshTokenClaims>(
181            token,
182            &DecodingKey::from_secret(config.secret.as_bytes()),
183            &Validation::default(),
184        )?;
185        if token_data.claims.typ != REFRESH_TOKEN_TYPE {
186            return Err(AuthError::InvalidToken);
187        }
188        Ok(token_data.claims)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn test_config() -> JwtConfig {
197        JwtConfig {
198            secret: "test-secret-key-for-unit-tests".to_string(),
199            access_token_ttl_secs: 900,
200            refresh_token_ttl_secs: 604800,
201            cookie_domain: None,
202            cookie_secure: false,
203        }
204    }
205
206    #[test]
207    fn access_token_round_trip() {
208        let config = test_config();
209        let user_id = Uuid::now_v7();
210        let token = AccessToken::for_user(user_id, "testuser", false, &config).unwrap();
211        let claims = AccessToken::decode(&token.0, &config).unwrap();
212
213        assert_eq!(claims.user_id, user_id);
214        assert_eq!(claims.username, "testuser");
215        assert!(!claims.is_admin);
216        assert_eq!(claims.sub, user_id);
217        assert_eq!(claims.typ, "access");
218    }
219
220    #[test]
221    fn access_token_admin_flag() {
222        let config = test_config();
223        let user_id = Uuid::now_v7();
224        let token = AccessToken::for_user(user_id, "admin", true, &config).unwrap();
225        let claims = AccessToken::decode(&token.0, &config).unwrap();
226
227        assert!(claims.is_admin);
228    }
229
230    #[test]
231    fn access_token_expiry_from_config() {
232        let config = test_config();
233        let user_id = Uuid::now_v7();
234        let before = Utc::now().timestamp();
235        let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
236        let claims = AccessToken::decode(&token.0, &config).unwrap();
237
238        assert!(claims.iat >= before);
239        assert_eq!(claims.exp - claims.iat, config.access_token_ttl_secs);
240    }
241
242    #[test]
243    fn decode_invalid_token_fails() {
244        let config = test_config();
245        let result = AccessToken::decode("not.a.valid.token", &config);
246        assert!(result.is_err());
247    }
248
249    #[test]
250    fn decode_tampered_token_fails() {
251        let config = test_config();
252        let user_id = Uuid::now_v7();
253        let token = AccessToken::for_user(user_id, "user", false, &config).unwrap();
254        let tampered = format!("{}x", &token.0[..token.0.len() - 1]);
255        assert!(AccessToken::decode(&tampered, &config).is_err());
256    }
257
258    #[test]
259    fn expired_token_fails() {
260        let config = test_config();
261        let user_id = Uuid::now_v7();
262        let claims = AccessTokenClaims {
263            sub: user_id,
264            jti: Uuid::now_v7().to_string(),
265            iat: Utc::now().timestamp() - 7200,
266            exp: Utc::now().timestamp() - 3600,
267            typ: "access".to_string(),
268            user_id,
269            username: "expired".to_string(),
270            is_admin: false,
271        };
272        let token_str = jsonwebtoken::encode(
273            &Header::default(),
274            &claims,
275            &EncodingKey::from_secret(config.secret.as_bytes()),
276        )
277        .unwrap();
278        assert!(AccessToken::decode(&token_str, &config).is_err());
279    }
280
281    #[test]
282    fn refresh_token_round_trip() {
283        let config = test_config();
284        let user_id = Uuid::now_v7();
285        let token = RefreshToken::for_user(user_id, "testuser", false, &config).unwrap();
286        let claims = RefreshToken::decode(&token.0, &config).unwrap();
287
288        assert_eq!(claims.user_id, user_id);
289        assert_eq!(claims.username, "testuser");
290        assert_eq!(claims.typ, "refresh");
291        assert_eq!(claims.exp - claims.iat, config.refresh_token_ttl_secs);
292    }
293
294    #[test]
295    fn refresh_token_cannot_be_used_as_access() {
296        let config = test_config();
297        let user_id = Uuid::now_v7();
298        let refresh = RefreshToken::for_user(user_id, "user", false, &config).unwrap();
299        assert!(AccessToken::decode(&refresh.0, &config).is_err());
300    }
301
302    #[test]
303    fn access_token_cannot_be_used_as_refresh() {
304        let config = test_config();
305        let user_id = Uuid::now_v7();
306        let access = AccessToken::for_user(user_id, "user", false, &config).unwrap();
307        assert!(RefreshToken::decode(&access.0, &config).is_err());
308    }
309}