Skip to main content

auth_password/
jwt.rs

1use chrono::{DateTime, Duration, Utc};
2use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
3use serde::{Deserialize, Serialize};
4
5/// JWT claims carried in every password-auth token when `token_strategy` is `"jwt"`.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Claims {
8    /// Subject — the authenticated user id.
9    pub sub: String,
10    /// Issuer — defaults to `"lenso"`.
11    pub iss: String,
12    /// Audience — defaults to `"lenso"`.
13    pub aud: String,
14    /// Issued-at timestamp (seconds since epoch).
15    pub iat: u64,
16    /// Expiration timestamp (seconds since epoch).
17    pub exp: u64,
18}
19
20/// Derived JWT configuration with owned values suitable for resolver construction.
21#[derive(Debug, Clone)]
22pub struct JwtConfig {
23    pub secret: String,
24    pub issuer: String,
25    pub audience: String,
26    pub ttl_hours: u32,
27}
28
29/// Create a signed JWT for the given user id.
30///
31/// The token carries standard claims (`sub`, `iss`, `aud`, `iat`, `exp`) and
32/// is signed with HMAC-SHA256.
33pub fn create_token(user_id: &str, config: &JwtConfig, now: DateTime<Utc>) -> String {
34    let iat = now.timestamp() as u64;
35    let exp = (now + Duration::hours(i64::from(config.ttl_hours))).timestamp() as u64;
36    let claims = Claims {
37        sub: user_id.to_owned(),
38        iss: config.issuer.clone(),
39        aud: config.audience.clone(),
40        iat,
41        exp,
42    };
43
44    encode(
45        &Header::default(),
46        &claims,
47        &EncodingKey::from_secret(config.secret.as_bytes()),
48    )
49    .expect("JWT encoding is infallible with HMAC-SHA256")
50}
51
52/// Verify a JWT and extract its claims.
53///
54/// Returns `None` if the token is malformed, expired, or fails validation.
55pub fn verify_token(token: &str, config: &JwtConfig) -> Option<Claims> {
56    let mut validation = Validation::default();
57    validation.set_issuer(&[&config.issuer]);
58    validation.set_audience(&[&config.audience]);
59
60    decode::<Claims>(
61        token,
62        &DecodingKey::from_secret(config.secret.as_bytes()),
63        &validation,
64    )
65    .map(|data| data.claims)
66    .ok()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    fn test_config() -> JwtConfig {
74        JwtConfig {
75            secret: "test-secret-key-for-jwt-validation".to_owned(),
76            issuer: "lenso".to_owned(),
77            audience: "lenso".to_owned(),
78            ttl_hours: 1,
79        }
80    }
81
82    #[test]
83    fn roundtrip_jwt_token() {
84        let config = test_config();
85        let now = Utc::now();
86        let token = create_token("usr_abc123", &config, now);
87
88        let claims = verify_token(&token, &config).expect("token should verify");
89        assert_eq!(claims.sub, "usr_abc123");
90        assert_eq!(claims.iss, "lenso");
91        assert_eq!(claims.aud, "lenso");
92    }
93
94    #[test]
95    fn rejects_wrong_secret() {
96        let config = test_config();
97        let now = Utc::now();
98        let token = create_token("usr_abc123", &config, now);
99
100        let mut wrong_config = config.clone();
101        wrong_config.secret = "wrong-secret".to_owned();
102
103        assert!(verify_token(&token, &wrong_config).is_none());
104    }
105
106    #[test]
107    fn rejects_expired_token() {
108        let config = test_config();
109        let past = Utc::now() - Duration::hours(2);
110        let token = create_token("usr_abc123", &config, past);
111
112        assert!(verify_token(&token, &config).is_none());
113    }
114
115    #[test]
116    fn rejects_wrong_audience() {
117        let config = test_config();
118        let now = Utc::now();
119        let token = create_token("usr_abc123", &config, now);
120
121        let mut wrong_config = config.clone();
122        wrong_config.audience = "wrong-audience".to_owned();
123
124        assert!(verify_token(&token, &wrong_config).is_none());
125    }
126}