shaperail_runtime/auth/
jwt.rs1use chrono::{Duration, Utc};
2use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Claims {
8 pub sub: String,
10 pub role: String,
12 pub iat: i64,
14 pub exp: i64,
16 #[serde(default = "default_token_type")]
18 pub token_type: String,
19}
20
21fn default_token_type() -> String {
22 "access".to_string()
23}
24
25#[derive(Debug, Clone)]
27pub struct JwtConfig {
28 secret: Vec<u8>,
30 pub access_ttl: Duration,
32 pub refresh_ttl: Duration,
34}
35
36impl JwtConfig {
37 pub fn new(secret: &str, access_ttl_secs: i64, refresh_ttl_secs: i64) -> Self {
42 Self {
43 secret: secret.as_bytes().to_vec(),
44 access_ttl: Duration::seconds(access_ttl_secs),
45 refresh_ttl: Duration::seconds(refresh_ttl_secs),
46 }
47 }
48
49 pub fn from_env() -> Option<Self> {
53 let secret = std::env::var("JWT_SECRET").ok()?;
54 if secret.is_empty() {
55 return None;
56 }
57 Some(Self::new(&secret, 86400, 2_592_000))
59 }
60
61 pub fn encode_access(
63 &self,
64 user_id: &str,
65 role: &str,
66 ) -> Result<String, jsonwebtoken::errors::Error> {
67 let now = Utc::now();
68 let claims = Claims {
69 sub: user_id.to_string(),
70 role: role.to_string(),
71 iat: now.timestamp(),
72 exp: (now + self.access_ttl).timestamp(),
73 token_type: "access".to_string(),
74 };
75 encode(
76 &Header::default(),
77 &claims,
78 &EncodingKey::from_secret(&self.secret),
79 )
80 }
81
82 pub fn encode_refresh(
84 &self,
85 user_id: &str,
86 role: &str,
87 ) -> Result<String, jsonwebtoken::errors::Error> {
88 let now = Utc::now();
89 let claims = Claims {
90 sub: user_id.to_string(),
91 role: role.to_string(),
92 iat: now.timestamp(),
93 exp: (now + self.refresh_ttl).timestamp(),
94 token_type: "refresh".to_string(),
95 };
96 encode(
97 &Header::default(),
98 &claims,
99 &EncodingKey::from_secret(&self.secret),
100 )
101 }
102
103 pub fn decode(&self, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
105 let data = decode::<Claims>(
106 token,
107 &DecodingKey::from_secret(&self.secret),
108 &Validation::default(),
109 )?;
110 Ok(data.claims)
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn test_config() -> JwtConfig {
119 JwtConfig::new("test-secret-key-at-least-32-bytes-long!", 3600, 86400)
120 }
121
122 #[test]
123 fn encode_decode_access_token() {
124 let cfg = test_config();
125 let token = cfg.encode_access("user-123", "admin").unwrap();
126 let claims = cfg.decode(&token).unwrap();
127 assert_eq!(claims.sub, "user-123");
128 assert_eq!(claims.role, "admin");
129 assert_eq!(claims.token_type, "access");
130 }
131
132 #[test]
133 fn encode_decode_refresh_token() {
134 let cfg = test_config();
135 let token = cfg.encode_refresh("user-456", "member").unwrap();
136 let claims = cfg.decode(&token).unwrap();
137 assert_eq!(claims.sub, "user-456");
138 assert_eq!(claims.role, "member");
139 assert_eq!(claims.token_type, "refresh");
140 }
141
142 #[test]
143 fn invalid_token_fails() {
144 let cfg = test_config();
145 let result = cfg.decode("garbage.token.here");
146 assert!(result.is_err());
147 }
148
149 #[test]
150 fn wrong_secret_fails() {
151 let cfg1 = test_config();
152 let cfg2 = JwtConfig::new("different-secret-key-also-long-enough!", 3600, 86400);
153 let token = cfg1.encode_access("user-123", "admin").unwrap();
154 let result = cfg2.decode(&token);
155 assert!(result.is_err());
156 }
157
158 #[test]
159 fn expired_token_fails() {
160 let cfg = JwtConfig::new("test-secret-key-at-least-32-bytes-long!", -120, -120);
161 let token = cfg.encode_access("user-123", "admin").unwrap();
162 let result = cfg.decode(&token);
163 assert!(result.is_err());
164 }
165}