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