use super::{Audience, Claims};
use serde::Serialize;
use std::time::{Duration, SystemTime};
pub const CLOCK_SKEW_LEEWAY_SECONDS: u64 = 60;
#[derive(Clone, Copy)]
pub enum IssuerValidation<'a> {
Skip,
MustMatch(&'a url::Url),
}
#[derive(Debug, Clone, Serialize)]
pub struct Token {
raw: String,
claims: Claims,
#[serde(skip)]
nbf: Option<SystemTime>,
#[serde(skip)]
nonce: Option<String>,
}
impl<'de> serde::Deserialize<'de> for Token {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct Helper {
raw: String,
}
let h = Helper::deserialize(deserializer)?;
Self::from_raw_jwt(&h.raw).map_err(serde::de::Error::custom)
}
}
impl Token {
pub(crate) const fn new(
raw: String,
claims: Claims,
nbf: Option<SystemTime>,
nonce: Option<String>,
) -> Self {
Self {
raw,
claims,
nbf,
nonce,
}
}
pub(crate) fn from_raw_jwt(raw: &str) -> Result<Self, crate::error::IdTokenError> {
use super::string_or_vec::StringOrVec;
use base64::Engine as _;
#[derive(serde::Deserialize)]
struct RawClaims {
sub: String,
email: Option<String>,
email_verified: Option<bool>,
name: Option<String>,
picture: Option<String>,
#[serde(default)]
iss: Option<String>,
#[serde(default)]
aud: Option<StringOrVec>,
#[serde(default)]
iat: Option<u64>,
#[serde(default)]
exp: Option<u64>,
#[serde(default)]
nbf: Option<u64>,
#[serde(default)]
nonce: Option<String>,
}
let malformed = |msg: String| crate::error::IdTokenError::MalformedIdToken(msg);
let payload = raw
.split('.')
.nth(1)
.ok_or_else(|| malformed("missing payload segment".to_owned()))?;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.map_err(|e| malformed(format!("base64 decode failed: {e}")))?;
let c: RawClaims = serde_json::from_slice(&decoded)
.map_err(|e| malformed(format!("JSON parse failed: {e}")))?;
let iss = c
.iss
.as_deref()
.ok_or_else(|| malformed("missing iss claim".to_owned()))
.and_then(|s| {
url::Url::parse(s).map_err(|e| malformed(format!("invalid iss URL: {e}")))
})?;
let aud = match c.aud {
None => vec![],
Some(StringOrVec::Single(s)) => vec![Audience::new(s)],
Some(StringOrVec::Multiple(v)) => v.into_iter().map(Audience::new).collect(),
};
let iat_secs = c
.iat
.ok_or_else(|| malformed("missing iat claim".to_owned()))?;
let exp_secs = c
.exp
.ok_or_else(|| malformed("missing exp claim".to_owned()))?;
let iat = std::time::UNIX_EPOCH + Duration::from_secs(iat_secs);
let exp = std::time::UNIX_EPOCH + Duration::from_secs(exp_secs);
let nbf = c
.nbf
.map(|secs| std::time::UNIX_EPOCH + Duration::from_secs(secs));
let claims = Claims::new(
c.sub,
c.email,
c.email_verified,
c.name,
c.picture,
iss,
aud,
iat,
exp,
);
Ok(Self::new(raw.to_string(), claims, nbf, c.nonce))
}
#[must_use]
pub fn raw(&self) -> &str {
&self.raw
}
#[must_use]
pub const fn claims(&self) -> &Claims {
&self.claims
}
#[must_use]
pub const fn nbf(&self) -> Option<SystemTime> {
self.nbf
}
pub(crate) fn validate_standard_claims(
&self,
client_id: &str,
issuer: IssuerValidation<'_>,
expected_nonce: Option<&str>,
) -> Result<(), crate::error::IdTokenError> {
use crate::error::IdTokenError;
let leeway = Duration::from_secs(CLOCK_SKEW_LEEWAY_SECONDS);
if SystemTime::now() > self.claims.exp() + leeway {
return Err(IdTokenError::Expired);
}
if let Some(nbf) = self.nbf
&& SystemTime::now() + leeway < nbf
{
return Err(IdTokenError::NotYetValid);
}
if self.claims.iat() > SystemTime::now() + leeway {
return Err(IdTokenError::MalformedIdToken(
"iat claim is in the future".to_owned(),
));
}
if self.claims.aud().is_empty() {
return Err(IdTokenError::MalformedIdToken(
"missing aud claim".to_owned(),
));
}
if !self.claims.aud_contains(client_id) {
return Err(IdTokenError::InvalidAudience);
}
if let IssuerValidation::MustMatch(expected_issuer) = issuer
&& self.claims.iss().as_url() != expected_issuer
{
return Err(IdTokenError::InvalidIssuer {
expected: expected_issuer.to_string(),
got: self.claims.iss().as_url().to_string(),
});
}
if let Some(expected) = expected_nonce {
match self.nonce.as_deref() {
Some(got) if got == expected => {}
_ => return Err(IdTokenError::NonceMismatch),
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
reason = "tests do not need to meet production lint standards"
)]
use super::*;
use crate::oidc::claims::Audience;
fn token_with_exp_offset(offset_secs: i64) -> Token {
let exp = if offset_secs >= 0 {
SystemTime::now() + Duration::from_secs(offset_secs.cast_unsigned())
} else {
SystemTime::now() - Duration::from_secs((-offset_secs).cast_unsigned())
};
let iat = SystemTime::now() - Duration::from_secs(10);
let iss = url::Url::parse("https://issuer.example.com").expect("valid url");
let claims = Claims::new(
"sub".to_owned(),
None,
None,
None,
None,
iss,
vec![Audience::new("client".to_owned())],
iat,
exp,
);
Token::new("raw.jwt.token".to_owned(), claims, None, None)
}
fn token_with_nbf_offset(offset_secs: i64) -> Token {
let exp = SystemTime::now() + Duration::from_secs(3600);
let iat = SystemTime::now() - Duration::from_secs(10);
let nbf = if offset_secs >= 0 {
SystemTime::now() + Duration::from_secs(offset_secs.cast_unsigned())
} else {
SystemTime::now() - Duration::from_secs((-offset_secs).cast_unsigned())
};
let iss = url::Url::parse("https://issuer.example.com").expect("valid url");
let claims = Claims::new(
"sub".to_owned(),
None,
None,
None,
None,
iss,
vec![Audience::new("client".to_owned())],
iat,
exp,
);
Token::new("raw.jwt.token".to_owned(), claims, Some(nbf), None)
}
#[test]
fn exp_beyond_leeway_returns_expired() {
let token = token_with_exp_offset(-120);
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
matches!(result, Err(crate::error::IdTokenError::Expired)),
"expected Expired, got {result:?}"
);
}
#[test]
fn exp_within_leeway_is_accepted() {
let token = token_with_exp_offset(-30);
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
result.is_ok(),
"expected Ok for token expired within leeway, got {result:?}"
);
}
#[test]
fn nbf_beyond_leeway_returns_not_yet_valid() {
let token = token_with_nbf_offset(120);
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
matches!(result, Err(crate::error::IdTokenError::NotYetValid)),
"expected NotYetValid, got {result:?}"
);
}
#[test]
fn nbf_within_leeway_is_accepted() {
let token = token_with_nbf_offset(30);
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
result.is_ok(),
"expected Ok for token with nbf within leeway, got {result:?}"
);
}
fn token_with_iat_offset(offset_secs: i64) -> Token {
let exp = SystemTime::now() + Duration::from_secs(3600);
let iat = if offset_secs >= 0 {
SystemTime::now() + Duration::from_secs(offset_secs.cast_unsigned())
} else {
SystemTime::now() - Duration::from_secs((-offset_secs).cast_unsigned())
};
let iss = url::Url::parse("https://issuer.example.com").expect("valid url");
let claims = Claims::new(
"sub".to_owned(),
None,
None,
None,
None,
iss,
vec![Audience::new("client".to_owned())],
iat,
exp,
);
Token::new("raw.jwt.token".to_owned(), claims, None, None)
}
#[test]
fn iat_beyond_leeway_in_future_returns_malformed() {
let token = token_with_iat_offset(120);
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
matches!(result, Err(crate::error::IdTokenError::MalformedIdToken(_))),
"expected MalformedIdToken for future iat, got {result:?}"
);
}
#[test]
fn iat_within_leeway_in_future_is_accepted() {
let token = token_with_iat_offset(30);
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
result.is_ok(),
"expected Ok for token with iat within leeway, got {result:?}"
);
}
fn token_with_nonce(nonce: Option<String>) -> Token {
let exp = SystemTime::now() + Duration::from_secs(3600);
let iat = SystemTime::now() - Duration::from_secs(10);
let iss = url::Url::parse("https://issuer.example.com").expect("valid url");
let claims = Claims::new(
"sub".to_owned(),
None,
None,
None,
None,
iss,
vec![Audience::new("client".to_owned())],
iat,
exp,
);
Token::new("raw.jwt.token".to_owned(), claims, None, nonce)
}
#[test]
fn matching_nonce_is_accepted() {
let token = token_with_nonce(Some("abc123".to_owned()));
let result =
token.validate_standard_claims("client", IssuerValidation::Skip, Some("abc123"));
assert!(
result.is_ok(),
"expected Ok for matching nonce, got {result:?}"
);
}
#[test]
fn mismatched_nonce_returns_nonce_mismatch() {
let token = token_with_nonce(Some("abc123".to_owned()));
let result =
token.validate_standard_claims("client", IssuerValidation::Skip, Some("wrong"));
assert!(
matches!(result, Err(crate::error::IdTokenError::NonceMismatch)),
"expected NonceMismatch for wrong nonce, got {result:?}"
);
}
#[test]
fn absent_nonce_with_expected_returns_nonce_mismatch() {
let token = token_with_nonce(None);
let result =
token.validate_standard_claims("client", IssuerValidation::Skip, Some("abc123"));
assert!(
matches!(result, Err(crate::error::IdTokenError::NonceMismatch)),
"expected NonceMismatch when token has no nonce but one was expected, got {result:?}"
);
}
#[test]
fn no_expected_nonce_skips_nonce_validation() {
let token = token_with_nonce(Some("any-nonce".to_owned()));
let result = token.validate_standard_claims("client", IssuerValidation::Skip, None);
assert!(
result.is_ok(),
"expected Ok when no expected nonce supplied, got {result:?}"
);
}
}