use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use bon::Builder;
use serde::{Deserialize, Serialize};
use snafu::{ensure, prelude::*};
use crate::core::{
crypto::verifier::JwsVerifier,
jwt::validator::{
ClaimCheck, JwtValidationError, JwtValidator, ValidatedJwt, within_max_age_secs,
},
platform::{Duration, SystemTime},
};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdToken(String);
impl std::fmt::Debug for IdToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("IdToken").field(&"[REDACTED]").finish()
}
}
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, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct IdTokenClaims {
#[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 sid: Option<String>,
#[serde(flatten)]
pub profile: StandardOidcProfileClaims,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StandardOidcProfileClaims {
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
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)]
#[builder(on(String, into))]
pub struct IdTokenValidator {
#[builder(with = |verifier: impl JwsVerifier + 'static| Arc::new(verifier) as Arc<dyn JwsVerifier>)]
verifier: Arc<dyn JwsVerifier>,
issuer: String,
audience: String,
#[builder(default)]
trusted_audiences: Vec<String>,
max_age: Option<Duration>,
#[builder(default = Duration::from_secs(10))]
clock_leeway: Duration,
required_acr: Option<String>,
allowed_algorithms: Option<HashSet<String>>,
}
impl IdTokenValidator {
pub async fn validate(
&self,
id_token: &IdToken,
expected_nonce: Option<&str>,
) -> Result<ValidatedJwt<IdTokenClaims>, IdTokenValidationError> {
let jwt_validator = JwtValidator::builder()
.verifier(self.verifier.clone())
.iss(ClaimCheck::required_value(self.issuer.clone()))
.aud(ClaimCheck::required_value(self.audience.clone()))
.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>(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 {
ensure!(
within_max_age_secs(SystemTime::now(), auth_time, max_age, self.clock_leeway),
AuthTimeTooOldSnafu {
auth_time,
max_age_secs: max_age.as_secs(),
}
);
}
}
let untrusted_audiences: Vec<String> = validated_jwt
.audience
.iter()
.filter(|aud| *aud != &self.audience && !self.trusted_audiences.contains(*aud))
.cloned()
.collect();
ensure!(
untrusted_audiences.is_empty(),
UntrustedAudienceSnafu {
untrusted: untrusted_audiences,
}
);
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)]
#[non_exhaustive]
pub enum IdTokenValidationError {
Jwt {
source: JwtValidationError,
},
NonceMismatch,
AuthTimeMissing,
SubjectMissing,
AuthTimeTooOld {
auth_time: u64,
max_age_secs: u64,
},
#[snafu(display("token contains untrusted audience(s): {}", untrusted.join(", ")))]
UntrustedAudience {
untrusted: Vec<String>,
},
AcrMissing,
AcrMismatch {
expected: String,
actual: String,
},
}
#[cfg(test)]
mod tests {
use huskarl_crypto_native::{
NativeVerifierPlatform,
asymmetric::signer::{GenerateAlgorithm, PrivateKey},
};
use rstest::rstest;
use super::*;
use crate::core::{
crypto::{signer::AsymmetricJwsSigner, verifier::JwsVerifierPlatform},
jwt::Jwt,
platform::Duration,
};
const ISS: &str = "https://issuer.example.com";
const AUD: &str = "client-123";
const SUB: &str = "user-abc";
async fn signer_and_verifier() -> (PrivateKey, Arc<dyn JwsVerifier>) {
let signer = PrivateKey::generate(GenerateAlgorithm::Es256, None).unwrap();
let verifier = NativeVerifierPlatform
.create_verifier_from_jwk(signer.public_key_jwk().into_owned())
.await
.unwrap();
(signer, verifier)
}
async fn mint(
signer: &PrivateKey,
iss: &str,
audiences: Vec<String>,
sub: Option<&str>,
claims: IdTokenClaims,
) -> IdToken {
let token = Jwt::builder()
.typ("JWT")
.issuer(iss.to_string())
.audiences(audiences)
.maybe_subject(sub.map(str::to_string))
.issued_now_expires_after(Duration::from_hours(1))
.claims(claims)
.build()
.to_jws_compact(signer)
.await
.unwrap();
IdToken::from(token.expose_secret())
}
async fn mint_standard(signer: &PrivateKey, claims: IdTokenClaims) -> IdToken {
mint(signer, ISS, vec![AUD.to_string()], Some(SUB), claims).await
}
fn validator(
verifier: Arc<dyn JwsVerifier>,
max_age: Option<Duration>,
required_acr: Option<&str>,
) -> IdTokenValidator {
IdTokenValidator::builder()
.verifier(verifier)
.issuer(ISS)
.audience(AUD)
.maybe_max_age(max_age)
.maybe_required_acr(required_acr.map(str::to_string))
.build()
}
#[tokio::test]
async fn valid_id_token_is_accepted() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(&signer, IdTokenClaims::default()).await;
let validated = validator(verifier, None, None)
.validate(&token, None)
.await
.expect("token should validate");
assert_eq!(validated.subject.as_deref(), Some(SUB));
assert_eq!(validated.issuer.as_deref(), Some(ISS));
}
#[tokio::test]
async fn missing_subject_is_rejected() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint(
&signer,
ISS,
vec![AUD.to_string()],
None,
IdTokenClaims::default(),
)
.await;
let err = validator(verifier, None, None)
.validate(&token, None)
.await
.unwrap_err();
assert!(
matches!(err, IdTokenValidationError::SubjectMissing),
"got {err:?}"
);
}
#[tokio::test]
async fn nonce_mismatch_is_rejected() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(
&signer,
IdTokenClaims {
nonce: Some("actual".to_string()),
..IdTokenClaims::default()
},
)
.await;
let err = validator(verifier, None, None)
.validate(&token, Some("expected"))
.await
.unwrap_err();
assert!(
matches!(err, IdTokenValidationError::NonceMismatch),
"got {err:?}"
);
}
#[tokio::test]
async fn matching_nonce_is_accepted() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(
&signer,
IdTokenClaims {
nonce: Some("the-nonce".to_string()),
..IdTokenClaims::default()
},
)
.await;
validator(verifier, None, None)
.validate(&token, Some("the-nonce"))
.await
.expect("matching nonce should validate");
}
#[tokio::test]
async fn token_for_other_client_is_rejected() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint(
&signer,
ISS,
vec!["other-client".to_string()],
Some(SUB),
IdTokenClaims::default(),
)
.await;
let err = validator(verifier, None, None)
.validate(&token, None)
.await
.unwrap_err();
assert!(matches!(err, IdTokenValidationError::Jwt { .. }), "{err:?}");
}
#[tokio::test]
async fn max_age_without_auth_time_is_rejected() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(&signer, IdTokenClaims::default()).await;
let err = validator(verifier, Some(Duration::from_mins(1)), None)
.validate(&token, None)
.await
.unwrap_err();
assert!(
matches!(err, IdTokenValidationError::AuthTimeMissing),
"got {err:?}"
);
}
#[tokio::test]
async fn auth_time_slightly_in_future_is_accepted() {
let (signer, verifier) = signer_and_verifier().await;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let token = mint_standard(
&signer,
IdTokenClaims {
auth_time: Some(now + 2),
..IdTokenClaims::default()
},
)
.await;
validator(verifier, Some(Duration::from_mins(1)), None)
.validate(&token, None)
.await
.expect("fresh login with 2s AS clock skew should validate");
}
#[tokio::test]
async fn auth_time_older_than_max_age_is_rejected() {
let (signer, verifier) = signer_and_verifier().await;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let token = mint_standard(
&signer,
IdTokenClaims {
auth_time: Some(now - 1000),
..IdTokenClaims::default()
},
)
.await;
let err = validator(verifier, Some(Duration::from_mins(1)), None)
.validate(&token, None)
.await
.unwrap_err();
assert!(
matches!(err, IdTokenValidationError::AuthTimeTooOld { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn recent_auth_time_within_max_age_is_accepted() {
let (signer, verifier) = signer_and_verifier().await;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let token = mint_standard(
&signer,
IdTokenClaims {
auth_time: Some(now - 10),
..IdTokenClaims::default()
},
)
.await;
validator(verifier, Some(Duration::from_mins(5)), None)
.validate(&token, None)
.await
.expect("recent auth_time should validate");
}
#[tokio::test]
async fn auth_time_at_u64_max_does_not_panic() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(
&signer,
IdTokenClaims {
auth_time: Some(u64::MAX),
..IdTokenClaims::default()
},
)
.await;
validator(verifier, Some(Duration::from_mins(1)), None)
.validate(&token, None)
.await
.expect("overflowing auth_time must not panic");
}
#[tokio::test]
async fn non_matching_azp_is_ignored() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(
&signer,
IdTokenClaims {
azp: Some("someone-else".to_string()),
..IdTokenClaims::default()
},
)
.await;
let validated = validator(verifier, None, None)
.validate(&token, None)
.await
.expect("a non-matching azp must be ignored, not rejected");
assert_eq!(validated.claims.azp.as_deref(), Some("someone-else"));
}
#[tokio::test]
async fn audience_not_trusted_is_rejected() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint(
&signer,
ISS,
vec![AUD.to_string(), "other-aud".to_string()],
Some(SUB),
IdTokenClaims::default(),
)
.await;
let err = validator(verifier, None, None)
.validate(&token, None)
.await
.unwrap_err();
assert!(
matches!(
&err,
IdTokenValidationError::UntrustedAudience { untrusted }
if untrusted == &["other-aud".to_string()]
),
"got {err:?}"
);
}
#[tokio::test]
async fn extra_audience_in_trusted_set_is_accepted() {
let (signer, verifier) = signer_and_verifier().await;
let token = mint(
&signer,
ISS,
vec![AUD.to_string(), "other-aud".to_string()],
Some(SUB),
IdTokenClaims::default(),
)
.await;
IdTokenValidator::builder()
.verifier(verifier)
.issuer(ISS)
.audience(AUD)
.trusted_audiences(vec!["other-aud".to_string()])
.build()
.validate(&token, None)
.await
.expect("a listed extra audience should validate");
}
#[rstest]
#[case::missing(None, true)]
#[case::mismatch(Some("urn:loa:low"), true)]
#[case::matches(Some("urn:loa:high"), false)]
#[tokio::test]
async fn required_acr_is_enforced(#[case] token_acr: Option<&str>, #[case] expect_err: bool) {
let (signer, verifier) = signer_and_verifier().await;
let token = mint_standard(
&signer,
IdTokenClaims {
acr: token_acr.map(str::to_string),
..IdTokenClaims::default()
},
)
.await;
let result = validator(verifier, None, Some("urn:loa:high"))
.validate(&token, None)
.await;
assert_eq!(result.is_err(), expect_err, "got {result:?}");
}
#[test]
fn id_token_debug_redacts_the_raw_jws() {
let token = IdToken::from("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig");
let rendered = format!("{token:?}");
assert!(rendered.contains("[REDACTED]"), "got {rendered}");
assert!(!rendered.contains("alice"), "got {rendered}");
assert!(!rendered.contains("eyJ"), "got {rendered}");
}
#[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.profile.updated_at, Some(expected));
}
#[test]
fn updated_at_serializes_as_unix_seconds() {
let claims = IdTokenClaims {
profile: StandardOidcProfileClaims {
updated_at: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
..StandardOidcProfileClaims::default()
},
..IdTokenClaims::default()
};
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.profile.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.profile.updated_at.is_none());
}
#[test]
fn sid_deserializes_to_typed_field_not_extra() {
let json = r#"{"sub":"alice","sid":"sess-123"}"#;
let claims: IdTokenClaims = serde_json::from_str(json).unwrap();
assert_eq!(claims.sid.as_deref(), Some("sess-123"));
assert!(!claims.extra.contains_key("sid"));
}
#[test]
fn unknown_claims_land_in_extra_not_profile() {
let json = r#"{"sub":"alice","email":"a@example.com","groups":["admin"]}"#;
let claims: IdTokenClaims = serde_json::from_str(json).unwrap();
assert_eq!(claims.profile.email.as_deref(), Some("a@example.com"));
assert_eq!(claims.extra["groups"], serde_json::json!(["admin"]));
assert!(!claims.extra.contains_key("email"));
}
}