Skip to main content

huskarl_core/jwt/validator/
mod.rs

1//! Validation infrastructure for JWT tokens.
2
3use std::{collections::HashSet, sync::Arc};
4
5use bon::Builder;
6use serde::Deserialize;
7use snafu::{ResultExt as _, Snafu, ensure};
8
9use crate::{
10    crypto::verifier::{JwsVerifier, KeyMatch, VerifyError},
11    jwt::{JtiUniquenessChecker, JwsParseError, ParsedJws, parse_compact_jws},
12    platform::{Duration, SystemTime},
13};
14
15mod checks;
16mod claim_check;
17mod validated_jwt;
18
19use checks::{check_aud, check_str_claim, check_temporal, check_typ};
20pub use checks::{within_max_age, within_max_age_secs};
21pub use claim_check::ClaimCheck;
22pub use validated_jwt::{ValidatedJwt, ValidatedJwtBuilder};
23
24/// Validates a JWT: verifies the JWS signature and checks the registered claims
25/// (`iss`/`sub`/`aud`/`typ`/`exp`/`iat`/`nbf`, and optionally `jti` uniqueness).
26///
27/// Build one with [`builder`](Self::builder); call [`validate`](Self::validate)
28/// to verify a token and recover its claims.
29///
30/// # Warning
31///
32/// The defaults check **nothing beyond the signature and temporal claims**:
33///
34/// - `aud` and `iss` default to [`ClaimCheck::NoCheck`], so a bare
35///   `JwtValidator::builder().verifier(v).build()` accepts a validly-signed
36///   token minted for **any audience by any trusted-key issuer** โ€” the classic
37///   audience-confusion vulnerability. Set `.aud("your-audience")` and
38///   `.iss("https://issuer")` unless cross-audience acceptance is intentional.
39/// - `exp` is **not** required: a token with no expiry is accepted. Set
40///   `require_exp(true)` unless non-expiring tokens are intentional.
41///
42/// The bundled profiles (RFC 9068 access tokens, OIDC ID tokens) already
43/// require the audience, issuer, and expiry.
44#[allow(clippy::struct_excessive_bools)]
45#[allow(clippy::should_implement_trait)] // `sub` is the JWT claim name, not arithmetic subtraction
46#[derive(Debug, Builder)]
47pub struct JwtValidator {
48    /// JWS verifier to use for token validation.
49    #[builder(with = |verifier: impl JwsVerifier + 'static| Arc::new(verifier) as Arc<dyn JwsVerifier>)]
50    verifier: Arc<dyn JwsVerifier>,
51    /// Check on the `iss` claim. Defaults to [`ClaimCheck::NoCheck`] โ€” see the
52    /// [Warning](Self#warning). A plain string means "present and equal".
53    #[builder(default, into)]
54    iss: ClaimCheck,
55    /// Check on the `sub` claim. A plain string means "present and equal".
56    #[builder(default, into)]
57    sub: ClaimCheck,
58    /// Check on the `aud` claim. Defaults to [`ClaimCheck::NoCheck`] โ€” see the
59    /// [Warning](Self#warning). A plain string means "present and equal".
60    #[builder(default, into)]
61    aud: ClaimCheck,
62    /// Type to validate against. A plain string means "present and equal".
63    #[builder(default, into)]
64    typ: ClaimCheck,
65    /// The `exp` claim is required.
66    #[builder(default)]
67    require_exp: bool,
68    /// The `iat` claim is required.
69    #[builder(default)]
70    require_iat: bool,
71    /// The `jti` claim is required.
72    #[builder(default)]
73    require_jti: bool,
74    /// Maximum allowed byte length for the `jti` claim. Defaults to 255.
75    #[builder(default = 255)]
76    max_jti_len: usize,
77    /// Optional checker used to reject replayed `jti` values; see
78    /// [`JtiUniquenessChecker`].
79    #[builder(with = |checker: impl JtiUniquenessChecker + 'static| Arc::new(checker) as Arc<dyn JtiUniquenessChecker>)]
80    jti_checker: Option<Arc<dyn JtiUniquenessChecker>>,
81    /// Maximum token age to validate against.
82    max_token_age: Option<Duration>,
83    /// Leeway applied to the time-based checks (`exp`, `nbf`, `iat`, and
84    /// `max_token_age`) to tolerate clock skew. Defaults to none.
85    #[builder(default)]
86    clock_leeway: Duration,
87    /// Specifies which `crit` values as allowed.
88    ///
89    /// By default, no values are understood, and a token
90    /// containing any values will be rejected. Values may
91    /// be added, where the user of the token is able to
92    /// understand and handle the corresponding extension.
93    #[builder(default, with = FromIterator::from_iter)]
94    allowed_crit: HashSet<String>,
95    /// If set, restricts accepted signature algorithms to this set.
96    ///
97    /// Per OIDC Core ยง3.1.3.7 step 7, the `alg` value SHOULD be `RS256` or the algorithm
98    /// registered during client registration. Use this to enforce an allowlist.
99    ///
100    /// Regardless of this setting, the algorithm `"none"` is always rejected.
101    #[builder(with = FromIterator::from_iter)]
102    allowed_algorithms: Option<HashSet<String>>,
103}
104
105impl JwtValidator {
106    fn validate_header(&self, alg: &str, crit: &[String]) -> Result<(), JwtValidationError> {
107        ensure!(alg != "none", UnsignedTokenSnafu);
108
109        if let Some(allowed) = &self.allowed_algorithms {
110            ensure!(
111                allowed.contains(alg),
112                DisallowedAlgorithmSnafu {
113                    alg: alg.to_string()
114                }
115            );
116        }
117
118        ensure!(
119            crit.iter().all(|v| self.allowed_crit.contains(v)),
120            UnrecognizedCriticalHeaderSnafu {
121                params: crit.to_vec()
122            }
123        );
124
125        Ok(())
126    }
127
128    async fn validate_jti(&self, jti: Option<&str>) -> Result<(), JwtValidationError> {
129        if let Some(jti) = jti {
130            ensure!(
131                jti.len() <= self.max_jti_len,
132                JtiTooLongSnafu {
133                    len: jti.len(),
134                    max_len: self.max_jti_len,
135                }
136            );
137            if let Some(jti_checker) = self.jti_checker.as_ref() {
138                ensure!(
139                    !jti_checker
140                        .check_and_mark_seen(jti)
141                        .await
142                        .context(JtiCheckSnafu)?,
143                    JtiNotUniqueSnafu
144                );
145            }
146        }
147        Ok(())
148    }
149
150    fn check_required_claims(
151        &self,
152        exp: Option<SystemTime>,
153        iat: Option<SystemTime>,
154        jti: Option<&str>,
155    ) -> Result<(), JwtValidationError> {
156        if self.require_exp {
157            ensure!(exp.is_some(), RequiredClaimMissingSnafu { claim: "exp" });
158        }
159        if self.require_iat {
160            ensure!(iat.is_some(), RequiredClaimMissingSnafu { claim: "iat" });
161        }
162        if self.require_jti {
163            ensure!(jti.is_some(), RequiredClaimMissingSnafu { claim: "jti" });
164        }
165        Ok(())
166    }
167
168    /// Validate a pre-parsed JWS, returning a [`ValidatedJwt`] on success.
169    ///
170    /// # Errors
171    ///
172    /// Returns a [`JwtValidationError`] if the token is invalid.
173    pub async fn validate_parsed_jws<C: for<'de> Deserialize<'de> + Clone + 'static>(
174        &self,
175        parsed_jwt: ParsedJws<(), C>,
176    ) -> Result<ValidatedJwt<C>, JwtValidationError> {
177        let now = SystemTime::now();
178
179        self.validate_header(&parsed_jwt.header.alg, &parsed_jwt.header.crit)?;
180
181        let key_match = KeyMatch {
182            alg: &parsed_jwt.header.alg,
183            kid: parsed_jwt.header.kid.as_deref(),
184        };
185        self.verifier
186            .verify(&parsed_jwt.signing_input, &parsed_jwt.signature, &key_match)
187            .await
188            .context(SignatureSnafu)?;
189
190        check_aud(&self.aud, &parsed_jwt.claims.aud)?;
191        self.check_required_claims(
192            parsed_jwt.claims.exp,
193            parsed_jwt.claims.iat,
194            parsed_jwt.claims.jti.as_deref(),
195        )?;
196
197        if let Some(max_token_age) = self.max_token_age {
198            let issued_at = parsed_jwt
199                .claims
200                .iat
201                .ok_or_else(|| RequiredClaimMissingSnafu { claim: "iat" }.build())?;
202
203            ensure!(
204                within_max_age(now, issued_at, max_token_age, self.clock_leeway),
205                TokenTooOldSnafu {
206                    issued_at,
207                    max_token_age
208                }
209            );
210        }
211
212        check_typ(&self.typ, parsed_jwt.header.typ.as_deref())?;
213        check_str_claim("iss", &self.iss, parsed_jwt.claims.iss.as_deref())?;
214        check_str_claim("sub", &self.sub, parsed_jwt.claims.sub.as_deref())?;
215
216        check_temporal(
217            now,
218            self.clock_leeway,
219            parsed_jwt.claims.exp,
220            parsed_jwt.claims.nbf,
221            parsed_jwt.claims.iat,
222        )?;
223
224        // Burn JTI after all other checks have passed.
225        self.validate_jti(parsed_jwt.claims.jti.as_deref()).await?;
226
227        Ok(ValidatedJwt {
228            issuer: parsed_jwt.claims.iss.map(Into::into),
229            subject: parsed_jwt.claims.sub.map(Into::into),
230            audience: parsed_jwt.claims.aud.iter().map(Into::into).collect(),
231            issued_at: parsed_jwt.claims.iat,
232            expiration: parsed_jwt.claims.exp,
233            jti: parsed_jwt.claims.jti.map(Into::into),
234            cnf: parsed_jwt.claims.cnf,
235            claims: match parsed_jwt.claims.claims {
236                std::borrow::Cow::Borrowed(c) => c.clone(),
237                std::borrow::Cow::Owned(c) => c,
238            },
239        })
240    }
241
242    /// Validate a JWT token, returning a [`ValidatedJwt`] on success.
243    ///
244    /// Uses two-phase parsing: the JWT is first parsed and validated structurally
245    /// (signature, standard claims), then the extra claims are deserialized into
246    /// the target type `C`. If the token is valid but does not contain the required
247    /// extra claims, returns [`JwtValidationError::ExtraClaims`] instead of
248    /// [`JwtValidationError::Parse`].
249    ///
250    /// # Errors
251    ///
252    /// Returns a [`JwtValidationError`] if the token is invalid.
253    pub async fn validate<C: Clone + for<'de> Deserialize<'de> + 'static>(
254        &self,
255        token: &str,
256    ) -> Result<ValidatedJwt<C>, JwtValidationError> {
257        let parsed_jwt = parse_compact_jws::<(), serde_json::Value>(token).context(ParseSnafu)?;
258        let validated = self.validate_parsed_jws(parsed_jwt).await?;
259        validated.try_map_claims(|value| {
260            if std::any::TypeId::of::<C>() == std::any::TypeId::of::<()>() {
261                serde_json::from_value(serde_json::Value::Null).context(ExtraClaimsSnafu)
262            } else {
263                serde_json::from_value(value).context(ExtraClaimsSnafu)
264            }
265        })
266    }
267}
268
269/// Validation errors that can occur while processing a JWT.
270#[derive(Debug, Snafu)]
271pub enum JwtValidationError {
272    /// The token could not be parsed as a compact JWS.
273    Parse {
274        /// The underlying error.
275        source: JwsParseError,
276    },
277    /// The token signature is invalid.
278    Signature {
279        /// The underlying error.
280        source: VerifyError,
281    },
282    /// The token is unsigned.
283    UnsignedToken,
284    /// The token uses a disallowed signature algorithm.
285    DisallowedAlgorithm {
286        /// The algorithm used by the token.
287        alg: String,
288    },
289    /// The token contains unrecognized critical header parameters.
290    UnrecognizedCriticalHeader {
291        /// The unrecognized critical header parameters.
292        params: Vec<String>,
293    },
294    /// The token is expired.
295    Expired {
296        /// The expiration timestamp of the JWT.
297        expiration: SystemTime,
298        /// The current time.
299        now: SystemTime,
300    },
301    /// The token is not yet valid.
302    NotYetValid {
303        /// The not-before timestamp of the JWT.
304        not_before: SystemTime,
305        /// The current time.
306        now: SystemTime,
307    },
308    /// The token is issued in the future.
309    IssuedInFuture {
310        /// The issued-at timestamp of the JWT.
311        issued_at: SystemTime,
312        /// The current time.
313        now: SystemTime,
314    },
315    /// The token is too old.
316    TokenTooOld {
317        /// The issued-at timestamp of the JWT.
318        issued_at: SystemTime,
319        /// The maximum age of the token.
320        max_token_age: Duration,
321    },
322    /// The token type claim is invalid.
323    InvalidTokenType {
324        /// The type of the JWT.
325        typ: Option<String>,
326    },
327    /// A claim did not match the expected value.
328    ClaimMismatch {
329        /// The claim name.
330        claim: &'static str,
331        /// The expected value.
332        expected: String,
333        /// The actual value.
334        actual: String,
335    },
336    /// A required claim is missing from the JWT.
337    RequiredClaimMissing {
338        /// The missing claim.
339        claim: &'static str,
340    },
341    /// The `jti` claim exceeds the maximum allowed length.
342    JtiTooLong {
343        /// The length of the `jti` value in bytes.
344        len: usize,
345        /// The maximum allowed length in bytes.
346        max_len: usize,
347    },
348    /// The JTI was required to be unique, but was previously marked as seen.
349    JtiNotUnique,
350    /// There was an internal failure when attempting to check for JTI uniqueness.
351    JtiCheck {
352        /// The underlying error.
353        source: crate::error::Error,
354    },
355    /// The token is structurally valid but does not contain the required extra claims.
356    ExtraClaims {
357        /// The underlying deserialization error.
358        source: serde_json::Error,
359    },
360}
361
362#[cfg(test)]
363mod tests;