use base64::prelude::*;
use http::StatusCode;
use sha2::{Digest as _, Sha256};
use snafu::{ensure, prelude::*};
use crate::{
TokenType,
core::{
BoxedError,
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<N: DpopNonceChecker>(
token_type: TokenType,
cnf: Option<&ConfirmationClaim>,
access_token: &SecretString,
dpop_binding_checker: &DPoPBindingChecker<N>,
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<N: DpopNonceChecker> {
pub(crate) dpop_nonce_checker: Option<N>,
pub(crate) proof_validator: DpopProofValidator,
pub(crate) required: bool,
}
impl<N: DpopNonceChecker> DPoPBindingChecker<N> {
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(|e| DPoPBindingError::NonceCheckFailed {
source: BoxedError::from_err(e),
})?;
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: BoxedError },
#[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())
}
}
}
}