Skip to main content

chopin_auth/
jwt.rs

1//! JWT encoding, decoding, and lifecycle management.
2//!
3//! Central types:
4//! - [`JwtManager`] — sign and verify tokens, configure algorithms and validation.
5//! - [`AuthError`] — typed errors for invalid, expired, and revoked tokens.
6//! - [`HasJti`] — opt-in trait for revocation support via JWT IDs.
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use chopin_auth::jwt::{JwtManager, HasJti};
12//! use serde::{Deserialize, Serialize};
13//!
14//! #[derive(Debug, Serialize, Deserialize)]
15//! struct Claims { sub: String, jti: String, exp: u64 }
16//!
17//! impl HasJti for Claims {
18//!     fn jti(&self) -> Option<&str> { Some(&self.jti) }
19//! }
20//!
21//! let mgr = JwtManager::new(b"my-secret");
22//! let claims = Claims { sub: "user42".into(), jti: "abc".into(), exp: 9999999999 };
23//! let token  = mgr.sign(&claims).unwrap();
24//! let back: Claims = mgr.verify(&token).unwrap();
25//! ```
26// src/jwt.rs
27use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
28use serde::{Deserialize, Serialize};
29use std::fmt;
30use std::sync::Arc;
31
32use crate::revocation::TokenBlacklist;
33
34// ─── Error type ──────────────────────────────────────────────────────────────
35
36/// Errors that can occur during JWT encode / decode operations.
37#[derive(Debug)]
38pub enum AuthError {
39    /// The token is syntactically invalid, has a bad signature, or fails
40    /// validation (e.g. wrong algorithm, audience, issuer).
41    InvalidToken(String),
42    /// The token's `exp` claim has passed.
43    Expired,
44    /// The token's JTI is on the revocation blacklist.
45    Revoked,
46    /// The manager has no encoding key; cannot sign tokens.
47    EncodingKeyMissing,
48    /// Signing the claims failed.
49    Encode(String),
50    /// A configuration or internal error unrelated to the token itself.
51    Internal(String),
52}
53
54impl fmt::Display for AuthError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::InvalidToken(e) => write!(f, "invalid token: {e}"),
58            Self::Expired => f.write_str("token expired"),
59            Self::Revoked => f.write_str("token revoked"),
60            Self::EncodingKeyMissing => f.write_str("no encoding key configured"),
61            Self::Encode(e) => write!(f, "encoding failed: {e}"),
62            Self::Internal(e) => write!(f, "internal error: {e}"),
63        }
64    }
65}
66
67impl std::error::Error for AuthError {}
68
69// ─── HasJti trait ─────────────────────────────────────────────────────────────
70
71/// Implemented by claims types that carry a JWT ID (`jti`) for revocation checks.
72///
73/// The **default implementation returns `None`**, so any claims type can opt out
74/// of revocation with an empty one-line impl:
75///
76/// ```rust
77/// # use chopin_auth::HasJti;
78/// struct MyClaims { sub: String, exp: u64 }
79/// impl HasJti for MyClaims {}
80/// ```
81///
82/// To enable revocation, return the `jti` field:
83///
84/// ```rust
85/// # use chopin_auth::HasJti;
86/// struct MyClaims { sub: String, jti: String, exp: u64 }
87/// impl HasJti for MyClaims {
88///     fn jti(&self) -> Option<&str> { Some(&self.jti) }
89/// }
90/// ```
91pub trait HasJti {
92    /// Return the `jti` claim value, or `None` if the claims do not carry one.
93    fn jti(&self) -> Option<&str> {
94        None
95    }
96}
97
98// ─── JwtConfig ───────────────────────────────────────────────────────────────
99
100/// Low-level configuration for a [`JwtManager`].
101///
102/// Prefer the constructor methods on [`JwtManager`] over constructing this directly.
103pub struct JwtConfig {
104    pub decoding_key: DecodingKey,
105    pub encoding_key: Option<EncodingKey>,
106    pub validation: Validation,
107}
108
109// ─── JwtManager ──────────────────────────────────────────────────────────────
110
111/// A cloneable JWT manager for encoding and decoding tokens.
112///
113/// Constructors:
114/// - [`JwtManager::new`]                – HMAC-SHA256 (sign + verify)
115/// - [`JwtManager::verify_only`]        – HMAC-SHA256 (verify only)
116/// - [`JwtManager::from_rsa_pem`]       – RS256 (sign + verify)
117/// - [`JwtManager::from_rsa_public_pem`] – RS256 (verify only)
118/// - [`JwtManager::from_ec_pem`]        – ES256 (sign + verify)
119/// - [`JwtManager::from_ec_public_pem`] – ES256 (verify only)
120/// - [`JwtManager::with_config`]        – fully custom config
121///
122/// Add a revocation blacklist with [`JwtManager::with_blacklist`].
123#[derive(Clone)]
124pub struct JwtManager {
125    config: Arc<JwtConfig>,
126    blacklist: Option<TokenBlacklist>,
127}
128
129impl JwtManager {
130    /// Construct a manager using a shared HMAC-SHA256 secret (sign + verify).
131    pub fn new(secret: &[u8]) -> Self {
132        let mut validation = Validation::new(Algorithm::HS256);
133        validation.leeway = 60; // 1 minute clock-skew leeway
134        Self {
135            config: Arc::new(JwtConfig {
136                decoding_key: DecodingKey::from_secret(secret),
137                encoding_key: Some(EncodingKey::from_secret(secret)),
138                validation,
139            }),
140            blacklist: None,
141        }
142    }
143
144    /// Construct a verify-only manager (no signing key) for HMAC-SHA256.
145    pub fn verify_only(secret: &[u8]) -> Self {
146        let mut validation = Validation::new(Algorithm::HS256);
147        validation.leeway = 60;
148        Self {
149            config: Arc::new(JwtConfig {
150                decoding_key: DecodingKey::from_secret(secret),
151                encoding_key: None,
152                validation,
153            }),
154            blacklist: None,
155        }
156    }
157
158    /// Construct a manager from a PEM-encoded RSA private/public key pair (RS256).
159    ///
160    /// Both the private key (for signing) and the public key (for verification)
161    /// must be provided. For verify-only use, see [`JwtManager::from_rsa_public_pem`].
162    pub fn from_rsa_pem(private_key_pem: &[u8], public_key_pem: &[u8]) -> Result<Self, AuthError> {
163        let encoding_key = EncodingKey::from_rsa_pem(private_key_pem)
164            .map_err(|e| AuthError::Internal(format!("RSA private key: {e}")))?;
165        let decoding_key = DecodingKey::from_rsa_pem(public_key_pem)
166            .map_err(|e| AuthError::Internal(format!("RSA public key: {e}")))?;
167        Ok(Self {
168            config: Arc::new(JwtConfig {
169                encoding_key: Some(encoding_key),
170                decoding_key,
171                validation: Validation::new(Algorithm::RS256),
172            }),
173            blacklist: None,
174        })
175    }
176
177    /// Construct a **verify-only** manager from a PEM-encoded RSA public key (RS256).
178    ///
179    /// No signing key is stored; calling [`JwtManager::encode`] will return
180    /// [`AuthError::EncodingKeyMissing`]. Ideal for microservices that only
181    /// consume tokens, never issue them.
182    pub fn from_rsa_public_pem(public_key_pem: &[u8]) -> Result<Self, AuthError> {
183        let decoding_key = DecodingKey::from_rsa_pem(public_key_pem)
184            .map_err(|e| AuthError::Internal(format!("RSA public key: {e}")))?;
185        Ok(Self {
186            config: Arc::new(JwtConfig {
187                encoding_key: None,
188                decoding_key,
189                validation: Validation::new(Algorithm::RS256),
190            }),
191            blacklist: None,
192        })
193    }
194
195    /// Construct a manager from a PEM-encoded EC private/public key pair (ES256).
196    ///
197    /// Both keys are required for signing and verification. For verify-only use,
198    /// see [`JwtManager::from_ec_public_pem`].
199    pub fn from_ec_pem(private_key_pem: &[u8], public_key_pem: &[u8]) -> Result<Self, AuthError> {
200        let encoding_key = EncodingKey::from_ec_pem(private_key_pem)
201            .map_err(|e| AuthError::Internal(format!("EC private key: {e}")))?;
202        let decoding_key = DecodingKey::from_ec_pem(public_key_pem)
203            .map_err(|e| AuthError::Internal(format!("EC public key: {e}")))?;
204        Ok(Self {
205            config: Arc::new(JwtConfig {
206                encoding_key: Some(encoding_key),
207                decoding_key,
208                validation: Validation::new(Algorithm::ES256),
209            }),
210            blacklist: None,
211        })
212    }
213
214    /// Construct a **verify-only** manager from a PEM-encoded EC public key (ES256).
215    ///
216    /// No signing key is stored; calling [`JwtManager::encode`] will return
217    /// [`AuthError::EncodingKeyMissing`]. Ideal for microservices that only
218    /// consume tokens, never issue them.
219    pub fn from_ec_public_pem(public_key_pem: &[u8]) -> Result<Self, AuthError> {
220        let decoding_key = DecodingKey::from_ec_pem(public_key_pem)
221            .map_err(|e| AuthError::Internal(format!("EC public key: {e}")))?;
222        Ok(Self {
223            config: Arc::new(JwtConfig {
224                encoding_key: None,
225                decoding_key,
226                validation: Validation::new(Algorithm::ES256),
227            }),
228            blacklist: None,
229        })
230    }
231
232    /// Construct a manager from a fully custom [`JwtConfig`].
233    pub fn with_config(config: JwtConfig) -> Self {
234        Self {
235            config: Arc::new(config),
236            blacklist: None,
237        }
238    }
239
240    /// Attach a revocation blacklist, returning the updated manager.
241    ///
242    /// ```rust,ignore
243    /// let bl = TokenBlacklist::new();
244    /// let manager = JwtManager::new(b"secret").with_blacklist(bl.clone());
245    /// // Later: bl.revoke(jti, Some(exp));
246    /// ```
247    pub fn with_blacklist(mut self, blacklist: TokenBlacklist) -> Self {
248        self.blacklist = Some(blacklist);
249        self
250    }
251
252    /// Decode and verify a JWT, optionally checking revocation.
253    ///
254    /// If a [`TokenBlacklist`] is attached and `T::jti()` returns `Some(jti)`,
255    /// the JTI is checked for revocation after the signature is verified.
256    ///
257    /// # Errors
258    /// - [`AuthError::Expired`]      – the `exp` claim has passed.
259    /// - [`AuthError::Revoked`]      – the JTI is on the blacklist.
260    /// - [`AuthError::InvalidToken`] – signature or format error.
261    pub fn decode<T>(&self, token: &str) -> Result<T, AuthError>
262    where
263        T: for<'de> Deserialize<'de> + HasJti,
264    {
265        let token_data = decode::<T>(token, &self.config.decoding_key, &self.config.validation)
266            .map_err(|e| match e.kind() {
267                jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::Expired,
268                _ => AuthError::InvalidToken(e.to_string()),
269            })?;
270
271        if let Some(bl) = &self.blacklist
272            && let Some(jti) = token_data.claims.jti()
273            && bl.is_revoked(jti)
274        {
275            return Err(AuthError::Revoked);
276        }
277
278        Ok(token_data.claims)
279    }
280
281    /// Sign a set of claims, returning a compact JWT string.
282    ///
283    /// # Errors
284    /// - [`AuthError::EncodingKeyMissing`] – no signing key is configured.
285    /// - [`AuthError::Encode`]             – claims serialisation failed.
286    pub fn encode<T: Serialize>(&self, claims: &T) -> Result<String, AuthError> {
287        let key = self
288            .config
289            .encoding_key
290            .as_ref()
291            .ok_or(AuthError::EncodingKeyMissing)?;
292        encode(&Header::default(), claims, key).map_err(|e| AuthError::Encode(e.to_string()))
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[derive(Debug, PartialEq, Serialize, Deserialize)]
301    struct TestClaims {
302        sub: String,
303        exp: u64,
304    }
305
306    // Opt out of revocation — no `jti` field.
307    impl HasJti for TestClaims {}
308
309    fn far_future_exp() -> u64 {
310        // 9999-01-01 00:00:00 UTC as a Unix timestamp
311        253_370_764_800_u64
312    }
313
314    #[test]
315    fn test_encode_decode_roundtrip() {
316        let mgr = JwtManager::new(b"test-secret-key");
317        let claims = TestClaims {
318            sub: "user-42".to_string(),
319            exp: far_future_exp(),
320        };
321        let token = mgr.encode(&claims).expect("encode should succeed");
322        assert!(!token.is_empty());
323        let decoded: TestClaims = mgr.decode(&token).expect("decode should succeed");
324        assert_eq!(decoded, claims);
325    }
326
327    #[test]
328    fn test_decode_wrong_secret_fails() {
329        let mgr_sign = JwtManager::new(b"correct-secret");
330        let mgr_verify = JwtManager::new(b"wrong-secret");
331        let claims = TestClaims {
332            sub: "user-1".to_string(),
333            exp: far_future_exp(),
334        };
335        let token = mgr_sign.encode(&claims).expect("encode must succeed");
336        let result: Result<TestClaims, _> = mgr_verify.decode(&token);
337        assert!(result.is_err(), "decode with wrong secret should fail");
338    }
339
340    #[test]
341    fn test_decode_invalid_token_fails() {
342        let mgr = JwtManager::new(b"any-secret");
343        let result: Result<TestClaims, _> = mgr.decode("not.a.jwt");
344        assert!(result.is_err(), "decode of garbage should fail");
345    }
346
347    #[test]
348    fn test_decode_mangled_token_fails() {
349        let mgr = JwtManager::new(b"secret");
350        let claims = TestClaims {
351            sub: "u".to_string(),
352            exp: far_future_exp(),
353        };
354        let mut token = mgr.encode(&claims).expect("encode ok");
355        // flip last byte of signature
356        let last = token.pop().unwrap();
357        token.push(if last == 'A' { 'B' } else { 'A' });
358        let result: Result<TestClaims, _> = mgr.decode(&token);
359        assert!(result.is_err(), "mangled token should fail");
360    }
361
362    #[test]
363    fn test_clone_shares_key() {
364        let mgr1 = JwtManager::new(b"shared-key");
365        let mgr2 = mgr1.clone();
366        let claims = TestClaims {
367            sub: "u".to_string(),
368            exp: far_future_exp(),
369        };
370        let token = mgr1.encode(&claims).unwrap();
371        // mgr2 must decode tokens signed with mgr1 (shares Arc<JwtConfig>)
372        let decoded: TestClaims = mgr2.decode(&token).expect("clone should decode");
373        assert_eq!(decoded.sub, "u");
374    }
375
376    #[test]
377    fn test_encode_without_key_returns_error() {
378        let config = JwtConfig {
379            decoding_key: DecodingKey::from_secret(b"secret"),
380            encoding_key: None,
381            validation: Validation::new(Algorithm::HS256),
382        };
383        let mgr = JwtManager::with_config(config);
384        let claims = TestClaims {
385            sub: "x".to_string(),
386            exp: far_future_exp(),
387        };
388        let result = mgr.encode(&claims);
389        assert!(
390            matches!(result, Err(AuthError::EncodingKeyMissing)),
391            "expected EncodingKeyMissing, got {result:?}"
392        );
393    }
394
395    #[test]
396    fn test_revoked_token_rejected() {
397        use crate::revocation::TokenBlacklist;
398
399        #[derive(Debug, PartialEq, Serialize, Deserialize)]
400        struct ClaimsWithJti {
401            sub: String,
402            jti: String,
403            exp: u64,
404        }
405        impl HasJti for ClaimsWithJti {
406            fn jti(&self) -> Option<&str> {
407                Some(&self.jti)
408            }
409        }
410
411        let blacklist = TokenBlacklist::new();
412        let mgr = JwtManager::new(b"s").with_blacklist(blacklist.clone());
413        let claims = ClaimsWithJti {
414            sub: "u".into(),
415            jti: "unique-jti-1".into(),
416            exp: far_future_exp(),
417        };
418        let token = mgr.encode(&claims).unwrap();
419
420        // Valid before revocation.
421        mgr.decode::<ClaimsWithJti>(&token)
422            .expect("should be valid before revocation");
423
424        // Revoke and verify rejection.
425        blacklist.revoke("unique-jti-1".into(), None);
426        let result = mgr.decode::<ClaimsWithJti>(&token);
427        assert!(
428            matches!(result, Err(AuthError::Revoked)),
429            "revoked token should be rejected, got {result:?}"
430        );
431    }
432
433    #[test]
434    fn test_expired_token_returns_expired_error() {
435        let mgr = JwtManager::new(b"secret");
436        // exp = 1 — Unix epoch 1970-01-01, long past
437        let claims = serde_json::json!({ "sub": "u", "exp": 1_u64 });
438        let token = encode(
439            &Header::default(),
440            &claims,
441            &EncodingKey::from_secret(b"secret"),
442        )
443        .unwrap();
444        let result: Result<TestClaims, _> = mgr.decode(&token);
445        assert!(
446            matches!(result, Err(AuthError::Expired)),
447            "expected Expired, got {result:?}"
448        );
449    }
450}