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    /// Tenant ID (M18). Optional — present when multi-tenancy is enabled.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub tenant_id: Option<String>,
22}
23
24fn default_token_type() -> String {
25    "access".to_string()
26}
27
28/// Configuration for JWT signing and validation.
29#[derive(Debug, Clone)]
30pub struct JwtConfig {
31    /// The secret key bytes used for HMAC-SHA256.
32    secret: Vec<u8>,
33    /// Access token lifetime.
34    pub access_ttl: Duration,
35    /// Refresh token lifetime.
36    pub refresh_ttl: Duration,
37}
38
39impl JwtConfig {
40    /// Creates a new JwtConfig from a secret string.
41    ///
42    /// `access_ttl_secs` — lifetime for access tokens in seconds.
43    /// `refresh_ttl_secs` — lifetime for refresh tokens in seconds.
44    pub fn new(secret: &str, access_ttl_secs: i64, refresh_ttl_secs: i64) -> Self {
45        Self {
46            secret: secret.as_bytes().to_vec(),
47            access_ttl: Duration::seconds(access_ttl_secs),
48            refresh_ttl: Duration::seconds(refresh_ttl_secs),
49        }
50    }
51
52    /// Creates a JwtConfig from the `JWT_SECRET` environment variable.
53    ///
54    /// Returns `None` if the variable is not set or is empty.
55    pub fn from_env() -> Option<Self> {
56        let secret = std::env::var("JWT_SECRET").ok()?;
57        if secret.is_empty() {
58            return None;
59        }
60        // Default: 24h access, 30d refresh
61        Some(Self::new(&secret, 86400, 2_592_000))
62    }
63
64    /// Encodes an access token for the given user ID and role.
65    pub fn encode_access(
66        &self,
67        user_id: &str,
68        role: &str,
69    ) -> Result<String, jsonwebtoken::errors::Error> {
70        self.encode_access_with_tenant(user_id, role, None)
71    }
72
73    /// Encodes an access token with an optional tenant_id claim (M18).
74    pub fn encode_access_with_tenant(
75        &self,
76        user_id: &str,
77        role: &str,
78        tenant_id: Option<&str>,
79    ) -> Result<String, jsonwebtoken::errors::Error> {
80        let now = Utc::now();
81        let claims = Claims {
82            sub: user_id.to_string(),
83            role: role.to_string(),
84            iat: now.timestamp(),
85            exp: (now + self.access_ttl).timestamp(),
86            token_type: "access".to_string(),
87            tenant_id: tenant_id.map(ToString::to_string),
88        };
89        encode(
90            &Header::default(),
91            &claims,
92            &EncodingKey::from_secret(&self.secret),
93        )
94    }
95
96    /// Encodes a refresh token for the given user ID and role.
97    pub fn encode_refresh(
98        &self,
99        user_id: &str,
100        role: &str,
101    ) -> Result<String, jsonwebtoken::errors::Error> {
102        let now = Utc::now();
103        let claims = Claims {
104            sub: user_id.to_string(),
105            role: role.to_string(),
106            iat: now.timestamp(),
107            exp: (now + self.refresh_ttl).timestamp(),
108            token_type: "refresh".to_string(),
109            tenant_id: None,
110        };
111        encode(
112            &Header::default(),
113            &claims,
114            &EncodingKey::from_secret(&self.secret),
115        )
116    }
117
118    /// Decodes and validates a JWT token, returning the claims.
119    pub fn decode(&self, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
120        let data = decode::<Claims>(
121            token,
122            &DecodingKey::from_secret(&self.secret),
123            &Validation::default(),
124        )?;
125        Ok(data.claims)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn test_config() -> JwtConfig {
134        JwtConfig::new("test-secret-key-at-least-32-bytes-long!", 3600, 86400)
135    }
136
137    #[test]
138    fn encode_decode_access_token() {
139        let cfg = test_config();
140        let token = cfg.encode_access("user-123", "admin").unwrap();
141        let claims = cfg.decode(&token).unwrap();
142        assert_eq!(claims.sub, "user-123");
143        assert_eq!(claims.role, "admin");
144        assert_eq!(claims.token_type, "access");
145    }
146
147    #[test]
148    fn encode_decode_refresh_token() {
149        let cfg = test_config();
150        let token = cfg.encode_refresh("user-456", "member").unwrap();
151        let claims = cfg.decode(&token).unwrap();
152        assert_eq!(claims.sub, "user-456");
153        assert_eq!(claims.role, "member");
154        assert_eq!(claims.token_type, "refresh");
155    }
156
157    #[test]
158    fn invalid_token_fails() {
159        let cfg = test_config();
160        let result = cfg.decode("garbage.token.here");
161        assert!(result.is_err());
162    }
163
164    #[test]
165    fn wrong_secret_fails() {
166        let cfg1 = test_config();
167        let cfg2 = JwtConfig::new("different-secret-key-also-long-enough!", 3600, 86400);
168        let token = cfg1.encode_access("user-123", "admin").unwrap();
169        let result = cfg2.decode(&token);
170        assert!(result.is_err());
171    }
172
173    #[test]
174    fn expired_token_fails() {
175        let cfg = JwtConfig::new("test-secret-key-at-least-32-bytes-long!", -120, -120);
176        let token = cfg.encode_access("user-123", "admin").unwrap();
177        let result = cfg.decode(&token);
178        assert!(result.is_err());
179    }
180
181    #[test]
182    fn encode_access_with_tenant_id() {
183        let cfg = test_config();
184        let token = cfg
185            .encode_access_with_tenant("user-123", "admin", Some("org-abc"))
186            .unwrap();
187        let claims = cfg.decode(&token).unwrap();
188        assert_eq!(claims.sub, "user-123");
189        assert_eq!(claims.role, "admin");
190        assert_eq!(claims.tenant_id.as_deref(), Some("org-abc"));
191    }
192
193    #[test]
194    fn encode_access_without_tenant_id() {
195        let cfg = test_config();
196        let token = cfg.encode_access("user-123", "admin").unwrap();
197        let claims = cfg.decode(&token).unwrap();
198        assert!(claims.tenant_id.is_none());
199    }
200}