use authkestra_crypto_util::verify_ed25519_signature_strict;
use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, Jwk};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use serde_json::Value;
const PRIVATE_JWK_MEMBERS: [&str; 7] = ["d", "p", "q", "dp", "dq", "qi", "k"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedDpopProof {
pub jkt: String,
pub jti: String,
pub iat: i64,
}
#[derive(Debug, thiserror::Error)]
pub enum DpopError {
#[error("dpop proof is not a well-formed compact JWS: {0}")]
Malformed(String),
#[error("dpop proof header typ must be \"dpop+jwt\"")]
WrongTyp,
#[error("dpop proof alg is not permitted: {0}")]
UnsupportedAlgorithm(String),
#[error("dpop proof header carries no jwk, or jwk is malformed: {0}")]
MissingOrInvalidJwk(String),
#[error("dpop proof jwk carries a private or symmetric-secret component: {0}")]
PrivateOrSymmetricJwk(String),
#[error("dpop proof key is unsafe to use: {0}")]
WeakKey(String),
#[error("dpop proof signature is invalid: {0}")]
BadSignature(String),
#[error("dpop proof htm does not match the request method")]
WrongHtm,
#[error("dpop proof htu does not match the request URI")]
WrongHtu,
#[error("dpop proof iat is outside the allowed freshness window")]
Stale,
#[error("dpop proof ath does not match the presented access token")]
AthMismatch,
#[error("dpop proof thumbprint could not be computed: {0}")]
ThumbprintFailed(String),
#[error("dpop proof jti is too long ({0} bytes, max {MAX_JTI_LEN})")]
JtiTooLong(usize),
}
const MAX_JTI_LEN: usize = 255;
#[derive(Debug, Deserialize)]
struct DpopClaims {
htm: String,
htu: String,
iat: i64,
jti: String,
#[serde(default)]
ath: Option<String>,
}
pub fn verify_dpop_proof(
compact_jws: &str,
expected_htm: &str,
expected_htu: Option<&str>,
expected_ath: Option<&str>,
max_age: chrono::Duration,
) -> Result<VerifiedDpopProof, DpopError> {
let mut parts = compact_jws.split('.');
let header_b64 = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| DpopError::Malformed("missing header segment".to_string()))?;
if parts.next().filter(|s| !s.is_empty()).is_none() {
return Err(DpopError::Malformed("missing payload segment".to_string()));
}
if parts.next().filter(|s| !s.is_empty()).is_none() {
return Err(DpopError::Malformed(
"missing signature segment".to_string(),
));
}
if parts.next().is_some() {
return Err(DpopError::Malformed(
"compact JWS has more than three segments".to_string(),
));
}
let header_bytes = base64_decode(header_b64)
.map_err(|e| DpopError::Malformed(format!("header is not valid base64url: {e}")))?;
let header_json: Value = serde_json::from_slice(&header_bytes)
.map_err(|e| DpopError::Malformed(format!("header is not valid JSON: {e}")))?;
let typ = header_json.get("typ").and_then(Value::as_str);
if typ != Some("dpop+jwt") {
return Err(DpopError::WrongTyp);
}
let raw_alg = header_json
.get("alg")
.and_then(Value::as_str)
.ok_or_else(|| DpopError::UnsupportedAlgorithm("missing".to_string()))?;
if ["none", "HS256", "HS384", "HS512"]
.iter()
.any(|bad| raw_alg.eq_ignore_ascii_case(bad))
{
return Err(DpopError::UnsupportedAlgorithm(raw_alg.to_string()));
}
let jwk_json = header_json
.get("jwk")
.ok_or_else(|| DpopError::MissingOrInvalidJwk("no jwk in header".to_string()))?;
let jwk_obj = jwk_json
.as_object()
.ok_or_else(|| DpopError::MissingOrInvalidJwk("jwk is not a JSON object".to_string()))?;
for member in PRIVATE_JWK_MEMBERS {
if jwk_obj.contains_key(member) {
return Err(DpopError::PrivateOrSymmetricJwk(member.to_string()));
}
}
let jwk: Jwk = serde_json::from_value(jwk_json.clone())
.map_err(|e| DpopError::MissingOrInvalidJwk(e.to_string()))?;
let algorithm = expected_algorithm(&jwk)?;
if let AlgorithmParameters::OctetKeyPair(params) = &jwk.algorithm {
let signing_input = compact_jws
.rsplit_once('.')
.map(|(input, _sig)| input)
.expect("already validated as header.payload.signature above");
let signature_b64 = compact_jws
.rsplit_once('.')
.map(|(_input, sig)| sig)
.expect("already validated as header.payload.signature above");
verify_ed25519_signature_strict(signing_input.as_bytes(), signature_b64, ¶ms.x)
.map_err(|e| match e {
authkestra_crypto_util::EdDsaVerifyError::Key(key_err) => {
DpopError::WeakKey(key_err.to_string())
}
authkestra_crypto_util::EdDsaVerifyError::Signature(msg) => {
DpopError::BadSignature(msg)
}
})?;
}
let decoding_key =
DecodingKey::from_jwk(&jwk).map_err(|e| DpopError::MissingOrInvalidJwk(e.to_string()))?;
let mut validation = Validation::new(algorithm);
validation.validate_exp = false;
validation.required_spec_claims.clear();
let data = jsonwebtoken::decode::<DpopClaims>(compact_jws, &decoding_key, &validation)
.map_err(|e| DpopError::BadSignature(e.to_string()))?;
let claims = data.claims;
if claims.jti.len() > MAX_JTI_LEN {
return Err(DpopError::JtiTooLong(claims.jti.len()));
}
if !claims.htm.eq_ignore_ascii_case(expected_htm) {
return Err(DpopError::WrongHtm);
}
if let Some(expected_htu) = expected_htu {
if canonicalize_htu(&claims.htu) != canonicalize_htu(expected_htu) {
return Err(DpopError::WrongHtu);
}
}
let now = chrono::Utc::now().timestamp();
const CLOCK_SKEW_ALLOWANCE_SECS: i64 = 5;
if claims.iat > now + CLOCK_SKEW_ALLOWANCE_SECS || now - claims.iat > max_age.num_seconds() {
return Err(DpopError::Stale);
}
if let Some(expected) = expected_ath {
let actual = claims.ath.as_deref().ok_or(DpopError::AthMismatch)?;
if !crate::token::cert_binding::constant_time_eq(actual, expected) {
return Err(DpopError::AthMismatch);
}
}
let jkt = compute_jwk_thumbprint(&jwk)?;
Ok(VerifiedDpopProof {
jkt,
jti: claims.jti,
iat: claims.iat,
})
}
pub fn compute_jwk_thumbprint(jwk: &Jwk) -> Result<String, DpopError> {
jwk.thumbprint(jsonwebtoken::jwk::ThumbprintHash::SHA256)
.map_err(|e| DpopError::ThumbprintFailed(e.to_string()))
}
fn expected_algorithm(jwk: &Jwk) -> Result<Algorithm, DpopError> {
match &jwk.algorithm {
AlgorithmParameters::EllipticCurve(params) => match ¶ms.curve {
EllipticCurve::P256 => Ok(Algorithm::ES256),
EllipticCurve::P384 => Ok(Algorithm::ES384),
other => Err(DpopError::UnsupportedAlgorithm(format!("{other:?}"))),
},
AlgorithmParameters::RSA(_) => Ok(Algorithm::RS256),
AlgorithmParameters::OctetKeyPair(params) => {
if params.curve == EllipticCurve::Ed25519 {
Ok(Algorithm::EdDSA)
} else {
Err(DpopError::UnsupportedAlgorithm(format!(
"OKP curve {:?}",
params.curve
)))
}
}
other => Err(DpopError::UnsupportedAlgorithm(format!("{other:?}"))),
}
}
fn canonicalize_htu(uri: &str) -> String {
match url::Url::parse(uri) {
Ok(mut url) => {
url.set_query(None);
url.set_fragment(None);
url.to_string()
}
Err(_) => uri.to_string(),
}
}
fn base64_decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn b64_json(v: &Value) -> String {
b64(serde_json::to_vec(v).unwrap().as_slice())
}
struct ProofBuilder {
signing_key: SigningKey,
htm: String,
htu: String,
iat: i64,
jti: String,
ath: Option<String>,
typ_override: Option<String>,
alg_override: Option<String>,
}
impl ProofBuilder {
fn new() -> Self {
Self {
signing_key: SigningKey::from_bytes(&[7u8; 32]),
htm: "POST".to_string(),
htu: "https://as.example.com/token".to_string(),
iat: chrono::Utc::now().timestamp(),
jti: "proof-jti-1".to_string(),
ath: None,
typ_override: None,
alg_override: None,
}
}
fn build(self) -> String {
let verifying = self.signing_key.verifying_key();
let jwk = serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"x": b64(verifying.as_bytes()),
});
let header = serde_json::json!({
"typ": self.typ_override.unwrap_or_else(|| "dpop+jwt".to_string()),
"alg": self.alg_override.unwrap_or_else(|| "EdDSA".to_string()),
"jwk": jwk,
});
let mut payload = serde_json::json!({
"htm": self.htm,
"htu": self.htu,
"iat": self.iat,
"jti": self.jti,
});
if let Some(ath) = self.ath {
payload["ath"] = serde_json::Value::String(ath);
}
let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
let signature = self.signing_key.sign(signing_input.as_bytes());
format!("{signing_input}.{}", b64(&signature.to_bytes()))
}
}
#[test]
fn accepts_a_genuine_fresh_proof_and_reports_the_correct_jkt() {
let builder = ProofBuilder::new();
let expected_jkt = compute_jwk_thumbprint(
&serde_json::from_value(serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"x": b64(builder.signing_key.verifying_key().as_bytes()),
}))
.unwrap(),
)
.unwrap();
let proof = ProofBuilder::new().build();
let verified = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect("a genuine, fresh proof must be accepted");
assert_eq!(verified.jkt, expected_jkt);
assert_eq!(verified.jti, "proof-jti-1");
}
#[test]
fn htm_comparison_is_case_insensitive() {
let proof = ProofBuilder::new().build();
verify_dpop_proof(
&proof,
"post",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect("htm must compare case-insensitively");
}
#[test]
fn rejects_wrong_htm() {
let proof = ProofBuilder::new().build();
let err = verify_dpop_proof(
&proof,
"GET",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a mismatched htm must be refused");
assert!(matches!(err, DpopError::WrongHtm));
}
#[test]
fn htu_comparison_ignores_query_and_fragment() {
let proof = ProofBuilder::new().build();
verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token?foo=bar#frag"),
None,
chrono::Duration::seconds(60),
)
.expect("htu must compare ignoring query/fragment");
}
#[test]
fn rejects_wrong_htu() {
let proof = ProofBuilder::new().build();
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/other-path"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a mismatched htu must be refused");
assert!(matches!(err, DpopError::WrongHtu));
}
#[test]
fn expected_htu_none_skips_the_check_entirely() {
let proof = ProofBuilder::new().build();
verify_dpop_proof(&proof, "POST", None, None, chrono::Duration::seconds(60))
.expect("None must skip the htu check regardless of the proof's own htu");
}
#[test]
fn rejects_a_stale_proof() {
let mut builder = ProofBuilder::new();
builder.iat = chrono::Utc::now().timestamp() - 120;
let proof = builder.build();
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a proof older than max_age must be refused");
assert!(matches!(err, DpopError::Stale));
}
#[test]
fn rejects_a_proof_too_far_in_the_future() {
let mut builder = ProofBuilder::new();
builder.iat = chrono::Utc::now().timestamp() + 3600;
let proof = builder.build();
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a proof from the future must be refused");
assert!(matches!(err, DpopError::Stale));
}
#[test]
fn rejects_malformed_compact_jws_segment_shapes() {
let cases: [(&str, &str); 4] = [
("", "empty string has no segments at all"),
("only-one-segment", "missing payload and signature segments"),
("header-only.payload-only", "missing signature segment"),
("a.b.c.d", "more than three segments"),
];
for (proof, description) in cases {
let err = verify_dpop_proof(
proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err(&format!(
"case ({description}) must be rejected as malformed"
));
assert!(
matches!(err, DpopError::Malformed(_)),
"case ({description}): expected Malformed, got {err:?}"
);
}
}
#[test]
fn rejects_a_header_segment_that_is_not_valid_base64url() {
let proof = "!!!not-base64url!!!.payload.signature";
let err = verify_dpop_proof(
proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a header segment that isn't valid base64url must be rejected");
assert!(matches!(err, DpopError::Malformed(_)));
}
#[test]
fn rejects_a_header_segment_that_decodes_but_is_not_valid_json() {
let header_b64 = b64(b"not valid json at all");
let proof = format!("{header_b64}.payload.signature");
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a header that decodes but isn't valid JSON must be rejected");
assert!(matches!(err, DpopError::Malformed(_)));
}
#[test]
fn rejects_wrong_typ() {
let mut builder = ProofBuilder::new();
builder.typ_override = Some("JWT".to_string());
let proof = builder.build();
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a non-dpop+jwt typ must be refused");
assert!(matches!(err, DpopError::WrongTyp));
}
#[test]
fn rejects_alg_none_and_hmac_algorithms_case_insensitively() {
for bad_alg in ["none", "None", "NONE", "HS256", "hs256", "Hs384", "HS512"] {
let mut builder = ProofBuilder::new();
builder.alg_override = Some(bad_alg.to_string());
let proof = builder.build();
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err(&format!("alg {bad_alg:?} must be refused"));
assert!(
matches!(err, DpopError::UnsupportedAlgorithm(_)),
"alg {bad_alg:?}: expected UnsupportedAlgorithm, got {err:?}"
);
}
}
#[test]
fn checks_ath_when_requested() {
let mut builder = ProofBuilder::new();
builder.ath = Some("correct-ath".to_string());
let proof = builder.build();
verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
Some("correct-ath"),
chrono::Duration::seconds(60),
)
.expect("a matching ath must be accepted");
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
Some("wrong-ath"),
chrono::Duration::seconds(60),
)
.expect_err("a mismatched ath must be refused");
assert!(matches!(err, DpopError::AthMismatch));
}
#[test]
fn requires_ath_when_caller_expects_one() {
let proof = ProofBuilder::new().build(); let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
Some("expected-ath"),
chrono::Duration::seconds(60),
)
.expect_err("a missing ath must be refused when the caller expects one");
assert!(matches!(err, DpopError::AthMismatch));
}
#[test]
fn rejects_a_low_order_key() {
let identity = {
let mut b = [0u8; 32];
b[0] = 1;
b
};
let jwk = serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"x": b64(&identity),
});
let header = serde_json::json!({
"typ": "dpop+jwt",
"alg": "EdDSA",
"jwk": jwk,
});
let payload = serde_json::json!({
"htm": "POST",
"htu": "https://as.example.com/token",
"iat": chrono::Utc::now().timestamp(),
"jti": "j1",
});
let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
let mut forged_sig = [0u8; 64];
forged_sig[..32].copy_from_slice(&identity);
let proof = format!("{signing_input}.{}", b64(&forged_sig));
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a low-order key must be refused");
assert!(matches!(err, DpopError::WeakKey(_)));
}
#[test]
fn rejects_a_private_jwk_member() {
let builder = ProofBuilder::new();
let header = serde_json::json!({
"typ": "dpop+jwt",
"alg": "EdDSA",
"jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": b64(builder.signing_key.verifying_key().as_bytes()),
"d": "smuggled-private-scalar",
},
});
let payload = serde_json::json!({
"htm": "POST",
"htu": "https://as.example.com/token",
"iat": chrono::Utc::now().timestamp(),
"jti": "j1",
});
let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
let signature = builder.signing_key.sign(signing_input.as_bytes());
let proof = format!("{signing_input}.{}", b64(&signature.to_bytes()));
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a jwk carrying a private component must be refused");
assert!(matches!(err, DpopError::PrivateOrSymmetricJwk(_)));
}
#[test]
fn rejects_a_missing_jwk() {
let header = serde_json::json!({
"typ": "dpop+jwt",
"alg": "EdDSA",
});
let payload = serde_json::json!({
"htm": "POST",
"htu": "https://as.example.com/token",
"iat": chrono::Utc::now().timestamp(),
"jti": "j1",
});
let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
let proof = format!("{signing_input}.{}", b64(&[0u8; 64]));
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("a proof with no embedded jwk must be refused");
assert!(matches!(err, DpopError::MissingOrInvalidJwk(_)));
}
#[test]
fn accepts_a_genuine_es256_proof() {
use jsonwebtoken::{Algorithm as JwtAlgorithm, EncodingKey, Header};
use p256::ecdsa::SigningKey as P256SigningKey;
use p256::elliptic_curve::{JwkEcKey, PublicKey as P256PublicKey};
use p256::pkcs8::EncodePrivateKey;
use rand_core::OsRng;
let signing_key = P256SigningKey::random(&mut OsRng);
let public_key: P256PublicKey<p256::NistP256> = signing_key.verifying_key().into();
let public_jwk: Jwk =
serde_json::from_value(serde_json::to_value(JwkEcKey::from(&public_key)).unwrap())
.expect("a p256 public JwkEcKey must parse as a jsonwebtoken Jwk");
let mut header = Header::new(JwtAlgorithm::ES256);
header.typ = Some("dpop+jwt".to_string());
header.jwk = Some(public_jwk);
let claims = serde_json::json!({
"htm": "POST",
"htu": "https://as.example.com/token",
"iat": chrono::Utc::now().timestamp(),
"jti": "es256-proof-1",
});
let pkcs8_der = signing_key.to_pkcs8_der().unwrap().as_bytes().to_vec();
let proof = jsonwebtoken::encode(&header, &claims, &EncodingKey::from_ec_der(&pkcs8_der))
.expect("encoding a genuine ES256 JWS must succeed");
let verified = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect("a genuine ES256 proof must be accepted");
assert_eq!(verified.jti, "es256-proof-1");
}
#[test]
fn rejects_a_jti_longer_than_the_max() {
let mut builder = ProofBuilder::new();
builder.jti = "j".repeat(MAX_JTI_LEN + 1);
let proof = builder.build();
let err = verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect_err("an over-long jti must be refused");
assert!(matches!(err, DpopError::JtiTooLong(len) if len == MAX_JTI_LEN + 1));
}
#[test]
fn a_jti_at_exactly_the_max_length_is_accepted() {
let mut builder = ProofBuilder::new();
builder.jti = "j".repeat(MAX_JTI_LEN);
let proof = builder.build();
verify_dpop_proof(
&proof,
"POST",
Some("https://as.example.com/token"),
None,
chrono::Duration::seconds(60),
)
.expect("a jti at exactly the max length must be accepted");
}
#[test]
fn canonicalize_htu_falls_back_to_exact_comparison_for_non_urls() {
assert_eq!(canonicalize_htu("not-a-url"), "not-a-url");
assert_ne!(canonicalize_htu("not-a-url"), canonicalize_htu("Not-A-Url"));
}
#[test]
fn thumbprint_matches_a_known_rfc7638_test_vector() {
let jwk: Jwk = serde_json::from_value(serde_json::json!({
"kty": "RSA",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB",
}))
.unwrap();
assert_eq!(
compute_jwk_thumbprint(&jwk).unwrap(),
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
);
}
}