use core::fmt;
use crate::jwt::{AsymmetricVerifyingKey, JwtClaims, verify_jwt_asymmetric};
use crate::util::timestamp::Timestamp;
pub const CLOCK_SKEW_LEEWAY_SECS: u64 = 60;
#[doc(alias = "client_assertion_error")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClientAssertionDenied {
NoKeys,
SignatureInvalid,
IssuerMismatch,
AudienceMismatch,
MissingExpiry,
Expired,
NotYetValid,
}
impl ClientAssertionDenied {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NoKeys => "no registered keys to verify against",
Self::SignatureInvalid => "assertion signature is invalid",
Self::IssuerMismatch => "iss/sub does not match client_id",
Self::AudienceMismatch => "aud does not match the token endpoint",
Self::MissingExpiry => "assertion is missing exp",
Self::Expired => "assertion has expired",
Self::NotYetValid => "assertion is not yet valid (nbf in the future)",
}
}
}
impl fmt::Display for ClientAssertionDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "client assertion rejected: {}", self.as_str())
}
}
impl std::error::Error for ClientAssertionDenied {}
#[derive(Debug, Clone, Copy)]
pub struct ClientAssertion<'a> {
pub jwt: &'a str,
pub client_id: &'a str,
pub audience: &'a str,
}
pub fn verify_client_assertion(
assertion: &ClientAssertion<'_>,
keys: &[AsymmetricVerifyingKey],
now: Timestamp,
) -> Result<JwtClaims, ClientAssertionDenied> {
use ClientAssertionDenied as D;
if keys.is_empty() {
return Err(D::NoKeys);
}
let claims = keys
.iter()
.find_map(|key| verify_jwt_asymmetric(assertion.jwt, key).ok())
.map(|(_, claims)| claims)
.ok_or(D::SignatureInvalid)?;
if claims.iss() != Some(assertion.client_id) || claims.sub() != Some(assertion.client_id) {
return Err(D::IssuerMismatch);
}
if !claims.validate_aud(assertion.audience) {
return Err(D::AudienceMismatch);
}
let now_secs = now.unix_epoch_secs();
let exp = claims.exp().ok_or(D::MissingExpiry)?;
if exp.saturating_add(CLOCK_SKEW_LEEWAY_SECS) <= now_secs {
return Err(D::Expired);
}
if let Some(nbf) = claims.nbf() {
if nbf.saturating_sub(CLOCK_SKEW_LEEWAY_SECS) > now_secs {
return Err(D::NotYetValid);
}
}
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jwt::{AsymmetricAlgorithm, AsymmetricSigningKey, JwtEncoder};
const CLIENT_ID: &str = "svc_app";
const AUD: &str = "https://auth.example.com/t/entropy/api/oidc/token";
const NOW: u64 = 1_700_000_000;
fn key() -> AsymmetricSigningKey {
AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap()
}
fn assertion_jwt(
key: &AsymmetricSigningKey,
iss: &str,
sub: &str,
aud: &str,
exp: u64,
nbf: Option<u64>,
) -> String {
let mut enc = JwtEncoder::new()
.issuer(iss)
.subject(sub)
.audience(aud)
.expiration(exp)
.issued_at(NOW);
if let Some(nbf) = nbf {
enc = enc.custom_claim("nbf", nbf);
}
enc.key_id(key.kid()).sign_asymmetric(key)
}
fn assertion_jwt_raw(key: &AsymmetricSigningKey, extra_claims: &str) -> String {
use crate::encoding::base64url_encode;
let header = base64url_encode(
format!(
r#"{{"alg":"{}","typ":"JWT","kid":"{}"}}"#,
key.algorithm().as_str(),
key.kid()
)
.as_bytes(),
);
let payload = base64url_encode(
format!(r#"{{"iss":"{CLIENT_ID}","sub":"{CLIENT_ID}","aud":"{AUD}",{extra_claims}}}"#)
.as_bytes(),
);
let signing_input = format!("{header}.{payload}");
let sig = base64url_encode(&key.sign(signing_input.as_bytes()));
format!("{signing_input}.{sig}")
}
fn verify(
jwt: &str,
keys: &[AsymmetricVerifyingKey],
) -> Result<JwtClaims, ClientAssertionDenied> {
verify_client_assertion(
&ClientAssertion {
jwt,
client_id: CLIENT_ID,
audience: AUD,
},
keys,
Timestamp::from_unix_secs(NOW),
)
}
#[test]
fn accepts_valid_assertion() {
let k = key();
let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
assert!(verify(&jwt, &[k.verifying_key().clone()]).is_ok());
}
#[test]
fn accepts_when_one_of_several_keys_matches() {
let signing = key();
let other = key();
let jwt = assertion_jwt(&signing, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
let keys = [
other.verifying_key().clone(),
signing.verifying_key().clone(),
];
assert!(verify(&jwt, &keys).is_ok());
}
#[test]
fn rejects_no_keys() {
let k = key();
let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
assert_eq!(
verify(&jwt, &[]).unwrap_err(),
ClientAssertionDenied::NoKeys
);
}
#[test]
fn rejects_wrong_key() {
let signing = key();
let wrong = key();
let jwt = assertion_jwt(&signing, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
assert_eq!(
verify(&jwt, &[wrong.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::SignatureInvalid
);
}
#[test]
fn rejects_issuer_mismatch() {
let k = key();
let jwt = assertion_jwt(&k, "someone_else", "someone_else", AUD, NOW + 300, None);
assert_eq!(
verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::IssuerMismatch
);
}
#[test]
fn rejects_audience_mismatch() {
let k = key();
let jwt = assertion_jwt(
&k,
CLIENT_ID,
CLIENT_ID,
"https://evil.example",
NOW + 300,
None,
);
assert_eq!(
verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::AudienceMismatch
);
}
#[test]
fn rejects_expired() {
let k = key();
let jwt = assertion_jwt(
&k,
CLIENT_ID,
CLIENT_ID,
AUD,
NOW - CLOCK_SKEW_LEEWAY_SECS - 1,
None,
);
assert_eq!(
verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::Expired
);
}
#[test]
fn sentinel_timestamps_reject_without_panicking() {
let k = key();
let jwt = assertion_jwt_raw(&k, r#""exp":"not-a-number""#);
assert_eq!(
verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::Expired
);
let jwt = assertion_jwt_raw(&k, &format!(r#""exp":{},"nbf":true"#, NOW + 300));
assert_eq!(
verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::NotYetValid
);
}
#[test]
fn accepts_within_clock_skew_leeway() {
let k = key();
let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW - 1, None);
assert!(verify(&jwt, &[k.verifying_key().clone()]).is_ok());
let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, Some(NOW + 5));
assert!(verify(&jwt, &[k.verifying_key().clone()]).is_ok());
}
#[test]
fn rejects_not_yet_valid() {
let k = key();
let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, Some(NOW + 100));
assert_eq!(
verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
ClientAssertionDenied::NotYetValid
);
}
}