use std::collections::{HashMap, HashSet};
use bon::Builder;
use serde::{Deserialize, Serialize};
use snafu::{ensure, prelude::*};
use crate::core::{
crypto::verifier::BoxedJwsVerifier,
jwt::validator::{ClaimCheck, JwtValidationError, JwtValidator, ValidatedJwt},
platform::{Duration, SystemTime},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdToken(String);
impl IdToken {
#[must_use]
pub fn token(&self) -> &str {
self.0.as_str()
}
}
impl From<&str> for IdToken {
fn from(value: &str) -> Self {
Self(value.into())
}
}
impl From<String> for IdToken {
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdTokenClaims<Extra = HashMap<String, serde_json::Value>> {
#[serde(skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_time: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acr: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub amr: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub azp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub middle_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub birthdate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zoneinfo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<StandardOidcAddressClaims>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::core::serde_utils::time::option_unix_secs"
)]
pub updated_at: Option<SystemTime>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandardOidcAddressClaims {
#[serde(skip_serializing_if = "Option::is_none")]
pub formatted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub street_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub postal_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
}
#[derive(Debug, Builder)]
pub struct IdTokenValidator {
verifier: BoxedJwsVerifier,
#[builder(into)]
issuer: String,
#[builder(into)]
audience: Option<String>,
max_age: Option<Duration>,
#[builder(default)]
clock_leeway: Duration,
#[builder(into)]
expected_azp: Option<String>,
#[builder(into)]
required_acr: Option<String>,
allowed_algorithms: Option<HashSet<String>>,
}
impl IdTokenValidator {
pub async fn validate<E: Clone + for<'de> Deserialize<'de> + 'static>(
&self,
id_token: &IdToken,
expected_nonce: Option<&str>,
) -> Result<ValidatedJwt<IdTokenClaims<E>>, IdTokenValidationError> {
let jwt_validator = JwtValidator::builder()
.verifier(self.verifier.clone())
.iss(ClaimCheck::required_value(self.issuer.clone()))
.maybe_aud(self.audience.as_deref().map(ClaimCheck::required_value))
.typ(ClaimCheck::if_present("JWT"))
.require_exp(true)
.require_iat(true)
.clock_leeway(self.clock_leeway)
.maybe_allowed_algorithms(self.allowed_algorithms.clone())
.build();
let validated_jwt = jwt_validator
.validate::<IdTokenClaims<E>>(id_token.token())
.await
.context(JwtSnafu)?;
ensure!(validated_jwt.subject.is_some(), SubjectMissingSnafu);
ensure!(
expected_nonce
.is_none_or(|expected| validated_jwt.claims.nonce.as_deref() == Some(expected)),
NonceMismatchSnafu
);
if let Some(max_age) = self.max_age {
ensure!(
validated_jwt.claims.auth_time.is_some(),
AuthTimeMissingSnafu
);
if let Some(auth_time) = validated_jwt.claims.auth_time {
let auth_at = SystemTime::UNIX_EPOCH + Duration::from_secs(auth_time);
let now = SystemTime::now();
ensure!(
now.duration_since(auth_at)
.is_ok_and(|d| d <= max_age + self.clock_leeway),
AuthTimeTooOldSnafu {
auth_time,
max_age_secs: max_age.as_secs(),
}
);
}
}
if validated_jwt.audience.len() > 1 {
let azp = validated_jwt
.claims
.azp
.as_deref()
.ok_or_else(|| AzpMissingSnafu.build())?;
if let Some(expected) = self.audience.as_deref() {
ensure!(
azp == expected,
AzpMismatchSnafu {
expected: expected.to_string(),
actual: azp.to_string(),
}
);
}
}
if let Some(expected_azp) = &self.expected_azp
&& let Some(azp) = validated_jwt.claims.azp.as_ref()
{
ensure!(
azp == expected_azp,
AzpMismatchSnafu {
expected: expected_azp.clone(),
actual: azp.clone(),
}
);
}
if let Some(required_acr) = &self.required_acr {
match validated_jwt.claims.acr.as_ref() {
Some(acr) => ensure!(
acr == required_acr,
AcrMismatchSnafu {
expected: required_acr.clone(),
actual: acr.clone(),
}
),
None => return AcrMissingSnafu.fail(),
}
}
Ok(validated_jwt)
}
}
#[derive(Debug, Snafu)]
pub enum IdTokenValidationError {
Jwt {
source: JwtValidationError,
},
NonceMismatch,
AuthTimeMissing,
SubjectMissing,
AuthTimeTooOld {
auth_time: u64,
max_age_secs: u64,
},
#[snafu(display("azp claim is required when aud contains multiple values"))]
AzpMissing,
AzpMismatch {
expected: String,
actual: String,
},
AcrMissing,
AcrMismatch {
expected: String,
actual: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::platform::Duration;
#[test]
fn updated_at_deserializes_from_unix_seconds() {
let json = r#"{"sub":"alice","updated_at":1700000000}"#;
let claims: IdTokenClaims = serde_json::from_str(json).unwrap();
let expected = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
assert_eq!(claims.updated_at, Some(expected));
}
#[test]
fn updated_at_serializes_as_unix_seconds() {
let claims = IdTokenClaims::<HashMap<String, serde_json::Value>> {
updated_at: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
..empty_claims()
};
let value: serde_json::Value = serde_json::to_value(&claims).unwrap();
assert_eq!(value["updated_at"], serde_json::json!(1_700_000_000));
}
#[test]
fn updated_at_round_trips() {
let json = r#"{"sub":"alice","updated_at":1700000000}"#;
let claims: IdTokenClaims = serde_json::from_str(json).unwrap();
let reserialized = serde_json::to_value(&claims).unwrap();
assert_eq!(reserialized["updated_at"], serde_json::json!(1_700_000_000));
}
#[test]
fn updated_at_absent_deserializes_to_none_and_is_omitted_on_serialize() {
let claims: IdTokenClaims = serde_json::from_str(r#"{"sub":"alice"}"#).unwrap();
assert!(claims.updated_at.is_none());
let value: serde_json::Value = serde_json::to_value(&claims).unwrap();
assert!(value.get("updated_at").is_none());
}
#[test]
fn updated_at_null_deserializes_to_none() {
let claims: IdTokenClaims =
serde_json::from_str(r#"{"sub":"alice","updated_at":null}"#).unwrap();
assert!(claims.updated_at.is_none());
}
fn empty_claims() -> IdTokenClaims<HashMap<String, serde_json::Value>> {
IdTokenClaims {
nonce: None,
auth_time: None,
acr: None,
amr: Vec::new(),
azp: None,
sub: None,
name: None,
given_name: None,
family_name: None,
middle_name: None,
nickname: None,
preferred_username: None,
profile: None,
picture: None,
website: None,
email: None,
email_verified: None,
gender: None,
birthdate: None,
zoneinfo: None,
locale: None,
phone_number: None,
phone_number_verified: None,
address: None,
updated_at: None,
extra: HashMap::new(),
}
}
}