use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::str::FromStr;
use std::sync::Arc;
use async_trait::async_trait;
use ppoppo_clock::ArcClock;
use ppoppo_clock::native::WallClock;
use ppoppo_token::id_token::{AuthError, Claims, Nonce, VerifyConfig as EngineVerifyConfig};
use ppoppo_token::SharedAuthError;
use time::OffsetDateTime;
use crate::audit::{AuditEvent, AuditSink, IdTokenFailureKind, VerifyErrorKind};
use crate::JwksCache;
use crate::VerifyConfig;
use crate::types::PpnumId;
use super::port::{IdAssertion, IdTokenVerifier, IdVerifyError, ScopePiiReader};
#[derive(Clone)]
pub struct PasIdTokenVerifier<S: ScopePiiReader> {
jwks: JwksCache,
expectations: VerifyConfig,
clock: ArcClock,
audit_sink: Option<Arc<dyn AuditSink>>,
_scope: PhantomData<S>,
}
impl<S: ScopePiiReader> std::fmt::Debug for PasIdTokenVerifier<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasIdTokenVerifier")
.field("expectations", &self.expectations)
.finish_non_exhaustive()
}
}
impl<S: ScopePiiReader> PasIdTokenVerifier<S> {
pub async fn from_jwks_url(
jwks_url: impl Into<String>,
expectations: VerifyConfig,
) -> Result<Self, IdVerifyError> {
let jwks = JwksCache::fetch(jwks_url)
.await
.map_err(|_| IdVerifyError::KeysetUnavailable)?;
Ok(Self {
jwks,
expectations,
clock: Arc::new(WallClock),
audit_sink: None,
_scope: PhantomData,
})
}
#[must_use]
pub fn with_audit(mut self, sink: Arc<dyn AuditSink>) -> Self {
self.audit_sink = Some(sink);
self
}
#[must_use]
pub fn with_clock(mut self, clock: ArcClock) -> Self {
self.jwks = self.jwks.with_clock(clock.clone());
self.clock = clock;
self
}
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub fn for_test_skip_fetch(expectations: VerifyConfig) -> Self {
Self {
jwks: JwksCache::for_test_empty(),
expectations,
clock: Arc::new(WallClock),
audit_sink: None,
_scope: PhantomData,
}
}
async fn emit_failure(&self, id_token: &str, err: IdVerifyError) -> IdVerifyError {
let Some(sink) = self.audit_sink.as_ref() else {
return err;
};
let kind = VerifyErrorKind::from(&err);
let (azp_hint, aud_hint, kid_hint) = peek_id_token_hints(id_token);
let mut metadata = BTreeMap::new();
if let IdVerifyError::Other(msg) = &err {
metadata.insert(
"engine_msg".to_owned(),
serde_json::Value::String(msg.clone()),
);
}
let event = AuditEvent::from_id_token_hints(
kind,
self.clock.now_utc(),
azp_hint,
aud_hint,
kid_hint,
metadata,
);
sink.record_failure(event).await;
err
}
}
fn peek_id_token_hints(token: &str) -> (Option<String>, Option<String>, Option<String>) {
use base64::Engine as _;
let mut parts = token.split('.');
let header_b64 = parts.next();
let payload_b64 = parts.next();
let kid_hint = header_b64.and_then(|h| {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(h).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value.get("kid").and_then(|k| k.as_str()).map(str::to_owned)
});
let payload_value = payload_b64.and_then(|p| {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(p).ok()?;
serde_json::from_slice::<serde_json::Value>(&bytes).ok()
});
let azp_hint = payload_value
.as_ref()
.and_then(|v| v.get("azp"))
.and_then(|a| a.as_str())
.map(str::to_owned);
let aud_hint = payload_value.as_ref().and_then(|v| v.get("aud")).and_then(|aud| {
match aud {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(arr) => arr
.first()
.and_then(|first| first.as_str())
.map(str::to_owned),
_ => None,
}
});
(azp_hint, aud_hint, kid_hint)
}
impl From<&IdVerifyError> for VerifyErrorKind {
fn from(err: &IdVerifyError) -> Self {
use IdTokenFailureKind as K;
use IdVerifyError as E;
match err {
E::InvalidFormat => Self::InvalidFormat,
E::SignatureInvalid => Self::SignatureInvalid,
E::Expired => Self::Expired,
E::IssuerInvalid => Self::IssuerInvalid,
E::AudienceInvalid => Self::AudienceInvalid,
E::MissingClaim(c) => Self::MissingClaim((*c).to_owned()),
E::KeysetUnavailable => Self::KeysetUnavailable,
E::NonceMissing => Self::IdToken(K::NonceMissing),
E::NonceMismatch => Self::IdToken(K::NonceMismatch),
E::AtHashMissing => Self::IdToken(K::AtHashMissing),
E::AtHashMismatch => Self::IdToken(K::AtHashMismatch),
E::CHashMissing => Self::IdToken(K::CHashMissing),
E::CHashMismatch => Self::IdToken(K::CHashMismatch),
E::AzpMissing => Self::IdToken(K::AzpMissing),
E::AzpMismatch => Self::IdToken(K::AzpMismatch),
E::AuthTimeMissing => Self::IdToken(K::AuthTimeMissing),
E::AuthTimeStale => Self::IdToken(K::AuthTimeStale),
E::AcrMissing => Self::IdToken(K::AcrMissing),
E::AcrNotAllowed => Self::IdToken(K::AcrNotAllowed),
E::UnknownClaim(name) => Self::IdToken(K::UnknownClaim(name.clone())),
E::CatMismatch(value) => Self::IdToken(K::CatMismatch(value.clone())),
E::Other(_) => Self::Other,
}
}
}
#[async_trait]
impl<S: ScopePiiReader> IdTokenVerifier<S> for PasIdTokenVerifier<S> {
async fn verify(
&self,
id_token: &str,
expected_nonce: &Nonce,
) -> Result<IdAssertion<S>, IdVerifyError> {
if id_token.is_empty() || !looks_like_jws_compact(id_token) {
return Err(self
.emit_failure(id_token, IdVerifyError::InvalidFormat)
.await);
}
let cfg = EngineVerifyConfig::id_token(
self.expectations.issuer.clone(),
self.expectations.audience.clone(),
expected_nonce.clone(),
);
let keyset = self.jwks.snapshot().await;
let now = self.clock.now_utc().unix_timestamp();
let claims = match ppoppo_token::id_token::verify::<S>(id_token, &cfg, &keyset, now).await {
Ok(c) => c,
Err(e) => {
let mapped = IdVerifyError::from(e);
return Err(self.emit_failure(id_token, mapped).await);
}
};
match claims_to_assertion::<S>(claims) {
Ok(assertion) => Ok(assertion),
Err(err) => Err(self.emit_failure(id_token, err).await),
}
}
}
fn looks_like_jws_compact(token: &str) -> bool {
token.split('.').count() == 3
}
fn claims_to_assertion<S: ScopePiiReader>(
claims: Claims<S>,
) -> Result<IdAssertion<S>, IdVerifyError> {
let sub = ulid::Ulid::from_str(&claims.sub)
.map(PpnumId)
.map_err(|_| IdVerifyError::MissingClaim("sub"))?;
let exp = OffsetDateTime::from_unix_timestamp(claims.exp)
.map_err(|_| IdVerifyError::MissingClaim("exp"))?;
let iat = OffsetDateTime::from_unix_timestamp(claims.iat)
.map_err(|_| IdVerifyError::MissingClaim("iat"))?;
let auth_time = match claims.auth_time {
Some(ts) => Some(
OffsetDateTime::from_unix_timestamp(ts)
.map_err(|_| IdVerifyError::MissingClaim("auth_time"))?,
),
None => None,
};
let mut assertion = IdAssertion::<S>::new_base(
claims.iss.clone(),
sub,
claims.aud.clone(),
exp,
iat,
claims.nonce.clone(),
claims.azp.clone(),
auth_time,
claims.acr.clone(),
claims.amr.clone(),
);
S::fill_pii(&claims, &mut assertion);
Ok(assertion)
}
fn map_auth_error(err: AuthError) -> IdVerifyError {
use AuthError as E;
use SharedAuthError as S;
match err {
E::NonceMissing => IdVerifyError::NonceMissing,
E::NonceMismatch => IdVerifyError::NonceMismatch,
E::NonceConfigEmpty => IdVerifyError::MissingClaim("nonce"),
E::AtHashMissing => IdVerifyError::AtHashMissing,
E::AtHashMismatch => IdVerifyError::AtHashMismatch,
E::CHashMissing => IdVerifyError::CHashMissing,
E::CHashMismatch => IdVerifyError::CHashMismatch,
E::AzpMissing => IdVerifyError::AzpMissing,
E::AzpMismatch => IdVerifyError::AzpMismatch,
E::AuthTimeMissing => IdVerifyError::AuthTimeMissing,
E::AuthTimeStale => IdVerifyError::AuthTimeStale,
E::AcrMissing => IdVerifyError::AcrMissing,
E::AcrNotAllowed => IdVerifyError::AcrNotAllowed,
E::UnknownClaim(name) => IdVerifyError::UnknownClaim(name),
E::CatMismatch(value) => IdVerifyError::CatMismatch(value),
E::Jose(
S::AlgNone
| S::AlgNotWhitelisted
| S::AlgHmacRejected
| S::AlgRsaRejected
| S::AlgEcdsaRejected
| S::HeaderJku
| S::HeaderX5u
| S::HeaderJwk
| S::HeaderX5c
| S::HeaderCrit
| S::HeaderExtraParam
| S::HeaderB64False
| S::KidUnknown
| S::TypMismatch
| S::NestedJws
| S::DuplicateJsonKeys
| S::HeaderUnparseable
| S::PayloadUnparseable
| S::NotJwsCompact,
) => IdVerifyError::SignatureInvalid,
E::Jose(
S::OversizedToken | S::JwsJsonRejected | S::JwePayload | S::LaxBase64,
) => IdVerifyError::InvalidFormat,
}
}
impl From<AuthError> for IdVerifyError {
fn from(err: AuthError) -> Self {
map_auth_error(err)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use ppoppo_token::id_token::scopes;
#[tokio::test]
async fn from_jwks_url_with_invalid_url_yields_keyset_unavailable() {
let result = PasIdTokenVerifier::<scopes::Openid>::from_jwks_url(
"http://nonexistent.invalid/.well-known/jwks.json",
VerifyConfig::new("accounts.ppoppo.com", "rp-client-id"),
)
.await;
let err = result.expect_err("bad URL must fail construction");
assert_eq!(err, IdVerifyError::KeysetUnavailable);
}
#[tokio::test]
async fn verify_empty_token_yields_invalid_format() {
let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
VerifyConfig::new("accounts.ppoppo.com", "rp-client-id"),
);
let nonce = Nonce::new("test-nonce").unwrap();
let err = verifier.verify("", &nonce).await.expect_err("empty rejects");
assert_eq!(err, IdVerifyError::InvalidFormat);
}
#[tokio::test]
async fn verify_two_segment_token_yields_invalid_format() {
let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
VerifyConfig::new("accounts.ppoppo.com", "rp-client-id"),
);
let nonce = Nonce::new("test-nonce").unwrap();
let err = verifier
.verify("aaa.bbb", &nonce)
.await
.expect_err("2-segment rejects");
assert_eq!(err, IdVerifyError::InvalidFormat);
}
#[test]
fn from_auth_error_covers_oidc_specific_rows() {
assert_eq!(
IdVerifyError::from(AuthError::NonceMissing),
IdVerifyError::NonceMissing
);
assert_eq!(
IdVerifyError::from(AuthError::AzpMismatch),
IdVerifyError::AzpMismatch
);
assert_eq!(
IdVerifyError::from(AuthError::CatMismatch("access".to_owned())),
IdVerifyError::CatMismatch("access".to_owned())
);
assert_eq!(
IdVerifyError::from(AuthError::UnknownClaim("backdoor".to_owned())),
IdVerifyError::UnknownClaim("backdoor".to_owned())
);
assert_eq!(
IdVerifyError::from(AuthError::NonceConfigEmpty),
IdVerifyError::MissingClaim("nonce")
);
}
#[test]
fn from_auth_error_collapses_jose_to_signature_invalid_or_invalid_format() {
assert_eq!(
IdVerifyError::from(AuthError::Jose(SharedAuthError::AlgNone)),
IdVerifyError::SignatureInvalid
);
assert_eq!(
IdVerifyError::from(AuthError::Jose(SharedAuthError::KidUnknown)),
IdVerifyError::SignatureInvalid
);
assert_eq!(
IdVerifyError::from(AuthError::Jose(SharedAuthError::TypMismatch)),
IdVerifyError::SignatureInvalid
);
assert_eq!(
IdVerifyError::from(AuthError::Jose(SharedAuthError::OversizedToken)),
IdVerifyError::InvalidFormat
);
assert_eq!(
IdVerifyError::from(AuthError::Jose(SharedAuthError::JwsJsonRejected)),
IdVerifyError::InvalidFormat
);
}
}