use std::marker::PhantomData;
use async_trait::async_trait;
use ppoppo_token::id_token::{
AddressClaim, Claims, HasAddress, HasEmail, HasPhone, HasProfile, Nonce, ScopeSet,
scopes::{
Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
},
};
use time::OffsetDateTime;
use crate::types::PpnumId;
#[async_trait]
pub trait IdTokenVerifier<S: ScopeSet>: Send + Sync {
async fn verify(
&self,
id_token: &str,
expected_nonce: &Nonce,
) -> Result<IdAssertion<S>, IdVerifyError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Address {
pub formatted: Option<String>,
pub street_address: Option<String>,
pub locality: Option<String>,
pub region: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
}
impl From<&AddressClaim> for Address {
fn from(c: &AddressClaim) -> Self {
Self {
formatted: c.formatted.clone(),
street_address: c.street_address.clone(),
locality: c.locality.clone(),
region: c.region.clone(),
postal_code: c.postal_code.clone(),
country: c.country.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdAssertion<S: ScopeSet> {
iss: String,
sub: PpnumId,
aud: Vec<String>,
exp: OffsetDateTime,
iat: OffsetDateTime,
nonce: String,
azp: Option<String>,
auth_time: Option<OffsetDateTime>,
acr: Option<String>,
amr: Option<Vec<String>>,
pub(crate) email: Option<String>,
pub(crate) email_verified: Option<bool>,
pub(crate) name: Option<String>,
pub(crate) given_name: Option<String>,
pub(crate) family_name: Option<String>,
pub(crate) middle_name: Option<String>,
pub(crate) nickname: Option<String>,
pub(crate) preferred_username: Option<String>,
pub(crate) profile: Option<String>,
pub(crate) picture: Option<String>,
pub(crate) website: Option<String>,
pub(crate) gender: Option<String>,
pub(crate) birthdate: Option<String>,
pub(crate) zoneinfo: Option<String>,
pub(crate) locale: Option<String>,
pub(crate) updated_at: Option<OffsetDateTime>,
pub(crate) phone_number: Option<String>,
pub(crate) phone_number_verified: Option<bool>,
pub(crate) address: Option<Address>,
pub(crate) _scope: PhantomData<S>,
}
impl<S: ScopeSet> IdAssertion<S> {
#[allow(clippy::too_many_arguments, dead_code)]
pub(crate) fn new_base(
iss: String,
sub: PpnumId,
aud: Vec<String>,
exp: OffsetDateTime,
iat: OffsetDateTime,
nonce: String,
azp: Option<String>,
auth_time: Option<OffsetDateTime>,
acr: Option<String>,
amr: Option<Vec<String>>,
) -> Self {
Self {
iss,
sub,
aud,
exp,
iat,
nonce,
azp,
auth_time,
acr,
amr,
email: None,
email_verified: None,
name: None,
given_name: None,
family_name: None,
middle_name: None,
nickname: None,
preferred_username: None,
profile: None,
picture: None,
website: None,
gender: None,
birthdate: None,
zoneinfo: None,
locale: None,
updated_at: None,
phone_number: None,
phone_number_verified: None,
address: None,
_scope: PhantomData,
}
}
#[cfg(any(test, feature = "test-support"))]
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn for_test(
iss: impl Into<String>,
sub: PpnumId,
aud: Vec<String>,
exp: OffsetDateTime,
iat: OffsetDateTime,
nonce: impl Into<String>,
) -> Self {
Self::new_base(
iss.into(),
sub,
aud,
exp,
iat,
nonce.into(),
None,
None,
None,
None,
)
}
#[must_use]
pub fn iss(&self) -> &str {
&self.iss
}
#[must_use]
pub fn sub(&self) -> &PpnumId {
&self.sub
}
#[must_use]
pub fn aud(&self) -> &[String] {
&self.aud
}
#[must_use]
pub fn exp(&self) -> OffsetDateTime {
self.exp
}
#[must_use]
pub fn iat(&self) -> OffsetDateTime {
self.iat
}
#[must_use]
pub fn nonce(&self) -> &str {
&self.nonce
}
#[must_use]
pub fn azp(&self) -> Option<&str> {
self.azp.as_deref()
}
#[must_use]
pub fn auth_time(&self) -> Option<OffsetDateTime> {
self.auth_time
}
#[must_use]
pub fn acr(&self) -> Option<&str> {
self.acr.as_deref()
}
#[must_use]
pub fn amr(&self) -> Option<&[String]> {
self.amr.as_deref()
}
}
impl<S: HasEmail> IdAssertion<S> {
#[must_use]
pub fn email(&self) -> &str {
self.email
.as_deref()
.expect("HasEmail bound implies email Some — IdP drift if absent")
}
#[must_use]
pub fn email_verified(&self) -> Option<bool> {
self.email_verified
}
}
impl<S: HasProfile> IdAssertion<S> {
#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
#[must_use]
pub fn given_name(&self) -> Option<&str> {
self.given_name.as_deref()
}
#[must_use]
pub fn family_name(&self) -> Option<&str> {
self.family_name.as_deref()
}
#[must_use]
pub fn middle_name(&self) -> Option<&str> {
self.middle_name.as_deref()
}
#[must_use]
pub fn nickname(&self) -> Option<&str> {
self.nickname.as_deref()
}
#[must_use]
pub fn preferred_username(&self) -> Option<&str> {
self.preferred_username.as_deref()
}
#[must_use]
pub fn profile(&self) -> Option<&str> {
self.profile.as_deref()
}
#[must_use]
pub fn picture(&self) -> Option<&str> {
self.picture.as_deref()
}
#[must_use]
pub fn website(&self) -> Option<&str> {
self.website.as_deref()
}
#[must_use]
pub fn gender(&self) -> Option<&str> {
self.gender.as_deref()
}
#[must_use]
pub fn birthdate(&self) -> Option<&str> {
self.birthdate.as_deref()
}
#[must_use]
pub fn zoneinfo(&self) -> Option<&str> {
self.zoneinfo.as_deref()
}
#[must_use]
pub fn locale(&self) -> Option<&str> {
self.locale.as_deref()
}
#[must_use]
pub fn updated_at(&self) -> Option<OffsetDateTime> {
self.updated_at
}
}
impl<S: HasPhone> IdAssertion<S> {
#[must_use]
pub fn phone_number(&self) -> Option<&str> {
self.phone_number.as_deref()
}
#[must_use]
pub fn phone_number_verified(&self) -> Option<bool> {
self.phone_number_verified
}
}
impl<S: HasAddress> IdAssertion<S> {
#[must_use]
pub fn address(&self) -> Option<&Address> {
self.address.as_ref()
}
}
pub trait ScopePiiReader:
ScopeSet + Sized + Clone + std::fmt::Debug + PartialEq + Eq + Send + Sync + 'static
{
fn fill_pii(claims: &Claims<Self>, assertion: &mut IdAssertion<Self>);
}
fn fill_email<S: HasEmail>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
a.email = Some(claims.email().to_owned());
a.email_verified = claims.email_verified();
}
fn fill_profile<S: HasProfile>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
a.name = claims.name().map(str::to_owned);
a.given_name = claims.given_name().map(str::to_owned);
a.family_name = claims.family_name().map(str::to_owned);
a.middle_name = claims.middle_name().map(str::to_owned);
a.nickname = claims.nickname().map(str::to_owned);
a.preferred_username = claims.preferred_username().map(str::to_owned);
a.profile = claims.profile().map(str::to_owned);
a.picture = claims.picture().map(str::to_owned);
a.website = claims.website().map(str::to_owned);
a.gender = claims.gender().map(str::to_owned);
a.birthdate = claims.birthdate().map(str::to_owned);
a.zoneinfo = claims.zoneinfo().map(str::to_owned);
a.locale = claims.locale().map(str::to_owned);
a.updated_at = claims
.updated_at()
.and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok());
}
fn fill_phone<S: HasPhone>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
a.phone_number = claims.phone_number().map(str::to_owned);
a.phone_number_verified = claims.phone_number_verified();
}
fn fill_address<S: HasAddress>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
a.address = claims.address().map(Address::from);
}
impl ScopePiiReader for Openid {
fn fill_pii(_: &Claims<Self>, _: &mut IdAssertion<Self>) {}
}
impl ScopePiiReader for Email {
fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
fill_email(claims, a);
}
}
impl ScopePiiReader for Profile {
fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
fill_profile(claims, a);
}
}
impl ScopePiiReader for EmailProfile {
fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
fill_email(claims, a);
fill_profile(claims, a);
}
}
impl ScopePiiReader for EmailProfilePhone {
fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
fill_email(claims, a);
fill_profile(claims, a);
fill_phone(claims, a);
}
}
impl ScopePiiReader for EmailProfilePhoneAddress {
fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
fill_email(claims, a);
fill_profile(claims, a);
fill_phone(claims, a);
fill_address(claims, a);
}
}
#[cfg(any(test, feature = "test-support"))]
impl<S: ScopeSet> IdAssertion<S> {
#[must_use]
pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
self.azp = Some(azp.into());
self
}
}
#[cfg(any(test, feature = "test-support"))]
impl<S: HasEmail> IdAssertion<S> {
#[must_use]
pub fn with_email(mut self, email: impl Into<String>, verified: Option<bool>) -> Self {
self.email = Some(email.into());
self.email_verified = verified;
self
}
}
#[cfg(any(test, feature = "test-support"))]
impl<S: HasProfile> IdAssertion<S> {
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[cfg(any(test, feature = "test-support"))]
impl<S: HasPhone> IdAssertion<S> {
#[must_use]
pub fn with_phone_number(
mut self,
phone: impl Into<String>,
verified: Option<bool>,
) -> Self {
self.phone_number = Some(phone.into());
self.phone_number_verified = verified;
self
}
}
#[cfg(any(test, feature = "test-support"))]
impl<S: HasAddress> IdAssertion<S> {
#[must_use]
pub fn with_address(mut self, address: Address) -> Self {
self.address = Some(address);
self
}
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum IdVerifyError {
#[error("invalid id_token format")]
InvalidFormat,
#[error("signature verification failed")]
SignatureInvalid,
#[error("id_token expired")]
Expired,
#[error("issuer invalid")]
IssuerInvalid,
#[error("audience invalid")]
AudienceInvalid,
#[error("missing required claim: {0}")]
MissingClaim(&'static str),
#[error("keyset unavailable")]
KeysetUnavailable,
#[error("M66: nonce claim absent from payload")]
NonceMissing,
#[error("M66: nonce does not match expected value")]
NonceMismatch,
#[error("M67: at_hash claim absent from payload")]
AtHashMissing,
#[error("M67: at_hash does not match expected access_token binding")]
AtHashMismatch,
#[error("M68: c_hash claim absent from payload")]
CHashMissing,
#[error("M68: c_hash does not match expected authorization_code binding")]
CHashMismatch,
#[error("M69: azp claim absent on multi-audience id_token")]
AzpMissing,
#[error("M69: azp does not match expected client_id")]
AzpMismatch,
#[error("M70: auth_time claim absent while max_age is configured")]
AuthTimeMissing,
#[error("M70: auth_time exceeds max_age window — re-authentication required")]
AuthTimeStale,
#[error("M71: acr claim absent while acr_values is configured")]
AcrMissing,
#[error("M71: acr value not in configured acr_values allowlist")]
AcrNotAllowed,
#[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
UnknownClaim(String),
#[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
CatMismatch(String),
#[error("verification failed: {0}")]
Other(String),
}
#[cfg(test)]
mod tests {
use super::*;
use ppoppo_token::id_token::scopes;
use ulid::Ulid;
fn fixture_sub() -> PpnumId {
PpnumId(
Ulid::from_string("01HK0000000000000000000001")
.expect("test ulid")
)
}
#[test]
fn for_test_constructor_yields_openid_assertion() {
let now = OffsetDateTime::now_utc();
let a: IdAssertion<scopes::Openid> = IdAssertion::for_test(
"accounts.ppoppo.com",
fixture_sub(),
vec!["rp-client".to_owned()],
now + time::Duration::hours(1),
now,
"n-0S6_WzA2Mj",
);
assert_eq!(a.iss(), "accounts.ppoppo.com");
assert_eq!(a.aud(), &["rp-client".to_owned()]);
assert_eq!(a.nonce(), "n-0S6_WzA2Mj");
assert!(a.azp().is_none());
assert!(a.auth_time().is_none());
}
#[test]
fn with_email_builder_populates_pii() {
let now = OffsetDateTime::now_utc();
let a: IdAssertion<scopes::Email> = IdAssertion::for_test(
"accounts.ppoppo.com",
fixture_sub(),
vec!["rp-client".to_owned()],
now + time::Duration::hours(1),
now,
"nonce",
)
.with_email("user@example.com", Some(true));
assert_eq!(a.email(), "user@example.com");
assert_eq!(a.email_verified(), Some(true));
}
#[test]
fn id_verify_error_display_preserves_m_codes() {
assert_eq!(
format!("{}", IdVerifyError::NonceMismatch),
"M66: nonce does not match expected value"
);
assert_eq!(
format!("{}", IdVerifyError::CatMismatch("access".to_owned())),
"M29-mirror: id_token cat must be 'id', got 'access'"
);
assert_eq!(
format!("{}", IdVerifyError::AzpMismatch),
"M69: azp does not match expected client_id"
);
}
#[allow(dead_code)]
fn dyn_object_safety<S: ScopeSet>() {
fn _accept<S: ScopeSet>(_: std::sync::Arc<dyn IdTokenVerifier<S>>) {}
}
#[allow(dead_code)]
fn scope_pii_reader_impls_exist() {
fn _accept<S: ScopePiiReader>() {}
_accept::<Openid>();
_accept::<Email>();
_accept::<Profile>();
_accept::<EmailProfile>();
_accept::<EmailProfilePhone>();
_accept::<EmailProfilePhoneAddress>();
}
}