Skip to main content

rskit_auth/jwt/
codec.rs

1use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
2use rskit_errors::{AppError, AppResult, ErrorCode};
3use serde::{Serialize, de::DeserializeOwned};
4
5use super::config::{AsymmetricAlgorithm, JwtAlgorithm, JwtConfig, JwtKeyMaterial};
6
7/// Decoded JWT header metadata exposed without leaking the underlying JWT library.
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub struct JwtHeader {
11    /// Header algorithm.
12    pub algorithm: JwtAlgorithm,
13    /// Token type, commonly `JWT`.
14    pub token_type: Option<String>,
15    /// Key identifier.
16    pub key_id: Option<String>,
17    /// Content type.
18    pub content_type: Option<String>,
19}
20
21impl TryFrom<Header> for JwtHeader {
22    type Error = AppError;
23
24    fn try_from(header: Header) -> AppResult<Self> {
25        Ok(Self {
26            algorithm: algorithm_from_jsonwebtoken(header.alg)?,
27            token_type: header.typ,
28            key_id: header.kid,
29            content_type: header.cty,
30        })
31    }
32}
33
34/// Reusable JWT codec for safe token encoding, header inspection, and validation.
35///
36/// This is the rskit-owned primitive used by [`JwtService`](super::JwtService).
37/// Application code can use it when it needs direct JWT operations without depending on the underlying JWT library.
38pub struct JwtCodec {
39    config: JwtConfig,
40    encoding_key: EncodingKey,
41    decoding_key: DecodingKey,
42}
43
44impl JwtCodec {
45    /// Create a codec from JWT configuration and key material.
46    ///
47    /// # Errors
48    /// Returns an error when the issuer/audience policy or key material is invalid.
49    pub fn new(config: JwtConfig) -> AppResult<Self> {
50        validate_config(&config)?;
51        let (encoding_key, decoding_key) = build_keys(&config.key_material)?;
52        Ok(Self {
53            config,
54            encoding_key,
55            decoding_key,
56        })
57    }
58
59    /// Return this codec's immutable validation/signing configuration.
60    #[must_use]
61    pub const fn config(&self) -> &JwtConfig {
62        &self.config
63    }
64
65    /// Sign claims using the configured algorithm and key material.
66    ///
67    /// This method does not synthesize or mutate registered claims; callers own claim construction,
68    /// while [`decode`](Self::decode) enforces the configured validation policy.
69    ///
70    /// # Errors
71    /// Returns an error when claim serialization or signing fails.
72    pub fn encode<C: Serialize>(&self, claims: &C) -> AppResult<String> {
73        let mut header = Header::new(self.config.algorithm().as_jsonwebtoken());
74        header.typ = Some("JWT".to_string());
75        jsonwebtoken::encode(&header, claims, &self.encoding_key).map_err(|error| {
76            AppError::new(
77                ErrorCode::Internal,
78                format!(
79                    "JWT encode error for {:?}: {error}",
80                    self.config.algorithm()
81                ),
82            )
83        })
84    }
85
86    /// Decode and validate a token using the configured validation policy.
87    ///
88    /// # Errors
89    /// Returns an authentication error when the token is malformed, signed with the wrong algorithm/key,
90    /// expired, immature, or missing required claims.
91    pub fn decode<C: DeserializeOwned>(&self, token: &str) -> AppResult<C> {
92        let header =
93            jsonwebtoken::decode_header(token).map_err(|error| map_validation_error(&error))?;
94        let configured_algorithm = self.config.algorithm().as_jsonwebtoken();
95        if header.alg != configured_algorithm {
96            return Err(AppError::invalid_token().context("JWT algorithm mismatch"));
97        }
98
99        let validation = validation_for(&self.config);
100        let data = jsonwebtoken::decode::<C>(token, &self.decoding_key, &validation)
101            .map_err(|error| map_validation_error(&error))?;
102        Ok(data.claims)
103    }
104
105    /// Decode JWT header metadata without validating the token signature.
106    ///
107    /// # Errors
108    /// Returns an error when the token header is malformed or uses an unsupported algorithm.
109    pub fn decode_header(token: &str) -> AppResult<JwtHeader> {
110        jsonwebtoken::decode_header(token)
111            .map_err(|error| map_validation_error(&error))
112            .and_then(JwtHeader::try_from)
113    }
114}
115
116fn validate_config(config: &JwtConfig) -> AppResult<()> {
117    if config.issuer.trim().is_empty() {
118        return Err(AppError::invalid_input(
119            "issuer",
120            "issuer must not be empty",
121        ));
122    }
123    if config.audience.is_empty() {
124        return Err(AppError::invalid_input(
125            "audience",
126            "at least one audience value is required",
127        ));
128    }
129    if config
130        .audience
131        .iter()
132        .any(|audience| audience.trim().is_empty())
133    {
134        return Err(AppError::invalid_input(
135            "audience",
136            "audience values must not be empty",
137        ));
138    }
139    if config.leeway.as_secs() > 60 {
140        return Err(AppError::invalid_input(
141            "leeway",
142            "clock skew tolerance must be 60 seconds or less",
143        ));
144    }
145    Ok(())
146}
147
148fn build_keys(key_material: &JwtKeyMaterial) -> AppResult<(EncodingKey, DecodingKey)> {
149    match key_material {
150        JwtKeyMaterial::Hs256Internal { secret } => {
151            if secret.is_empty() {
152                return Err(AppError::invalid_input(
153                    "secret",
154                    "HMAC secret must not be empty",
155                ));
156            }
157            if secret.len() < 32 {
158                return Err(AppError::invalid_input(
159                    "secret",
160                    "HMAC secret must be at least 32 bytes",
161                ));
162            }
163            Ok((
164                EncodingKey::from_secret(secret.expose().as_bytes()),
165                DecodingKey::from_secret(secret.expose().as_bytes()),
166            ))
167        }
168        JwtKeyMaterial::Asymmetric { algorithm, keys } => {
169            let priv_pem = keys.private_key_pem.expose().as_bytes();
170            let pub_pem = keys.public_key_pem.expose().as_bytes();
171            let (enc, dec) = match algorithm {
172                AsymmetricAlgorithm::Rs256 => (
173                    EncodingKey::from_rsa_pem(priv_pem),
174                    DecodingKey::from_rsa_pem(pub_pem),
175                ),
176                AsymmetricAlgorithm::Es256 => (
177                    EncodingKey::from_ec_pem(priv_pem),
178                    DecodingKey::from_ec_pem(pub_pem),
179                ),
180                AsymmetricAlgorithm::EdDsa => (
181                    EncodingKey::from_ed_pem(priv_pem),
182                    DecodingKey::from_ed_pem(pub_pem),
183                ),
184            };
185            Ok((
186                enc.map_err(|e| jwt_key_error(&e))?,
187                dec.map_err(|e| jwt_key_error(&e))?,
188            ))
189        }
190    }
191}
192
193fn jwt_key_error(error: &jsonwebtoken::errors::Error) -> AppError {
194    AppError::new(
195        ErrorCode::InvalidInput,
196        format!("invalid JWT key material: {error}"),
197    )
198}
199
200fn validation_for(config: &JwtConfig) -> Validation {
201    let mut validation = Validation::new(config.algorithm().as_jsonwebtoken());
202    validation.leeway = config.leeway.as_secs();
203    validation.validate_nbf = true;
204    validation.set_issuer(&[config.issuer.as_str()]);
205    validation.set_audience(&config.audience);
206    validation.set_required_spec_claims(&["exp", "nbf", "iss", "aud", "sub", "iat"]);
207    validation.algorithms = vec![config.algorithm().as_jsonwebtoken()];
208    validation
209}
210
211fn map_validation_error(error: &jsonwebtoken::errors::Error) -> AppError {
212    match error.kind() {
213        jsonwebtoken::errors::ErrorKind::ExpiredSignature => AppError::token_expired(),
214        jsonwebtoken::errors::ErrorKind::InvalidAlgorithm
215        | jsonwebtoken::errors::ErrorKind::InvalidSignature
216        | jsonwebtoken::errors::ErrorKind::InvalidToken
217        | jsonwebtoken::errors::ErrorKind::InvalidIssuer
218        | jsonwebtoken::errors::ErrorKind::InvalidAudience
219        | jsonwebtoken::errors::ErrorKind::ImmatureSignature
220        | jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(_) => AppError::invalid_token(),
221        _ => AppError::new(
222            ErrorCode::Unauthorized,
223            format!("JWT validation failed: {error}"),
224        ),
225    }
226}
227
228fn algorithm_from_jsonwebtoken(algorithm: jsonwebtoken::Algorithm) -> AppResult<JwtAlgorithm> {
229    match algorithm {
230        jsonwebtoken::Algorithm::HS256 => Ok(JwtAlgorithm::Hs256Internal),
231        jsonwebtoken::Algorithm::RS256 => Ok(JwtAlgorithm::Rs256),
232        jsonwebtoken::Algorithm::ES256 => Ok(JwtAlgorithm::Es256),
233        jsonwebtoken::Algorithm::EdDSA => Ok(JwtAlgorithm::EdDsa),
234        _ => Err(AppError::invalid_token().context("JWT algorithm is not allowed by rskit")),
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use serde::{Deserialize, Serialize};
241
242    use super::*;
243
244    const ISSUER: &str = "https://issuer.example";
245    const AUDIENCE: &str = "rskit-tests";
246
247    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248    struct Claims {
249        sub: String,
250        iss: String,
251        aud: Vec<String>,
252        exp: u64,
253        nbf: u64,
254        iat: u64,
255    }
256
257    fn now() -> u64 {
258        std::time::SystemTime::now()
259            .duration_since(std::time::UNIX_EPOCH)
260            .unwrap()
261            .as_secs()
262    }
263
264    fn claims() -> Claims {
265        let now = now();
266        Claims {
267            sub: "user-1".to_owned(),
268            iss: ISSUER.to_owned(),
269            aud: vec![AUDIENCE.to_owned()],
270            exp: now + 3600,
271            nbf: now.saturating_sub(1),
272            iat: now,
273        }
274    }
275
276    fn codec() -> JwtCodec {
277        JwtCodec::new(JwtConfig::hs256_internal(
278            "codec-test-secret-32-bytes-long!",
279            ISSUER,
280            vec![AUDIENCE.to_owned()],
281        ))
282        .unwrap()
283    }
284
285    #[test]
286    fn codec_roundtrip_and_header_decode_use_rskit_types() {
287        let codec = codec();
288        let token = codec.encode(&claims()).unwrap();
289
290        let header = JwtCodec::decode_header(&token).unwrap();
291        assert_eq!(header.algorithm, JwtAlgorithm::Hs256Internal);
292        assert_eq!(header.token_type.as_deref(), Some("JWT"));
293
294        let decoded: Claims = codec.decode(&token).unwrap();
295        assert_eq!(decoded.sub, "user-1");
296    }
297
298    #[test]
299    fn codec_decode_rejects_missing_required_claims() {
300        let codec = codec();
301        let mut claims = serde_json::json!({
302            "sub": "user-1",
303            "iss": ISSUER,
304            "aud": [AUDIENCE],
305            "exp": now() + 3600,
306            "nbf": now().saturating_sub(1),
307            "iat": now(),
308        });
309        claims.as_object_mut().unwrap().remove("aud");
310        let token = codec.encode(&claims).unwrap();
311
312        let result = codec.decode::<serde_json::Value>(&token);
313
314        assert!(result.is_err());
315    }
316
317    #[test]
318    fn codec_rejects_blank_audience_values() {
319        let err = JwtCodec::new(JwtConfig::hs256_internal(
320            "codec-test-secret-32-bytes-long!",
321            ISSUER,
322            vec![AUDIENCE.to_owned(), " \t ".to_owned()],
323        ))
324        .err()
325        .unwrap();
326
327        assert_eq!(err.code(), ErrorCode::InvalidInput);
328    }
329
330    #[test]
331    fn codec_rejects_invalid_policy_and_key_material() {
332        for config in [
333            JwtConfig::hs256_internal(
334                "codec-test-secret-32-bytes-long!",
335                " \t ",
336                vec![AUDIENCE.to_owned()],
337            ),
338            JwtConfig::hs256_internal("codec-test-secret-32-bytes-long!", ISSUER, Vec::new()),
339            JwtConfig {
340                leeway: std::time::Duration::from_secs(61),
341                ..JwtConfig::hs256_internal(
342                    "codec-test-secret-32-bytes-long!",
343                    ISSUER,
344                    vec![AUDIENCE.to_owned()],
345                )
346            },
347            JwtConfig::hs256_internal("", ISSUER, vec![AUDIENCE.to_owned()]),
348            JwtConfig::hs256_internal("too-short", ISSUER, vec![AUDIENCE.to_owned()]),
349        ] {
350            assert_eq!(
351                JwtCodec::new(config).err().unwrap().code(),
352                ErrorCode::InvalidInput
353            );
354        }
355
356        let asymmetric = JwtConfig::rs256(
357            "not a private key",
358            "not a public key",
359            ISSUER,
360            vec![AUDIENCE.to_owned()],
361        );
362        assert_eq!(
363            JwtCodec::new(asymmetric).err().unwrap().code(),
364            ErrorCode::InvalidInput
365        );
366    }
367
368    #[test]
369    fn codec_rejects_algorithm_mismatch_and_malformed_header() {
370        let codec = codec();
371        let mut header = Header::new(jsonwebtoken::Algorithm::HS384);
372        header.typ = Some("JWT".to_string());
373        let token = jsonwebtoken::encode(
374            &header,
375            &claims(),
376            &EncodingKey::from_secret(b"codec-test-secret-32-bytes-long!"),
377        )
378        .unwrap();
379
380        assert_eq!(
381            codec.decode::<Claims>(&token).unwrap_err().code(),
382            ErrorCode::InvalidToken
383        );
384        assert_eq!(
385            JwtCodec::decode_header("not-a-jwt").unwrap_err().code(),
386            ErrorCode::InvalidToken
387        );
388    }
389}