Skip to main content

shaperail_runtime/auth/
jwt.rs

1use chrono::{Duration, Utc};
2use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
3use serde::{Deserialize, Serialize};
4
5/// JWT claims stored in every access token.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Claims {
8    /// Subject — the user ID.
9    pub sub: String,
10    /// User role.
11    pub role: String,
12    /// Issued at (unix timestamp).
13    pub iat: i64,
14    /// Expiration (unix timestamp).
15    pub exp: i64,
16    /// Token type: "access" or "refresh".
17    #[serde(default = "default_token_type")]
18    pub token_type: String,
19}
20
21fn default_token_type() -> String {
22    "access".to_string()
23}
24
25/// Configuration for JWT signing and validation.
26#[derive(Debug, Clone)]
27pub struct JwtConfig {
28    /// The secret key bytes used for HMAC-SHA256.
29    secret: Vec<u8>,
30    /// Access token lifetime.
31    pub access_ttl: Duration,
32    /// Refresh token lifetime.
33    pub refresh_ttl: Duration,
34}
35
36impl JwtConfig {
37    /// Creates a new JwtConfig from a secret string.
38    ///
39    /// `access_ttl_secs` — lifetime for access tokens in seconds.
40    /// `refresh_ttl_secs` — lifetime for refresh tokens in seconds.
41    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    /// Creates a JwtConfig from the `JWT_SECRET` environment variable.
50    ///
51    /// Returns `None` if the variable is not set or is empty.
52    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        // Default: 24h access, 30d refresh
58        Some(Self::new(&secret, 86400, 2_592_000))
59    }
60
61    /// Encodes an access token for the given user ID and role.
62    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    /// Encodes a refresh token for the given user ID and role.
83    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    /// Decodes and validates a JWT token, returning the claims.
104    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}