use std::sync::Arc;
use bon::Builder;
use serde::Deserialize;
use snafu::prelude::*;
use crate::core::{
crypto::verifier::{CreateVerifierError, JwsVerifierPlatform},
jwt::{
JtiUniquenessChecker, JwsParseError, parse_compact_jws,
validator::{ClaimCheck, JwtValidationError, JwtValidator},
},
platform::{Duration, SystemTime},
};
#[non_exhaustive]
pub struct ValidatedDPoPProof {
pub htm: Option<String>,
pub htu: Option<String>,
pub ath: Option<String>,
pub nonce: Option<String>,
pub jti: Option<String>,
pub thumbprint: Option<String>,
pub alg: String,
pub iat: Option<u64>,
pub exp: Option<u64>,
}
#[derive(Debug, Builder)]
pub struct DPoPProofValidator {
jws_verifier_platform: Arc<dyn JwsVerifierPlatform>,
#[builder(default = Duration::from_mins(1))]
max_proof_age: Duration,
#[builder(default = super::DEFAULT_CLOCK_LEEWAY)]
clock_leeway: Duration,
allowed_signing_algorithms: Option<Vec<String>>,
jti_checker: Option<Arc<dyn JtiUniquenessChecker>>,
}
impl DPoPProofValidator {
#[must_use]
pub fn allowed_signing_algorithms(&self) -> Option<&[String]> {
self.allowed_signing_algorithms.as_deref()
}
pub async fn validate(&self, proof: &str) -> Result<ValidatedDPoPProof, DPoPProofError> {
let parsed = parse_compact_jws::<(), DPoPProofClaims>(proof).context(BadFormatSnafu)?;
let jwk = parsed
.header
.jwk
.clone()
.ok_or_else(|| MissingJwkHeaderSnafu.build())?;
ensure!(jwk.x5u.is_none(), JwkX5uSnafu);
ensure!(!jwk.has_private_parameters, JwkPrivateKeySnafu);
let thumbprint = jwk.thumbprint();
let alg = parsed.header.alg.clone().into_owned();
let verifier = self
.jws_verifier_platform
.create_verifier_from_jwk(jwk)
.await
.context(CreateVerifierSnafu)?;
let validator = JwtValidator::builder()
.verifier(verifier)
.typ(ClaimCheck::required_value("dpop+jwt"))
.maybe_allowed_algorithms(self.allowed_signing_algorithms.clone())
.max_token_age(self.max_proof_age)
.clock_leeway(self.clock_leeway)
.require_jti(self.jti_checker.is_some())
.maybe_jti_checker(self.jti_checker.clone())
.build();
let validated = validator
.validate_parsed_jws(parsed)
.await
.context(InvalidProofSnafu)?;
Ok(ValidatedDPoPProof {
htm: validated.claims.htm,
htu: validated.claims.htu,
ath: validated.claims.ath,
nonce: validated.claims.nonce,
jti: validated.jti,
thumbprint: Some(thumbprint),
alg,
iat: validated.issued_at.and_then(|t| {
t.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}),
exp: validated.expiration.and_then(|t| {
t.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}),
})
}
}
#[derive(Debug, Clone, Deserialize)]
struct DPoPProofClaims {
htm: Option<String>,
htu: Option<String>,
ath: Option<String>,
nonce: Option<String>,
}
impl DPoPProofError {
#[must_use]
pub fn error_description(&self) -> Option<String> {
match self {
Self::BadFormat { .. } => Some("The DPoP proof is malformed".to_string()),
Self::MissingJwkHeader => Some("The DPoP proof is missing the JWK header".to_string()),
Self::JwkX5u => {
Some("The DPoP proof JWK contains an unsupported x5u parameter".to_string())
}
Self::JwkPrivateKey => {
Some("The DPoP proof JWK contains private-key material".to_string())
}
Self::CreateVerifier { .. } => Some("The DPoP proof signature is invalid".to_string()),
Self::InvalidProof { source } => {
use JwtValidationError as E;
match source {
E::Parse { .. } => Some("The DPoP proof is malformed".to_string()),
E::Signature { .. } => Some("The DPoP proof signature is invalid".to_string()),
E::UnsignedToken => Some("The DPoP proof is unsigned".to_string()),
E::DisallowedAlgorithm { .. } => {
Some("The DPoP proof uses an unsupported signature algorithm".to_string())
}
E::UnrecognizedCriticalHeader { .. } => Some(
"The DPoP proof contains unrecognized critical header parameters"
.to_string(),
),
E::Expired { .. } => Some("The DPoP proof has expired".to_string()),
E::NotYetValid { .. } => Some("The DPoP proof is not yet valid".to_string()),
E::IssuedInFuture { .. } => {
Some("The DPoP proof was issued in the future".to_string())
}
E::TokenTooOld { .. } => Some("The DPoP proof is too old".to_string()),
E::InvalidTokenType { .. } => {
Some("The DPoP proof has an invalid typ header".to_string())
}
E::ClaimMismatch { claim, .. } => {
Some(format!("The DPoP proof '{claim}' claim is invalid"))
}
E::RequiredClaimMissing { claim } => Some(format!(
"The DPoP proof is missing the required '{claim}' claim"
)),
E::JtiNotUnique => Some("The DPoP proof jti has already been used".to_string()),
E::JtiTooLong { .. } => Some("The DPoP proof jti is too long".to_string()),
E::JtiCheck { .. } | E::ExtraClaims { .. } => None,
}
}
}
}
}
#[cfg(test)]
#[cfg(all(not(target_family = "wasm"), feature = "default-jws-verifier-platform"))]
mod tests {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use super::*;
use crate::DefaultJwsVerifierPlatform;
const EC_PUBLIC: &str = r#""kty":"EC","crv":"P-256","x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4","y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM""#;
fn proof_with_jwk(jwk_json: &str) -> String {
let header = format!(r#"{{"alg":"ES256","typ":"dpop+jwt","jwk":{jwk_json}}}"#);
let claims = r#"{"jti":"test-jti","htm":"GET","htu":"https://rs.example/r"}"#;
format!(
"{}.{}.{}",
URL_SAFE_NO_PAD.encode(header),
URL_SAFE_NO_PAD.encode(claims),
URL_SAFE_NO_PAD.encode("sig")
)
}
fn validator() -> DPoPProofValidator {
DPoPProofValidator::builder()
.jws_verifier_platform(DefaultJwsVerifierPlatform::default().into())
.build()
}
#[tokio::test]
async fn proof_jwk_with_private_key_material_is_rejected() {
let jwk = format!(r#"{{{EC_PUBLIC},"d":"870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE"}}"#);
let err = validator()
.validate(&proof_with_jwk(&jwk))
.await
.err()
.expect("validation should fail");
assert!(matches!(err, DPoPProofError::JwkPrivateKey), "got {err:?}");
}
#[tokio::test]
async fn proof_jwk_without_private_key_material_passes_the_header_check() {
let jwk = format!("{{{EC_PUBLIC}}}");
let err = validator()
.validate(&proof_with_jwk(&jwk))
.await
.err()
.expect("validation should fail");
assert!(!matches!(err, DPoPProofError::JwkPrivateKey), "got {err:?}");
}
}
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum DPoPProofError {
#[snafu(display("Bad DPoP proof format"))]
BadFormat {
source: JwsParseError,
},
#[snafu(display("DPoP proof is missing the JWK header"))]
MissingJwkHeader,
#[snafu(display("DPoP proof JWK contains unsupported x5u parameter"))]
JwkX5u,
#[snafu(display("DPoP proof JWK contains private-key material"))]
JwkPrivateKey,
#[snafu(display("Failed to create DPoP verification key"))]
CreateVerifier {
source: CreateVerifierError,
},
#[snafu(display("Invalid DPoP proof"))]
InvalidProof {
source: JwtValidationError,
},
}