1use 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#[derive(Debug, Clone)]
33pub struct JwtConfig {
34 pub secret: String,
36 pub access_token_ttl_secs: i64,
38 pub refresh_token_ttl_secs: i64,
40 pub cookie_domain: Option<String>,
42 pub cookie_secure: bool,
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone)]
48pub struct AccessTokenClaims {
49 pub sub: Uuid,
51 pub jti: String,
53 pub iat: i64,
55 pub exp: i64,
57 pub typ: String,
59 pub user_id: Uuid,
61 pub username: String,
63 pub is_admin: bool,
65}
66
67#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct RefreshTokenClaims {
70 pub sub: Uuid,
72 pub jti: String,
74 pub iat: i64,
76 pub exp: i64,
78 pub typ: String,
80 pub user_id: Uuid,
82 pub username: String,
84 pub is_admin: bool,
86}
87
88pub struct AccessToken(pub String);
90
91impl AccessToken {
92 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 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
140pub struct RefreshToken(pub String);
142
143impl RefreshToken {
144 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 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}