lmrc_http_common/auth/
jwt.rs

1//! JWT token generation and validation
2
3use chrono::{Duration, Utc};
4use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8/// JWT-related errors
9#[derive(Debug, Error)]
10pub enum JwtError {
11    #[error("Failed to create token: {0}")]
12    CreateError(String),
13
14    #[error("Invalid token: {0}")]
15    InvalidToken(String),
16
17    #[error("Token expired")]
18    Expired,
19
20    #[error("Missing claim: {0}")]
21    MissingClaim(String),
22}
23
24/// JWT configuration
25#[derive(Debug, Clone)]
26pub struct JwtConfig {
27    /// Secret key for signing tokens
28    pub secret: String,
29    /// Token expiration in seconds
30    pub expiration_seconds: i64,
31    /// Token issuer
32    pub issuer: Option<String>,
33    /// Token audience
34    pub audience: Option<String>,
35}
36
37impl JwtConfig {
38    pub fn new(secret: impl Into<String>, expiration_seconds: i64) -> Self {
39        Self {
40            secret: secret.into(),
41            expiration_seconds,
42            issuer: None,
43            audience: None,
44        }
45    }
46
47    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
48        self.issuer = Some(issuer.into());
49        self
50    }
51
52    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
53        self.audience = Some(audience.into());
54        self
55    }
56}
57
58/// Standard JWT claims
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct JwtClaims {
61    /// Subject (user ID)
62    pub sub: String,
63    /// Issued at timestamp
64    pub iat: i64,
65    /// Expiration timestamp
66    pub exp: i64,
67    /// Issuer
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub iss: Option<String>,
70    /// Audience
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub aud: Option<String>,
73    /// User email
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub email: Option<String>,
76    /// Custom claims
77    #[serde(flatten)]
78    pub custom: serde_json::Map<String, serde_json::Value>,
79}
80
81impl JwtClaims {
82    /// Create new claims with subject and expiration
83    pub fn new(subject: impl Into<String>, expires_in: Duration) -> Self {
84        let now = Utc::now();
85        Self {
86            sub: subject.into(),
87            iat: now.timestamp(),
88            exp: (now + expires_in).timestamp(),
89            iss: None,
90            aud: None,
91            email: None,
92            custom: serde_json::Map::new(),
93        }
94    }
95
96    pub fn with_email(mut self, email: impl Into<String>) -> Self {
97        self.email = Some(email.into());
98        self
99    }
100
101    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
102        self.iss = Some(issuer.into());
103        self
104    }
105
106    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
107        self.aud = Some(audience.into());
108        self
109    }
110
111    pub fn add_custom_claim(
112        mut self,
113        key: impl Into<String>,
114        value: impl Into<serde_json::Value>,
115    ) -> Self {
116        self.custom.insert(key.into(), value.into());
117        self
118    }
119
120    /// Check if token is expired
121    pub fn is_expired(&self) -> bool {
122        Utc::now().timestamp() > self.exp
123    }
124}
125
126/// Create a JWT token
127///
128/// # Example
129///
130/// ```rust
131/// use lmrc_http_common::auth::{JwtConfig, JwtClaims, create_token};
132/// use chrono::Duration;
133///
134/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
135/// let config = JwtConfig::new("my_secret_key", 3600);
136/// let claims = JwtClaims::new("user_123", Duration::hours(1))
137///     .with_email("user@example.com");
138///
139/// let token = create_token(&claims, &config)?;
140/// # Ok(())
141/// # }
142/// ```
143pub fn create_token(claims: &JwtClaims, config: &JwtConfig) -> Result<String, JwtError> {
144    encode(
145        &Header::default(),
146        claims,
147        &EncodingKey::from_secret(config.secret.as_bytes()),
148    )
149    .map_err(|e| JwtError::CreateError(e.to_string()))
150}
151
152/// Verify and decode a JWT token
153///
154/// # Example
155///
156/// ```rust
157/// use lmrc_http_common::auth::{JwtConfig, JwtClaims, create_token, verify_token};
158/// use chrono::Duration;
159///
160/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
161/// let config = JwtConfig::new("my_secret_key", 3600);
162/// let claims = JwtClaims::new("user_123", Duration::hours(1));
163///
164/// let token = create_token(&claims, &config)?;
165/// let decoded = verify_token(&token, &config)?;
166///
167/// assert_eq!(decoded.sub, "user_123");
168/// # Ok(())
169/// # }
170/// ```
171pub fn verify_token(token: &str, config: &JwtConfig) -> Result<JwtClaims, JwtError> {
172    let mut validation = Validation::default();
173
174    if let Some(ref iss) = config.issuer {
175        validation.set_issuer(&[iss]);
176    }
177
178    if let Some(ref aud) = config.audience {
179        validation.set_audience(&[aud]);
180    }
181
182    let token_data = decode::<JwtClaims>(
183        token,
184        &DecodingKey::from_secret(config.secret.as_bytes()),
185        &validation,
186    )
187    .map_err(|e| {
188        if e.to_string().contains("ExpiredSignature") {
189            JwtError::Expired
190        } else {
191            JwtError::InvalidToken(e.to_string())
192        }
193    })?;
194
195    Ok(token_data.claims)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_create_and_verify_token() {
204        let config = JwtConfig::new("test_secret", 3600);
205        let claims = JwtClaims::new("user_123", Duration::hours(1))
206            .with_email("test@example.com");
207
208        let token = create_token(&claims, &config).unwrap();
209        let decoded = verify_token(&token, &config).unwrap();
210
211        assert_eq!(decoded.sub, "user_123");
212        assert_eq!(decoded.email, Some("test@example.com".to_string()));
213    }
214
215    #[test]
216    fn test_expired_token() {
217        let config = JwtConfig::new("test_secret", 3600);
218        let mut claims = JwtClaims::new("user_123", Duration::hours(1));
219        // Force expiration
220        claims.exp = Utc::now().timestamp() - 100;
221
222        let token = create_token(&claims, &config).unwrap();
223        let result = verify_token(&token, &config);
224
225        assert!(matches!(result, Err(JwtError::Expired)));
226    }
227
228    #[test]
229    fn test_invalid_secret() {
230        let config = JwtConfig::new("test_secret", 3600);
231        let claims = JwtClaims::new("user_123", Duration::hours(1));
232
233        let token = create_token(&claims, &config).unwrap();
234
235        let wrong_config = JwtConfig::new("wrong_secret", 3600);
236        let result = verify_token(&token, &wrong_config);
237
238        assert!(matches!(result, Err(JwtError::InvalidToken(_))));
239    }
240}