use core::fmt;
use crate::json::JsonValue;
use super::decode::{SegmentDecodeError, decode_segment};
#[doc(alias = "payload")]
#[derive(Debug, Clone)]
pub struct JwtClaims {
iss: Option<String>,
sub: Option<String>,
aud: Vec<String>,
exp: Option<u64>,
nbf: Option<u64>,
iat: Option<u64>,
jti: Option<String>,
raw: JsonValue,
}
impl JwtClaims {
#[must_use = "parsing may fail; handle the Result"]
pub fn parse(payload_b64: &str) -> Result<Self, JwtClaimsError> {
let value = decode_segment(payload_b64).map_err(|e| JwtClaimsError {
kind: match e {
SegmentDecodeError::InvalidBase64 => JwtClaimsErrorKind::InvalidBase64,
SegmentDecodeError::InvalidUtf8 => JwtClaimsErrorKind::InvalidUtf8,
SegmentDecodeError::InvalidJson => JwtClaimsErrorKind::InvalidJson,
},
})?;
if !matches!(value, JsonValue::Object(_)) {
return Err(JwtClaimsError {
kind: JwtClaimsErrorKind::InvalidJson,
});
}
let iss = value.get_str("iss").map(String::from);
let sub = value.get_str("sub").map(String::from);
let jti = value.get_str("jti").map(String::from);
let aud = match value.get("aud") {
Some(JsonValue::String(s)) => vec![s.clone()],
Some(JsonValue::Array(arr)) => {
let mut audiences = Vec::with_capacity(arr.len());
for item in arr {
match item.as_str() {
Some(s) => audiences.push(s.to_string()),
None => {
return Err(JwtClaimsError {
kind: JwtClaimsErrorKind::InvalidAudience,
});
}
}
}
audiences
}
Some(_) => {
return Err(JwtClaimsError {
kind: JwtClaimsErrorKind::InvalidAudience,
});
}
None => Vec::new(),
};
let exp = Self::extract_timestamp(&value, "exp");
let nbf = Self::extract_timestamp(&value, "nbf");
let iat = Self::extract_timestamp(&value, "iat");
Ok(Self {
iss,
sub,
aud,
exp,
nbf,
iat,
jti,
raw: value,
})
}
#[must_use]
#[inline]
pub fn iss(&self) -> Option<&str> {
self.iss.as_deref()
}
#[must_use]
#[inline]
pub fn sub(&self) -> Option<&str> {
self.sub.as_deref()
}
#[must_use]
#[inline]
pub fn aud(&self) -> &[String] {
&self.aud
}
#[must_use]
#[inline]
pub fn exp(&self) -> Option<u64> {
self.exp
}
#[must_use]
#[inline]
pub fn nbf(&self) -> Option<u64> {
self.nbf
}
#[must_use]
#[inline]
pub fn iat(&self) -> Option<u64> {
self.iat
}
#[must_use]
#[inline]
pub fn jti(&self) -> Option<&str> {
self.jti.as_deref()
}
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
fn extract_timestamp(value: &JsonValue, key: &str) -> Option<u64> {
let v = value.get(key)?;
let fail_closed = if key == "exp" { 0 } else { u64::MAX };
if let Some(i) = v.as_i64() {
return Some(u64::try_from(i).unwrap_or(fail_closed));
}
match v.as_f64() {
None => Some(fail_closed),
Some(n) if !n.is_finite() || n < 0.0 => Some(fail_closed),
Some(n) if n >= u64::MAX as f64 => Some(u64::MAX),
Some(n) => Some(n as u64),
}
}
#[must_use]
#[inline]
pub fn validate_exp(&self, now_secs: u64, clock_skew_secs: u64) -> bool {
match self.exp {
Some(exp) => now_secs < exp.saturating_add(clock_skew_secs),
None => true,
}
}
#[must_use]
#[inline]
pub fn validate_nbf(&self, now_secs: u64, clock_skew_secs: u64) -> bool {
match self.nbf {
Some(nbf) => now_secs.saturating_add(clock_skew_secs) >= nbf,
None => true,
}
}
#[must_use]
#[inline]
pub fn validate_iss(&self, expected: &str) -> bool {
self.iss.as_deref() == Some(expected)
}
#[must_use]
#[inline]
pub fn validate_aud(&self, expected: &str) -> bool {
self.aud.iter().any(|a| a == expected)
}
#[must_use]
#[inline]
pub fn get_claim(&self, key: &str) -> Option<&JsonValue> {
self.raw.get(key)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum JwtClaimsErrorKind {
InvalidBase64,
InvalidUtf8,
InvalidJson,
InvalidAudience,
}
#[doc(alias = "claims_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JwtClaimsError {
kind: JwtClaimsErrorKind,
}
impl fmt::Display for JwtClaimsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
JwtClaimsErrorKind::InvalidBase64 => {
write!(f, "jwt claims: invalid base64url encoding")
}
JwtClaimsErrorKind::InvalidUtf8 => {
write!(f, "jwt claims: decoded bytes are not valid UTF-8")
}
JwtClaimsErrorKind::InvalidJson => {
write!(f, "jwt claims: decoded content is not valid JSON")
}
JwtClaimsErrorKind::InvalidAudience => {
write!(f, "jwt claims: 'aud' claim has invalid type")
}
}
}
}
impl std::error::Error for JwtClaimsError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoding::base64url_encode;
fn encode_claims(json: &str) -> String {
base64url_encode(json.as_bytes())
}
#[test]
fn parse_minimal_claims() {
let b64 = encode_claims("{}");
let claims = JwtClaims::parse(&b64).unwrap();
assert_eq!(claims.iss(), None);
assert_eq!(claims.sub(), None);
assert!(claims.aud().is_empty());
assert_eq!(claims.exp(), None);
assert_eq!(claims.nbf(), None);
assert_eq!(claims.iat(), None);
assert_eq!(claims.jti(), None);
}
#[test]
fn parse_full_claims() {
let json = r#"{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "my-client",
"exp": 1700000000,
"nbf": 1699999000,
"iat": 1699999000,
"jti": "unique-id-abc"
}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert_eq!(claims.iss(), Some("https://auth.example.com"));
assert_eq!(claims.sub(), Some("user-123"));
assert_eq!(claims.aud(), &["my-client"]);
assert_eq!(claims.exp(), Some(1_700_000_000));
assert_eq!(claims.nbf(), Some(1_699_999_000));
assert_eq!(claims.iat(), Some(1_699_999_000));
assert_eq!(claims.jti(), Some("unique-id-abc"));
}
#[test]
fn parse_aud_as_array() {
let json = r#"{"aud": ["client-a", "client-b"]}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert_eq!(claims.aud(), &["client-a", "client-b"]);
}
#[test]
fn parse_aud_as_single_string() {
let json = r#"{"aud": "single-client"}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert_eq!(claims.aud(), &["single-client"]);
}
#[test]
fn reject_invalid_aud_type() {
let json = r#"{"aud": 42}"#;
let b64 = encode_claims(json);
let err = JwtClaims::parse(&b64).unwrap_err();
assert!(err.to_string().contains("aud"));
}
#[test]
fn reject_aud_array_with_non_string() {
let json = r#"{"aud": ["ok", 42]}"#;
let b64 = encode_claims(json);
assert!(JwtClaims::parse(&b64).is_err());
}
#[test]
fn get_custom_claim() {
let json = r#"{"iss": "test", "custom_field": "custom_value"}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
let custom = claims.get_claim("custom_field").unwrap();
assert_eq!(custom.as_str(), Some("custom_value"));
}
#[test]
fn get_missing_custom_claim() {
let b64 = encode_claims("{}");
let claims = JwtClaims::parse(&b64).unwrap();
assert_eq!(claims.get_claim("nonexistent"), None);
}
#[test]
fn validate_exp_not_expired() {
let json = r#"{"exp": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_exp(999, 0));
}
#[test]
fn extract_timestamp_fails_closed_on_garbage() {
let claims = JwtClaims::parse(&encode_claims(r#"{"exp": 99999999999999999999}"#)).unwrap();
assert!(claims.validate_exp(2_000_000_000, 0));
let claims = JwtClaims::parse(&encode_claims(r#"{"exp": -5}"#)).unwrap();
assert!(!claims.validate_exp(0, 0));
let claims = JwtClaims::parse(&encode_claims(r#"{"nbf": 99999999999999999999}"#)).unwrap();
assert!(!claims.validate_nbf(2_000_000_000, 0));
}
#[test]
fn garbage_nbf_fails_closed() {
let claims = JwtClaims::parse(&encode_claims(r#"{"nbf": -5}"#)).unwrap();
assert_eq!(claims.nbf(), Some(u64::MAX));
assert!(!claims.validate_nbf(0, 0));
}
#[test]
fn string_typed_timestamps_fail_closed() {
let claims = JwtClaims::parse(&encode_claims(r#"{"exp": "1735689600"}"#)).unwrap();
assert_eq!(claims.exp(), Some(0));
assert!(!claims.validate_exp(0, 0));
let claims = JwtClaims::parse(&encode_claims(r#"{"nbf": true}"#)).unwrap();
assert_eq!(claims.nbf(), Some(u64::MAX));
assert!(!claims.validate_nbf(2_000_000_000, 0));
}
#[test]
fn validate_exp_exactly_expired() {
let json = r#"{"exp": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_exp(1000, 0));
}
#[test]
fn validate_exp_expired() {
let json = r#"{"exp": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_exp(1001, 0));
}
#[test]
fn validate_exp_with_clock_skew() {
let json = r#"{"exp": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_exp(1005, 10));
}
#[test]
fn validate_exp_absent_returns_true() {
let b64 = encode_claims("{}");
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_exp(9999, 0));
}
#[test]
fn validate_nbf_already_valid() {
let json = r#"{"nbf": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_nbf(1000, 0));
}
#[test]
fn validate_nbf_not_yet_valid() {
let json = r#"{"nbf": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_nbf(999, 0));
}
#[test]
fn validate_nbf_with_clock_skew() {
let json = r#"{"nbf": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_nbf(995, 10));
}
#[test]
fn validate_nbf_with_clock_skew_still_invalid() {
let json = r#"{"nbf": 1000}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_nbf(985, 10));
}
#[test]
fn validate_nbf_absent_returns_true() {
let b64 = encode_claims("{}");
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_nbf(0, 0));
}
#[test]
fn validate_iss_match() {
let json = r#"{"iss": "https://auth.example.com"}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_iss("https://auth.example.com"));
}
#[test]
fn validate_iss_mismatch() {
let json = r#"{"iss": "https://auth.example.com"}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_iss("https://other.example.com"));
}
#[test]
fn validate_iss_absent() {
let b64 = encode_claims("{}");
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_iss("any"));
}
#[test]
fn validate_aud_match() {
let json = r#"{"aud": ["client-a", "client-b"]}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(claims.validate_aud("client-b"));
}
#[test]
fn validate_aud_mismatch() {
let json = r#"{"aud": "client-a"}"#;
let b64 = encode_claims(json);
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_aud("client-x"));
}
#[test]
fn validate_aud_empty() {
let b64 = encode_claims("{}");
let claims = JwtClaims::parse(&b64).unwrap();
assert!(!claims.validate_aud("any"));
}
#[test]
fn reject_invalid_base64() {
let err = JwtClaims::parse("!!!invalid!!!").unwrap_err();
assert!(err.to_string().contains("base64url"));
}
#[test]
fn reject_invalid_json() {
let b64 = base64url_encode(b"not json");
let err = JwtClaims::parse(&b64).unwrap_err();
assert!(err.to_string().contains("JSON"));
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(JwtClaimsError {
kind: JwtClaimsErrorKind::InvalidJson,
});
let _ = err.to_string();
}
}