use base64::prelude::*;
use http::StatusCode;
use sha2::{Digest as _, Sha256};
use snafu::{ensure, prelude::*};
use crate::{
TokenType,
core::{
Error,
dpop::{hash_access_token_for_dpop, normalize_uri_for_dpop},
jwt::ConfirmationClaim,
secrets::SecretString,
},
validator::{
dpop_nonce::{DpopNonceChecker, NonceCheck},
dpop_proof::{DpopProofError, DpopProofValidator},
error::{
DPoPBindingSnafu, DPoPHeaderNotStringSnafu, DpopRequiredForBoundTokenSnafu,
DpopRequiredSnafu, MissingDPoPHeaderSnafu, MtlsBindingSnafu, TokenBindingError,
UnsupportedCnfMethodSnafu,
},
},
};
pub(crate) fn check_mtls_binding(
cnf: Option<&ConfirmationClaim>,
client_cert_der: Option<&[u8]>,
require_mtls: bool,
) -> Result<(), MtlsBindingError> {
if let Some(expected_thumbprint) = cnf.and_then(|c| c.x5t_s256.as_ref()) {
let cert_der = client_cert_der.ok_or_else(|| CertBoundTokenWithoutCertSnafu.build())?;
ensure!(
cert_thumbprint(cert_der) == *expected_thumbprint,
CertThumbprintMismatchSnafu
);
} else if require_mtls {
MtlsRequiredSnafu.fail()?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn check_token_binding(
token_type: TokenType,
cnf: Option<&ConfirmationClaim>,
access_token: &SecretString,
dpop_binding_checker: &DPoPBindingChecker,
require_mtls: bool,
headers: &http::HeaderMap,
http_method: &http::Method,
http_uri: &http::Uri,
client_cert_der: Option<&[u8]>,
) -> (Option<String>, Result<(), TokenBindingError>) {
if let Some(cnf) = cnf {
if cnf.jwe.is_some() {
return (None, UnsupportedCnfMethodSnafu { method: "jwe" }.fail());
}
if cnf.jku.is_some() {
return (None, UnsupportedCnfMethodSnafu { method: "jku" }.fail());
}
}
let dpop_nonce = match token_type {
TokenType::Bearer => {
if cnf.and_then(|c| c.jkt.as_ref()).is_some() {
return (None, DpopRequiredForBoundTokenSnafu.fail());
}
if dpop_binding_checker.required {
return (None, DpopRequiredSnafu.fail());
}
None
}
TokenType::DPoP => {
let dpop_proof = match headers
.get("DPoP")
.map(|hv| hv.to_str().context(DPoPHeaderNotStringSnafu))
.transpose()
.and_then(|opt| opt.context(MissingDPoPHeaderSnafu))
{
Ok(proof) => proof,
Err(e) => return (None, Err(e)),
};
match dpop_binding_checker
.check(cnf, access_token, dpop_proof, http_method, http_uri)
.await
{
Ok(nonce) => nonce,
Err(e) => {
let nonce = if let DPoPBindingError::NonceRequired { ref nonce } = e {
Some(nonce.clone())
} else {
None
};
return (nonce, Err(e).context(DPoPBindingSnafu));
}
}
}
};
if let Err(e) = check_mtls_binding(cnf, client_cert_der, require_mtls).context(MtlsBindingSnafu)
{
return (dpop_nonce, Err(e));
}
(dpop_nonce, Ok(()))
}
fn cert_thumbprint(der: &[u8]) -> String {
BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(der))
}
#[derive(Debug, Snafu)]
pub enum MtlsBindingError {
#[snafu(display("Token is certificate-bound but no client certificate was presented"))]
CertBoundTokenWithoutCert,
#[snafu(display("Client certificate thumbprint does not match token binding"))]
CertThumbprintMismatch,
#[snafu(display("Certificate-bound tokens are required but token has no certificate binding"))]
MtlsRequired,
}
pub(crate) struct DPoPBindingChecker {
pub(crate) dpop_nonce_checker: Option<std::sync::Arc<dyn DpopNonceChecker>>,
pub(crate) proof_validator: DpopProofValidator,
pub(crate) required: bool,
}
impl DPoPBindingChecker {
pub(crate) async fn check(
&self,
cnf: Option<&ConfirmationClaim>,
access_token: &SecretString,
dpop_proof: &str,
method: &http::Method,
uri: &http::Uri,
) -> Result<Option<String>, DPoPBindingError> {
let validated_proof = self
.proof_validator
.validate(dpop_proof)
.await
.context(ProofValidationSnafu)?;
let proof_nonce = validated_proof.nonce.as_deref();
let nonce_check = match self.dpop_nonce_checker.as_ref() {
Some(c) => c.check_nonce(proof_nonce).await,
None => Ok(NonceCheck::Valid),
}
.map_err(|source| DPoPBindingError::NonceCheckFailed { source })?;
let new_nonce = match nonce_check {
NonceCheck::Valid => None,
NonceCheck::ValidWithNewNonce(n) => Some(n),
NonceCheck::Invalid(n) => return NonceRequiredSnafu { nonce: n }.fail(),
};
let access_token_hash = hash_access_token_for_dpop(access_token.expose_secret());
match (
validated_proof.htm.as_ref(),
validated_proof.htu.as_ref(),
validated_proof.ath.as_ref(),
) {
(None, _, _) => return MissingProofClaimSnafu { claim: "htm" }.fail(),
(_, None, _) => return MissingProofClaimSnafu { claim: "htu" }.fail(),
(_, _, None) => return MissingProofClaimSnafu { claim: "ath" }.fail(),
(Some(htm), Some(htu), Some(ath)) => {
ensure!(
htm == method.as_str(),
ProofClaimMismatchSnafu {
claim: "htm",
expected: method.as_str(),
actual: htm,
}
);
ensure!(
*htu == normalize_uri_for_dpop(uri)
.context(MalformedUrlSnafu)?
.to_string(),
ProofClaimMismatchSnafu {
claim: "htu",
expected: uri.to_string(),
actual: htu,
}
);
ensure!(
*ath == access_token_hash,
ProofClaimMismatchSnafu {
claim: "ath",
expected: &access_token_hash,
actual: ath,
}
);
}
}
match (cnf.and_then(|c| c.jkt.as_ref()), validated_proof.thumbprint) {
(None, _) => return MissingThumbprintBindingSnafu.fail(),
(_, None) => return NoThumbprintForKeySnafu.fail(),
(Some(jkt), Some(tp)) => ensure!(*jkt == tp, ThumbprintMismatchSnafu),
}
Ok(new_nonce)
}
}
#[derive(Debug, Snafu)]
pub enum DPoPBindingError {
#[snafu(display("Token has no DPoP key thumbprint binding"))]
MissingThumbprintBinding,
#[snafu(display("No thumbprint for DPoP proof key"))]
NoThumbprintForKey,
#[snafu(display("DPoP key thumbprint does not match token binding"))]
ThumbprintMismatch,
#[snafu(display("DPoP proof validation failed: {source}"))]
ProofValidation { source: DpopProofError },
#[snafu(display("Malformed HTTP URL in DPoP proof"))]
MalformedUrl { source: http::Error },
#[snafu(display("DPoP nonce check failed"))]
NonceCheckFailed { source: Error },
#[snafu(display("A DPoP nonce is required"))]
NonceRequired { nonce: String },
#[snafu(display("DPoP proof is missing the required claim '{claim}'"))]
MissingProofClaim {
claim: &'static str,
},
#[snafu(display("DPoP proof claim '{claim}' mismatch: expected {expected}, got {actual}"))]
ProofClaimMismatch {
claim: &'static str,
expected: String,
actual: String,
},
}
impl crate::error::ToRfc6750Error for DPoPBindingError {
fn attempted_scheme(&self) -> Option<TokenType> {
Some(TokenType::DPoP)
}
fn token_error(&self) -> crate::error::TokenValidationError {
use crate::error::{TokenErrorCode, TokenValidationError};
match self {
Self::NonceCheckFailed { .. } => {
TokenValidationError::Server(StatusCode::INTERNAL_SERVER_ERROR)
}
Self::ProofValidation {
source:
DpopProofError::InvalidProof {
source: crate::core::jwt::validator::JwtValidationError::JtiCheck { .. },
},
} => TokenValidationError::Server(StatusCode::INTERNAL_SERVER_ERROR),
Self::NonceRequired { .. } => {
TokenValidationError::Client(TokenErrorCode::UseDPoPNonce)
}
Self::MissingThumbprintBinding => {
TokenValidationError::Client(TokenErrorCode::InvalidToken)
}
_ => TokenValidationError::Client(TokenErrorCode::InvalidDPoPProof),
}
}
fn error_description(&self) -> Option<String> {
match self {
Self::MissingThumbprintBinding => {
Some("The access token has no DPoP key thumbprint binding".to_string())
}
Self::NoThumbprintForKey => Some("The DPoP proof key is invalid".to_string()),
Self::ThumbprintMismatch => {
Some("The DPoP key thumbprint does not match the token binding".to_string())
}
Self::ProofValidation { source } => source.error_description(),
Self::MalformedUrl { .. } => {
Some("The DPoP proof has a malformed HTTP URL".to_string())
}
Self::NonceCheckFailed { .. } => None,
Self::NonceRequired { .. } => Some("A DPoP nonce is required".to_string()),
Self::MissingProofClaim { claim } => Some(format!(
"The DPoP proof is missing the required '{claim}' claim"
)),
Self::ProofClaimMismatch { claim, .. } => {
Some(format!("The DPoP proof '{claim}' claim is invalid"))
}
}
}
}
impl crate::error::ToRfc6750Error for MtlsBindingError {
fn attempted_scheme(&self) -> Option<TokenType> {
None
}
fn token_error(&self) -> crate::error::TokenValidationError {
crate::error::TokenValidationError::Client(crate::error::TokenErrorCode::InvalidToken)
}
fn error_description(&self) -> Option<String> {
match self {
Self::CertBoundTokenWithoutCert => Some(
"The access token is certificate-bound but no client certificate was presented"
.to_string(),
),
Self::CertThumbprintMismatch => Some(
"The client certificate thumbprint does not match the token binding".to_string(),
),
Self::MtlsRequired => {
Some("The protected resource requires a client certificate".to_string())
}
}
}
}
#[cfg(all(
test,
not(target_family = "wasm"),
feature = "default-jws-verifier-platform"
))]
mod tests {
use std::sync::Arc;
use huskarl_crypto_native::asymmetric::signer::{GenerateAlgorithm, PrivateKey};
use rstest::rstest;
use serde::Serialize;
use super::*;
use crate::{
DefaultJwsVerifierPlatform,
core::{crypto::signer::AsymmetricJwsSigner, jwt::Jwt, platform::Duration},
};
const ACCESS_TOKEN: &str = "the-access-token";
fn req_uri() -> http::Uri {
"https://rs.example.com/resource".parse().unwrap()
}
fn es256() -> PrivateKey {
PrivateKey::generate(GenerateAlgorithm::Es256, None).unwrap()
}
fn matching_jkt(signer: &PrivateKey) -> String {
signer.public_key_jwk().thumbprint()
}
#[derive(Debug, Clone, Default, Serialize)]
struct ProofClaims {
#[serde(skip_serializing_if = "Option::is_none")]
htm: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
htu: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ath: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
nonce: Option<String>,
}
fn valid_claims() -> ProofClaims {
ProofClaims {
htm: Some("POST".to_string()),
htu: Some(normalize_uri_for_dpop(&req_uri()).unwrap().to_string()),
ath: Some(hash_access_token_for_dpop(ACCESS_TOKEN)),
nonce: None,
}
}
async fn sign_proof(signer: &PrivateKey, claims: ProofClaims) -> SecretString {
Jwt::builder()
.typ("dpop+jwt")
.issued_now_expires_after(Duration::from_secs(60))
.jwk(signer.public_key_jwk().into_owned())
.claims(claims)
.build()
.to_jws_compact(signer)
.await
.unwrap()
}
fn checker(
nonce_checker: Option<Arc<dyn DpopNonceChecker>>,
required: bool,
) -> DPoPBindingChecker {
DPoPBindingChecker {
dpop_nonce_checker: nonce_checker,
proof_validator: DpopProofValidator::builder()
.jws_verifier_platform(DefaultJwsVerifierPlatform::default().into())
.build(),
required,
}
}
fn cnf(jkt: Option<String>) -> ConfirmationClaim {
ConfirmationClaim {
jkt,
x5t_s256: None,
jwe: None,
jku: None,
}
}
#[derive(Debug)]
struct FixedNonce(NonceCheck);
impl DpopNonceChecker for FixedNonce {
fn check_nonce<'a>(
&'a self,
_nonce: Option<&'a str>,
) -> crate::core::platform::MaybeSendBoxFuture<'a, Result<NonceCheck, Error>> {
let verdict = self.0.clone();
Box::pin(async move { Ok(verdict) })
}
}
async fn run(
signer: &PrivateKey,
claims: ProofClaims,
cnf_jkt: Option<String>,
nonce_checker: Option<Arc<dyn DpopNonceChecker>>,
required: bool,
) -> Result<Option<String>, DPoPBindingError> {
let proof = sign_proof(signer, claims).await;
let confirmation = cnf(cnf_jkt);
checker(nonce_checker, required)
.check(
Some(&confirmation),
&SecretString::new(ACCESS_TOKEN),
proof.expose_secret(),
&http::Method::POST,
&req_uri(),
)
.await
}
#[tokio::test]
async fn valid_proof_and_binding_is_accepted() {
let signer = es256();
let result = run(
&signer,
valid_claims(),
Some(matching_jkt(&signer)),
None,
false,
)
.await;
assert!(
matches!(result, Ok(None)),
"expected Ok(None), got {result:?}"
);
}
#[rstest]
#[case::htm(ProofClaims { htm: None, ..valid_claims() }, "htm")]
#[case::htu(ProofClaims { htu: None, ..valid_claims() }, "htu")]
#[case::ath(ProofClaims { ath: None, ..valid_claims() }, "ath")]
#[tokio::test]
async fn missing_required_proof_claim_is_rejected(
#[case] claims: ProofClaims,
#[case] expected_claim: &str,
) {
let signer = es256();
let err = run(&signer, claims, Some(matching_jkt(&signer)), None, false)
.await
.unwrap_err();
assert!(
matches!(err, DPoPBindingError::MissingProofClaim { claim } if claim == expected_claim),
"expected MissingProofClaim({expected_claim}), got {err:?}"
);
}
#[rstest]
#[case::htm(ProofClaims { htm: Some("GET".to_string()), ..valid_claims() }, "htm")]
#[case::htu(ProofClaims { htu: Some("https://evil.example/other".to_string()), ..valid_claims() }, "htu")]
#[case::ath(ProofClaims { ath: Some("not-the-token-hash".to_string()), ..valid_claims() }, "ath")]
#[tokio::test]
async fn proof_claim_mismatch_is_rejected(
#[case] claims: ProofClaims,
#[case] expected_claim: &str,
) {
let signer = es256();
let err = run(&signer, claims, Some(matching_jkt(&signer)), None, false)
.await
.unwrap_err();
assert!(
matches!(err, DPoPBindingError::ProofClaimMismatch { claim, .. } if claim == expected_claim),
"expected ProofClaimMismatch({expected_claim}), got {err:?}"
);
}
#[tokio::test]
async fn token_without_jkt_binding_is_rejected() {
let signer = es256();
let err = run(&signer, valid_claims(), None, None, false)
.await
.unwrap_err();
assert!(
matches!(err, DPoPBindingError::MissingThumbprintBinding),
"got {err:?}"
);
}
#[tokio::test]
async fn thumbprint_mismatch_is_rejected() {
let signer = es256();
let other_jkt = matching_jkt(&es256());
let err = run(&signer, valid_claims(), Some(other_jkt), None, false)
.await
.unwrap_err();
assert!(
matches!(err, DPoPBindingError::ThumbprintMismatch),
"got {err:?}"
);
}
#[tokio::test]
async fn invalid_nonce_requires_retry_with_new_nonce() {
let signer = es256();
let nonce_checker = Arc::new(FixedNonce(NonceCheck::Invalid("fresh-nonce".to_string())));
let err = run(
&signer,
valid_claims(),
Some(matching_jkt(&signer)),
Some(nonce_checker),
false,
)
.await
.unwrap_err();
assert!(
matches!(err, DPoPBindingError::NonceRequired { ref nonce } if nonce == "fresh-nonce"),
"got {err:?}"
);
}
#[tokio::test]
async fn rotating_nonce_is_returned_on_success() {
let signer = es256();
let nonce_checker = Arc::new(FixedNonce(NonceCheck::ValidWithNewNonce(
"rotated-nonce".to_string(),
)));
let result = run(
&signer,
valid_claims(),
Some(matching_jkt(&signer)),
Some(nonce_checker),
false,
)
.await;
assert!(
matches!(result, Ok(Some(ref n)) if n == "rotated-nonce"),
"expected Ok(Some(\"rotated-nonce\")), got {result:?}"
);
}
}