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;