use std::{collections::HashSet, sync::Arc};
use bon::Builder;
use serde::Deserialize;
use snafu::{ResultExt as _, Snafu, ensure};
use crate::{
crypto::verifier::{JwsVerifier, KeyMatch, VerifyError},
jwt::{ConfirmationClaim, JtiUniquenessChecker, JwsParseError, ParsedJws, parse_compact_jws},
platform::{Duration, SystemTime},
};
#[non_exhaustive]
#[derive(Debug, Clone, Default)]
pub enum ClaimCheck {
IfPresent(String),
RequireAny(Vec<String>),
RequiredValue(String),
Present,
#[default]
NoCheck,
}
impl ClaimCheck {
pub fn if_present(value: impl Into<String>) -> Self {
Self::IfPresent(value.into())
}
pub fn require_any(values: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self::RequireAny(values.into_iter().map(Into::into).collect())
}
pub fn required_value(value: impl Into<String>) -> Self {
Self::RequiredValue(value.into())
}
#[must_use]
pub fn present() -> Self {
Self::Present
}
}
#[allow(clippy::struct_excessive_bools)]
#[allow(clippy::should_implement_trait)] #[derive(Debug, Builder)]
pub struct JwtValidator {
#[builder(with = |verifier: impl JwsVerifier + 'static| Arc::new(verifier) as Arc<dyn JwsVerifier>)]
verifier: Arc<dyn JwsVerifier>,
#[builder(default)]
iss: ClaimCheck,
#[builder(default)]
sub: ClaimCheck,
#[builder(default)]
aud: ClaimCheck,
#[builder(default)]
typ: ClaimCheck,
#[builder(default)]
require_exp: bool,
#[builder(default)]
require_iat: bool,
#[builder(default)]
require_jti: bool,
#[builder(default = 255)]
max_jti_len: usize,
#[builder(with = |checker: impl JtiUniquenessChecker + 'static| Arc::new(checker) as Arc<dyn JtiUniquenessChecker>)]
jti_checker: Option<Arc<dyn JtiUniquenessChecker>>,
max_token_age: Option<Duration>,
#[builder(default)]
clock_leeway: Duration,
#[builder(default, with = FromIterator::from_iter)]
allowed_crit: HashSet<String>,
#[builder(with = FromIterator::from_iter)]
allowed_algorithms: Option<HashSet<String>>,
}
fn normalize_typ(typ: &str) -> &str {
match typ.split_at_checked(12) {
Some((prefix, rest)) if !rest.is_empty() && prefix.eq_ignore_ascii_case("application/") => {
rest
}
_ => typ,
}
}
fn check_str_claim(
claim: &'static str,
check: &ClaimCheck,
value: Option<&str>,
) -> Result<(), JwtValidationError> {
match check {
ClaimCheck::Present => {
if value.is_none() {
return Err(RequiredClaimMissingSnafu { claim }.build());
}
}
ClaimCheck::RequiredValue(v) => match value {
Some(val) if val == v.as_str() => {}
Some(val) => {
return Err(ClaimMismatchSnafu {
claim,
expected: v.clone(),
actual: val,
}
.build());
}
None => return Err(RequiredClaimMissingSnafu { claim }.build()),
},
ClaimCheck::RequireAny(vs) => match value {
Some(val) if vs.iter().any(|x| val == x.as_str()) => {}
Some(val) => {
return Err(ClaimMismatchSnafu {
claim,
expected: vs.join(", "),
actual: val,
}
.build());
}
None => return Err(RequiredClaimMissingSnafu { claim }.build()),
},
ClaimCheck::IfPresent(v) => {
if let Some(val) = value
&& val != v.as_str()
{
return Err(ClaimMismatchSnafu {
claim,
expected: v.clone(),
actual: val,
}
.build());
}
}
ClaimCheck::NoCheck => {}
}
Ok(())
}
fn check_aud(check: &ClaimCheck, aud: &[String]) -> Result<(), JwtValidationError> {
match check {
ClaimCheck::Present => ensure!(!aud.is_empty(), RequiredClaimMissingSnafu { claim: "aud" }),
ClaimCheck::RequiredValue(v) => ensure!(
aud.contains(v),
ClaimMismatchSnafu {
claim: "aud",
expected: v.clone(),
actual: aud.join(", "),
}
),
ClaimCheck::RequireAny(vs) => ensure!(
vs.iter().any(|v| aud.contains(v)),
ClaimMismatchSnafu {
claim: "aud",
expected: vs.join(", "),
actual: aud.join(", "),
}
),
ClaimCheck::IfPresent(v) => {
if !aud.is_empty() {
ensure!(
aud.contains(v),
ClaimMismatchSnafu {
claim: "aud",
expected: v.clone(),
actual: aud.join(", "),
}
);
}
}
ClaimCheck::NoCheck => {}
}
Ok(())
}
fn check_typ(check: &ClaimCheck, typ: Option<&str>) -> Result<(), JwtValidationError> {
match check {
ClaimCheck::IfPresent(t) => ensure!(
typ.is_none_or(|v| normalize_typ(v).eq_ignore_ascii_case(normalize_typ(t))),
InvalidTokenTypeSnafu {
typ: typ.map(Into::into)
}
),
ClaimCheck::RequireAny(allowed) => match typ {
None => return RequiredClaimMissingSnafu { claim: "typ" }.fail(),
Some(v)
if allowed
.iter()
.any(|t| normalize_typ(v).eq_ignore_ascii_case(normalize_typ(t))) => {}
Some(v) => {
return InvalidTokenTypeSnafu {
typ: Some(v.into()),
}
.fail();
}
},
ClaimCheck::RequiredValue(t) => match typ {
None => return RequiredClaimMissingSnafu { claim: "typ" }.fail(),
Some(v) if normalize_typ(v).eq_ignore_ascii_case(normalize_typ(t)) => {}
Some(v) => {
return InvalidTokenTypeSnafu {
typ: Some(v.into()),
}
.fail();
}
},
ClaimCheck::Present => {
ensure!(typ.is_some(), RequiredClaimMissingSnafu { claim: "typ" });
}
ClaimCheck::NoCheck => {}
}
Ok(())
}
fn check_temporal(
now: SystemTime,
clock_leeway: Duration,
exp: Option<SystemTime>,
nbf: Option<SystemTime>,
iat: Option<SystemTime>,
) -> Result<(), JwtValidationError> {
if let Some(expiration) = exp {
ensure!(
!now.duration_since(expiration)
.is_ok_and(|past| past > clock_leeway),
ExpiredSnafu { expiration, now }
);
}
if let Some(not_before) = nbf {
ensure!(
!not_before
.duration_since(now)
.is_ok_and(|ahead| ahead > clock_leeway),
NotYetValidSnafu { not_before, now }
);
}
if let Some(issued_at) = iat {
ensure!(
!issued_at
.duration_since(now)
.is_ok_and(|ahead| ahead > clock_leeway),
IssuedInFutureSnafu { issued_at, now }
);
}
Ok(())
}
impl JwtValidator {
fn validate_header(&self, alg: &str, crit: &[String]) -> Result<(), JwtValidationError> {
ensure!(alg != "none", UnsignedTokenSnafu);
if let Some(allowed) = &self.allowed_algorithms {
ensure!(
allowed.contains(alg),
DisallowedAlgorithmSnafu {
alg: alg.to_string()
}
);
}
ensure!(
crit.iter().all(|v| self.allowed_crit.contains(v)),
UnrecognizedCriticalHeaderSnafu {
params: crit.to_vec()
}
);
Ok(())
}
async fn validate_jti(&self, jti: Option<&str>) -> Result<(), JwtValidationError> {
if let Some(jti) = jti {
ensure!(
jti.len() <= self.max_jti_len,
JtiTooLongSnafu {
len: jti.len(),
max_len: self.max_jti_len,
}
);
if let Some(jti_checker) = self.jti_checker.as_ref() {
ensure!(
!jti_checker
.check_and_mark_seen(jti)
.await
.context(JtiCheckSnafu)?,
JtiNotUniqueSnafu
);
}
}
Ok(())
}
fn check_required_claims(
&self,
exp: Option<SystemTime>,
iat: Option<SystemTime>,
jti: Option<&str>,
) -> Result<(), JwtValidationError> {
if self.require_exp {
ensure!(exp.is_some(), RequiredClaimMissingSnafu { claim: "exp" });
}
if self.require_iat {
ensure!(iat.is_some(), RequiredClaimMissingSnafu { claim: "iat" });
}
if self.require_jti {
ensure!(jti.is_some(), RequiredClaimMissingSnafu { claim: "jti" });
}
Ok(())
}
pub async fn validate_parsed_jws<C: for<'de> Deserialize<'de> + Clone + 'static>(
&self,
parsed_jwt: ParsedJws<(), C>,
) -> Result<ValidatedJwt<C>, JwtValidationError> {
let now = SystemTime::now();
self.validate_header(&parsed_jwt.header.alg, &parsed_jwt.header.crit)?;
let key_match = KeyMatch {
alg: &parsed_jwt.header.alg,
kid: parsed_jwt.header.kid.as_deref(),
};
self.verifier
.verify(&parsed_jwt.signing_input, &parsed_jwt.signature, &key_match)
.await
.context(SignatureSnafu)?;
check_aud(&self.aud, &parsed_jwt.claims.aud)?;
self.check_required_claims(
parsed_jwt.claims.exp,
parsed_jwt.claims.iat,
parsed_jwt.claims.jti.as_deref(),
)?;
if let Some(max_token_age) = self.max_token_age {
let issued_at = parsed_jwt
.claims
.iat
.ok_or_else(|| RequiredClaimMissingSnafu { claim: "iat" }.build())?;
ensure!(
now.duration_since(issued_at)
.is_ok_and(|d| d <= max_token_age.saturating_add(self.clock_leeway)),
TokenTooOldSnafu {
issued_at,
max_token_age
}
);
}
check_typ(&self.typ, parsed_jwt.header.typ.as_deref())?;
check_str_claim("iss", &self.iss, parsed_jwt.claims.iss.as_deref())?;
check_str_claim("sub", &self.sub, parsed_jwt.claims.sub.as_deref())?;
check_temporal(
now,
self.clock_leeway,
parsed_jwt.claims.exp,
parsed_jwt.claims.nbf,
parsed_jwt.claims.iat,
)?;
self.validate_jti(parsed_jwt.claims.jti.as_deref()).await?;
Ok(ValidatedJwt {
issuer: parsed_jwt.claims.iss.map(Into::into),
subject: parsed_jwt.claims.sub.map(Into::into),
audience: parsed_jwt.claims.aud.iter().map(Into::into).collect(),
issued_at: parsed_jwt.claims.iat,
expiration: parsed_jwt.claims.exp,
jti: parsed_jwt.claims.jti.map(Into::into),
cnf: parsed_jwt.claims.cnf,
claims: match parsed_jwt.claims.claims {
std::borrow::Cow::Borrowed(c) => c.clone(),
std::borrow::Cow::Owned(c) => c,
},
})
}
pub async fn validate<C: Clone + for<'de> Deserialize<'de> + 'static>(
&self,
token: &str,
) -> Result<ValidatedJwt<C>, JwtValidationError> {
let parsed_jwt = parse_compact_jws::<(), serde_json::Value>(token).context(ParseSnafu)?;
let validated = self.validate_parsed_jws(parsed_jwt).await?;
validated.try_map_claims(|value| {
if std::any::TypeId::of::<C>() == std::any::TypeId::of::<()>() {
serde_json::from_value(serde_json::Value::Null).context(ExtraClaimsSnafu)
} else {
serde_json::from_value(value).context(ExtraClaimsSnafu)
}
})
}
}
#[non_exhaustive]
#[derive(Debug, Builder)]
pub struct ValidatedJwt<Claims> {
pub issuer: Option<String>,
pub subject: Option<String>,
pub audience: Vec<String>,
pub jti: Option<String>,
pub issued_at: Option<SystemTime>,
pub expiration: Option<SystemTime>,
pub cnf: Option<ConfirmationClaim>,
pub claims: Claims,
}
impl<Claims> ValidatedJwt<Claims> {
pub fn map_claims<C1, F>(self, f: F) -> ValidatedJwt<C1>
where
F: FnOnce(Claims) -> C1,
{
ValidatedJwt {
issuer: self.issuer,
subject: self.subject,
audience: self.audience,
jti: self.jti,
issued_at: self.issued_at,
expiration: self.expiration,
cnf: self.cnf,
claims: f(self.claims),
}
}
pub fn try_map_claims<C1, E, F>(self, f: F) -> Result<ValidatedJwt<C1>, E>
where
F: FnOnce(Claims) -> Result<C1, E>,
{
Ok(ValidatedJwt {
issuer: self.issuer,
subject: self.subject,
audience: self.audience,
jti: self.jti,
issued_at: self.issued_at,
expiration: self.expiration,
cnf: self.cnf,
claims: f(self.claims)?,
})
}
}
#[derive(Debug, Snafu)]
pub enum JwtValidationError {
Parse {
source: JwsParseError,
},
Signature {
source: VerifyError,
},
UnsignedToken,
DisallowedAlgorithm {
alg: String,
},
UnrecognizedCriticalHeader {
params: Vec<String>,
},
Expired {
expiration: SystemTime,
now: SystemTime,
},
NotYetValid {
not_before: SystemTime,
now: SystemTime,
},
IssuedInFuture {
issued_at: SystemTime,
now: SystemTime,
},
TokenTooOld {
issued_at: SystemTime,
max_token_age: Duration,
},
InvalidTokenType {
typ: Option<String>,
},
ClaimMismatch {
claim: &'static str,
expected: String,
actual: String,
},
RequiredClaimMissing {
claim: &'static str,
},
JtiTooLong {
len: usize,
max_len: usize,
},
JtiNotUnique,
JtiCheck {
source: crate::error::Error,
},
ExtraClaims {
source: serde_json::Error,
},
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::crypto::{
KeyMatchStrength,
verifier::{JwsVerifier, KeyMatch, VerifyError},
};
#[derive(Debug)]
struct MockVerifier;
impl JwsVerifier for MockVerifier {
fn key_match(&self, _key_match: &KeyMatch<'_>) -> Option<KeyMatchStrength> {
Some(KeyMatchStrength::ByAlgorithm)
}
fn verify<'a>(
&'a self,
_input: &'a [u8],
_signature: &'a [u8],
_key: &'a KeyMatch<'a>,
) -> crate::platform::MaybeSendBoxFuture<'a, Result<(), VerifyError>> {
Box::pin(async { Ok(()) })
}
}
#[tokio::test]
async fn test_validate_unit_claims_with_extra_fields() {
let token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJmb28iOiJiYXIifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(ClaimCheck::required_value("joe"))
.build();
let result = validator.validate::<()>(token).await;
assert!(result.is_ok(), "Expected Ok, got {:?}", result.err());
}
#[tokio::test]
async fn test_validate_custom_claims_success() {
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct MyClaims {
foo: String,
}
let token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJmb28iOiJiYXIifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(ClaimCheck::required_value("joe"))
.build();
let result = validator.validate::<MyClaims>(token).await.unwrap();
assert_eq!(
result.claims,
MyClaims {
foo: "bar".to_string()
}
);
}
#[tokio::test]
async fn test_validate_custom_claims_failure() {
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct MyClaims {
missing: String,
}
let token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJmb28iOiJiYXIifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(ClaimCheck::required_value("joe"))
.build();
let result = validator.validate::<MyClaims>(token).await;
assert!(matches!(
result,
Err(JwtValidationError::ExtraClaims { .. })
));
}
fn make_parsed_jws(
header: serde_json::Value,
claims: serde_json::Value,
) -> ParsedJws<(), serde_json::Value> {
use crate::jwt::structure::{JwtClaims, JwtHeader};
let header: JwtHeader<'static, ()> = serde_json::from_value(header).unwrap();
let claims: JwtClaims<'static, serde_json::Value> = serde_json::from_value(claims).unwrap();
ParsedJws {
header,
claims,
signing_input: b"dummy.input".to_vec(),
signature: vec![0x00],
}
}
fn default_validator() -> JwtValidator {
JwtValidator::builder().verifier(MockVerifier).build()
}
#[tokio::test]
async fn reject_alg_none() {
let parsed = make_parsed_jws(serde_json::json!({"alg": "none"}), serde_json::json!({}));
let result = default_validator()
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(result, Err(JwtValidationError::UnsignedToken)));
}
#[tokio::test]
async fn reject_disallowed_algorithm() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.allowed_algorithms(["ES256".to_string()])
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::DisallowedAlgorithm { .. })
));
}
#[tokio::test]
async fn reject_unrecognized_crit() {
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256", "crit": ["unknown-ext"]}),
serde_json::json!({}),
);
let result = default_validator()
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::UnrecognizedCriticalHeader { .. })
));
}
#[tokio::test]
async fn reject_expired_token() {
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"exp": 1}), );
let result = default_validator()
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(result, Err(JwtValidationError::Expired { .. })));
}
#[tokio::test]
async fn reject_not_yet_valid() {
let far_future = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 999_999;
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"nbf": far_future}),
);
let result = default_validator()
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::NotYetValid { .. })
));
}
#[tokio::test]
async fn reject_issued_in_future() {
let far_future = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 999_999;
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"iat": far_future}),
);
let result = default_validator()
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::IssuedInFuture { .. })
));
}
#[tokio::test]
async fn clock_leeway_allows_slightly_expired() {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.clock_leeway(Duration::from_secs(10))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"exp": now - 5}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn require_exp_missing() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.require_exp(true)
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::RequiredClaimMissing { claim: "exp" })
));
}
#[tokio::test]
async fn require_iat_missing() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.require_iat(true)
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::RequiredClaimMissing { claim: "iat" })
));
}
#[tokio::test]
async fn require_jti_missing() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.require_jti(true)
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::RequiredClaimMissing { claim: "jti" })
));
}
#[rstest]
#[case::required_value(ClaimCheck::required_value("expected-issuer"), "wrong-issuer")]
#[case::require_any(ClaimCheck::require_any(["a", "b"]), "c")]
#[tokio::test]
async fn iss_mismatch_is_rejected(#[case] iss_check: ClaimCheck, #[case] actual_iss: &str) {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(iss_check)
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({ "iss": actual_iss }),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::ClaimMismatch { claim: "iss", .. })
));
}
#[tokio::test]
async fn iss_present_missing() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(ClaimCheck::present())
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::RequiredClaimMissing { claim: "iss" })
));
}
#[tokio::test]
async fn iss_if_present_mismatch() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(ClaimCheck::if_present("expected"))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"iss": "wrong"}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::ClaimMismatch { claim: "iss", .. })
));
}
#[tokio::test]
async fn iss_if_present_absent_ok() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.iss(ClaimCheck::if_present("expected"))
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn aud_required_value_not_in_list() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.aud(ClaimCheck::required_value("expected-aud"))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"aud": "wrong-aud"}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::ClaimMismatch { claim: "aud", .. })
));
}
#[tokio::test]
async fn aud_empty_when_required() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.aud(ClaimCheck::present())
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::RequiredClaimMissing { claim: "aud" })
));
}
#[tokio::test]
async fn typ_required_value_mismatch() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.typ(ClaimCheck::required_value("at+jwt"))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256", "typ": "JWT"}),
serde_json::json!({}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::InvalidTokenType { .. })
));
}
#[tokio::test]
async fn typ_application_prefix_normalization() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.typ(ClaimCheck::required_value("at+jwt"))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256", "typ": "application/at+jwt"}),
serde_json::json!({}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn typ_case_insensitive() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.typ(ClaimCheck::required_value("AT+JWT"))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256", "typ": "at+jwt"}),
serde_json::json!({}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn max_token_age_old_token() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.max_token_age(Duration::from_mins(1))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"iat": 1}), );
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::TokenTooOld { .. })
));
}
#[tokio::test]
async fn max_token_age_missing_iat() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.max_token_age(Duration::from_mins(1))
.build();
let parsed = make_parsed_jws(serde_json::json!({"alg": "RS256"}), serde_json::json!({}));
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(
result,
Err(JwtValidationError::RequiredClaimMissing { claim: "iat" })
));
}
#[test]
fn normalize_typ_non_char_boundary() {
let typ = "abcdefghijké-jwt";
assert!(!typ.is_char_boundary(12));
assert_eq!(normalize_typ(typ), typ);
assert_eq!(normalize_typ("application/at+jwt"), "at+jwt");
assert_eq!(normalize_typ("APPLICATION/at+jwt"), "at+jwt");
assert_eq!(normalize_typ("application/"), "application/");
assert_eq!(normalize_typ("JWT"), "JWT");
}
#[tokio::test]
async fn overflowing_exp_is_a_parse_error_not_a_panic() {
use base64::prelude::*;
let header = BASE64_URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#);
let claims = BASE64_URL_SAFE_NO_PAD.encode(r#"{"exp":18446744073709551615}"#);
let signature = BASE64_URL_SAFE_NO_PAD.encode([0x00]);
let token = [header, claims, signature].join(".");
let result = default_validator().validate::<()>(&token).await;
assert!(matches!(result, Err(JwtValidationError::Parse { .. })));
}
#[tokio::test]
async fn max_representable_exp_with_leeway_does_not_panic() {
let secs = 9_223_372_036_854_775_807_u64;
if SystemTime::UNIX_EPOCH
.checked_add(Duration::from_secs(secs))
.is_none()
{
return;
}
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.clock_leeway(Duration::from_secs(10))
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"exp": secs, "nbf": 0, "iat": 0}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(result.is_ok(), "far-future exp is valid: {result:?}");
}
#[derive(Debug, Default)]
struct InMemoryJtiChecker {
seen: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl JtiUniquenessChecker for InMemoryJtiChecker {
fn check_and_mark_seen(
&self,
jti: &str,
) -> crate::platform::MaybeSendBoxFuture<'_, Result<bool, crate::error::Error>> {
let previously_seen = !self.seen.lock().unwrap().insert(jti.to_owned());
Box::pin(async move { Ok(previously_seen) })
}
}
#[tokio::test]
async fn temporally_invalid_token_does_not_burn_jti() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.jti_checker(InMemoryJtiChecker::default())
.build();
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let early = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"jti": "jti-1", "nbf": now + 999_999}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(early)
.await;
assert!(matches!(
result,
Err(JwtValidationError::NotYetValid { .. })
));
let expired = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"jti": "jti-2", "exp": 1}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(expired)
.await;
assert!(matches!(result, Err(JwtValidationError::Expired { .. })));
for jti in ["jti-1", "jti-2"] {
let valid = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"jti": jti, "nbf": now - 10, "exp": now + 3600}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(valid)
.await;
assert!(result.is_ok(), "{jti} should not be burned: {result:?}");
}
let replay = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"jti": "jti-1", "nbf": now - 10, "exp": now + 3600}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(replay)
.await;
assert!(matches!(result, Err(JwtValidationError::JtiNotUnique)));
}
#[tokio::test]
async fn jti_too_long() {
let validator = JwtValidator::builder()
.verifier(MockVerifier)
.max_jti_len(5)
.build();
let parsed = make_parsed_jws(
serde_json::json!({"alg": "RS256"}),
serde_json::json!({"jti": "toolong"}),
);
let result = validator
.validate_parsed_jws::<serde_json::Value>(parsed)
.await;
assert!(matches!(result, Err(JwtValidationError::JtiTooLong { .. })));
}
}