Skip to main content

authkestra_engine/token/
dpop.rs

1//! RFC 9449 DPoP (Demonstrating Proof-of-Possession) proof verification.
2//!
3//! This module verifies a single DPoP proof JWT and reports the RFC 7638
4//! thumbprint of its embedded public key — nothing more. It does not issue
5//! tokens, does not stamp a `cnf` claim, and does not track replay: those
6//! are call-site concerns (see below), kept out of this module so it stays
7//! a pure, independently-testable function.
8//!
9//! ## Why this lives in `authkestra-engine`, not `authkestra-op`
10//!
11//! DPoP applies at both ends of a token's life — issuance in
12//! `authkestra-op` and verification in `authkestra-resource` — and neither
13//! crate depends on the other. `authkestra-engine` is the one crate both
14//! already depend on, so this is where a proof verifier usable from either
15//! side has to live.
16//!
17//! ## Relationship to `authkestra-op`'s enrolment/attestation verifiers
18//!
19//! [`verify_dpop_proof`] follows the same shape as
20//! `authkestra_op::attestation::verify_challenge_signature` — peek the raw
21//! `alg` before trusting `jsonwebtoken`'s typed API (whose `Algorithm`
22//! deserializer hard-fails on `"none"` or an unrecognised value), derive
23//! the algorithm from the key rather than the header, and use
24//! [`authkestra_crypto_util::verify_ed25519_signature_strict`] ahead of
25//! `jsonwebtoken::decode` so a low-order key or a low-order signature `R`
26//! can never satisfy proof of possession (authkestra#242 / authkestra#256).
27//! The logic is **reimplemented here, not imported**: `authkestra-op`'s
28//! version is crate-private and `authkestra-engine` cannot depend on
29//! `authkestra-op` without inverting the dependency graph. Only the
30//! genuinely shared low-level primitive
31//! (`authkestra_crypto_util::verify_ed25519_signature_strict`) is reused;
32//! `authkestra-op::strict_jws` itself is untouched by this work.
33//!
34//! ## What callers still have to do
35//!
36//! - **Replay protection.** A DPoP proof's `jti` is client-generated, so
37//!   there is nothing for this server to have stored ahead of time — the
38//!   check is "was this `jti` already claimed", which is the shape
39//!   [`crate::store::AtomicInsert`] provides, not [`crate::store::AtomicConsume`].
40//!   Call `insert_if_absent(jti, ..., max_age)` after a proof verifies and
41//!   treat `Ok(false)` as a replay.
42//! - **Binding the `jkt` into an issued token**, and **comparing a proof's
43//!   `jkt` against an access token's `cnf.jkt`** — both call sites already
44//!   have everything needed for this ([`crate::token::TokenManager::issue_user_token_with_extra`]
45//!   and [`crate::token::cert_binding::constant_time_eq`] respectively).
46
47use authkestra_crypto_util::verify_ed25519_signature_strict;
48use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, Jwk};
49use jsonwebtoken::{Algorithm, DecodingKey, Validation};
50use serde::Deserialize;
51use serde_json::Value;
52
53/// JWK members that indicate private or symmetric key material. Mirrors
54/// `authkestra_op::attestation::PRIVATE_JWK_MEMBERS` — a JWK embedded in a
55/// DPoP proof header is exactly as attacker-controlled as one submitted to
56/// the enrolment ceremony that constant guards, and `jsonwebtoken::jwk::Jwk`
57/// silently drops unknown members on a typed parse, so this check must run
58/// against the raw JSON before that parse happens.
59const PRIVATE_JWK_MEMBERS: [&str; 7] = ["d", "p", "q", "dp", "dq", "qi", "k"];
60
61/// A DPoP proof that has passed signature, `typ`, `htm`/`htu`, freshness,
62/// and (when requested) `ath` verification.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct VerifiedDpopProof {
65    /// The RFC 7638 SHA-256 thumbprint of the proof's embedded public key —
66    /// the value to stamp into (or compare against) an access token's
67    /// `cnf.jkt`.
68    pub jkt: String,
69    /// The proof's `jti` claim — pass this to a replay guard.
70    pub jti: String,
71    /// The proof's `iat` claim (Unix seconds), already checked against
72    /// `max_age` by [`verify_dpop_proof`] — exposed for callers that want
73    /// to log or further reason about it.
74    pub iat: i64,
75}
76
77/// Why a DPoP proof was refused.
78#[derive(Debug, thiserror::Error)]
79pub enum DpopError {
80    #[error("dpop proof is not a well-formed compact JWS: {0}")]
81    Malformed(String),
82    #[error("dpop proof header typ must be \"dpop+jwt\"")]
83    WrongTyp,
84    #[error("dpop proof alg is not permitted: {0}")]
85    UnsupportedAlgorithm(String),
86    #[error("dpop proof header carries no jwk, or jwk is malformed: {0}")]
87    MissingOrInvalidJwk(String),
88    #[error("dpop proof jwk carries a private or symmetric-secret component: {0}")]
89    PrivateOrSymmetricJwk(String),
90    #[error("dpop proof key is unsafe to use: {0}")]
91    WeakKey(String),
92    #[error("dpop proof signature is invalid: {0}")]
93    BadSignature(String),
94    #[error("dpop proof htm does not match the request method")]
95    WrongHtm,
96    #[error("dpop proof htu does not match the request URI")]
97    WrongHtu,
98    #[error("dpop proof iat is outside the allowed freshness window")]
99    Stale,
100    #[error("dpop proof ath does not match the presented access token")]
101    AthMismatch,
102    #[error("dpop proof thumbprint could not be computed: {0}")]
103    ThumbprintFailed(String),
104    #[error("dpop proof jti is too long ({0} bytes, max {MAX_JTI_LEN})")]
105    JtiTooLong(usize),
106}
107
108/// The largest `jti` this module accepts. RFC 9449 places no length limit
109/// on `jti` itself, but this module documents it as a value callers pass
110/// straight through to a replay-guard store as a key — and the MySQL
111/// `AtomicInsert` backend's key column is `VARCHAR(255)` (see
112/// `crate::store::sql::session`), so an unbounded `jti` is a
113/// client-controlled way to make that store call fail. Rejecting it here,
114/// before any store is ever involved, is a single, explicit check rather
115/// than every possible backend needing its own truncation-or-error policy.
116const MAX_JTI_LEN: usize = 255;
117
118/// The DPoP proof's payload claims (RFC 9449 §4.2).
119#[derive(Debug, Deserialize)]
120struct DpopClaims {
121    htm: String,
122    htu: String,
123    iat: i64,
124    jti: String,
125    #[serde(default)]
126    ath: Option<String>,
127}
128
129/// Verifies a DPoP proof JWT (RFC 9449 §4.3).
130///
131/// `expected_htm` is compared case-insensitively (§4.2: "the value of the
132/// HTTP method") and always checked — a caller always knows the current
133/// request's method unambiguously, so there's no case where skipping it
134/// would be appropriate.
135///
136/// `expected_htu`, when `Some`, is compared against the proof's own `htu`
137/// with both sides stripped of query and fragment first, per §4.3 step 6,
138/// using `url::Url` for canonicalization — pass in the request's URI as
139/// received, not pre-stripped. `None` skips the `htu` check entirely: a
140/// resource server protecting many routes behind a reverse proxy or load
141/// balancer often cannot reliably reconstruct the exact absolute URL the
142/// client used (scheme and authority depend on the TLS-termination point,
143/// which isn't always visible to application code), unlike an
144/// authorization server's `/token` endpoint, which has exactly one, known,
145/// statically configured absolute URL. Skipping `htu` still leaves `jti`
146/// replay tracking and (when the caller supplies it) `ath` binding to
147/// prevent a captured proof from being reused — see the caller's own
148/// threat-model notes for what's traded away by not also checking `htu`.
149///
150/// `expected_ath` should be `Some(base64url(SHA256(access_token)))` when
151/// verifying a proof presented alongside an access token (the resource
152/// server case, §4.3 step 12), and `None` at the token endpoint, where no
153/// access token exists yet for the proof to bind to.
154///
155/// `max_age` is both the proof-freshness window (§4.3 step 11) and, by
156/// convention, the TTL a caller should pass to
157/// [`crate::store::AtomicInsert::insert_if_absent`] when recording the
158/// `jti` — a proof can never be usefully replayed after its own freshness
159/// window has elapsed, since it would independently fail this check first.
160///
161/// Does **not** check replay — see the module doc.
162pub fn verify_dpop_proof(
163    compact_jws: &str,
164    expected_htm: &str,
165    expected_htu: Option<&str>,
166    expected_ath: Option<&str>,
167    max_age: chrono::Duration,
168) -> Result<VerifiedDpopProof, DpopError> {
169    let mut parts = compact_jws.split('.');
170    let header_b64 = parts
171        .next()
172        .filter(|s| !s.is_empty())
173        .ok_or_else(|| DpopError::Malformed("missing header segment".to_string()))?;
174    if parts.next().filter(|s| !s.is_empty()).is_none() {
175        return Err(DpopError::Malformed("missing payload segment".to_string()));
176    }
177    if parts.next().filter(|s| !s.is_empty()).is_none() {
178        return Err(DpopError::Malformed(
179            "missing signature segment".to_string(),
180        ));
181    }
182    if parts.next().is_some() {
183        return Err(DpopError::Malformed(
184            "compact JWS has more than three segments".to_string(),
185        ));
186    }
187
188    let header_bytes = base64_decode(header_b64)
189        .map_err(|e| DpopError::Malformed(format!("header is not valid base64url: {e}")))?;
190    let header_json: Value = serde_json::from_slice(&header_bytes)
191        .map_err(|e| DpopError::Malformed(format!("header is not valid JSON: {e}")))?;
192
193    // `typ` is a MUST per RFC 9449 §4.2 — it's what stops a proof from
194    // being confused with any other kind of JWT this server might accept.
195    let typ = header_json.get("typ").and_then(Value::as_str);
196    if typ != Some("dpop+jwt") {
197        return Err(DpopError::WrongTyp);
198    }
199
200    // Peeked before the typed jsonwebtoken API touches it: `Algorithm`'s
201    // deserializer hard-fails on `"none"` or an unrecognised value, which
202    // would otherwise collapse a `bad_alg` case into an opaque parse error
203    // rather than the specific rejection this reports.
204    let raw_alg = header_json
205        .get("alg")
206        .and_then(Value::as_str)
207        .ok_or_else(|| DpopError::UnsupportedAlgorithm("missing".to_string()))?;
208    if ["none", "HS256", "HS384", "HS512"]
209        .iter()
210        .any(|bad| raw_alg.eq_ignore_ascii_case(bad))
211    {
212        return Err(DpopError::UnsupportedAlgorithm(raw_alg.to_string()));
213    }
214
215    let jwk_json = header_json
216        .get("jwk")
217        .ok_or_else(|| DpopError::MissingOrInvalidJwk("no jwk in header".to_string()))?;
218    let jwk_obj = jwk_json
219        .as_object()
220        .ok_or_else(|| DpopError::MissingOrInvalidJwk("jwk is not a JSON object".to_string()))?;
221    for member in PRIVATE_JWK_MEMBERS {
222        if jwk_obj.contains_key(member) {
223            return Err(DpopError::PrivateOrSymmetricJwk(member.to_string()));
224        }
225    }
226    let jwk: Jwk = serde_json::from_value(jwk_json.clone())
227        .map_err(|e| DpopError::MissingOrInvalidJwk(e.to_string()))?;
228
229    let algorithm = expected_algorithm(&jwk)?;
230
231    // Strict EdDSA gate ahead of `jsonwebtoken`'s non-strict backend
232    // (authkestra#242 / authkestra#256) — a no-op for non-Ed25519 keys,
233    // since only an OctetKeyPair/Ed25519 key reaches that backend at all.
234    if let AlgorithmParameters::OctetKeyPair(params) = &jwk.algorithm {
235        let signing_input = compact_jws
236            .rsplit_once('.')
237            .map(|(input, _sig)| input)
238            .expect("already validated as header.payload.signature above");
239        let signature_b64 = compact_jws
240            .rsplit_once('.')
241            .map(|(_input, sig)| sig)
242            .expect("already validated as header.payload.signature above");
243
244        verify_ed25519_signature_strict(signing_input.as_bytes(), signature_b64, &params.x)
245            .map_err(|e| match e {
246                authkestra_crypto_util::EdDsaVerifyError::Key(key_err) => {
247                    DpopError::WeakKey(key_err.to_string())
248                }
249                authkestra_crypto_util::EdDsaVerifyError::Signature(msg) => {
250                    DpopError::BadSignature(msg)
251                }
252            })?;
253    }
254
255    let decoding_key =
256        DecodingKey::from_jwk(&jwk).map_err(|e| DpopError::MissingOrInvalidJwk(e.to_string()))?;
257    let mut validation = Validation::new(algorithm);
258    // DPoP proofs carry `iat`, not `exp` — freshness is checked explicitly
259    // below against `max_age`, not via jsonwebtoken's `exp`/`nbf` handling.
260    validation.validate_exp = false;
261    validation.required_spec_claims.clear();
262
263    let data = jsonwebtoken::decode::<DpopClaims>(compact_jws, &decoding_key, &validation)
264        .map_err(|e| DpopError::BadSignature(e.to_string()))?;
265    let claims = data.claims;
266
267    if claims.jti.len() > MAX_JTI_LEN {
268        return Err(DpopError::JtiTooLong(claims.jti.len()));
269    }
270
271    if !claims.htm.eq_ignore_ascii_case(expected_htm) {
272        return Err(DpopError::WrongHtm);
273    }
274    if let Some(expected_htu) = expected_htu {
275        if canonicalize_htu(&claims.htu) != canonicalize_htu(expected_htu) {
276            return Err(DpopError::WrongHtu);
277        }
278    }
279
280    let now = chrono::Utc::now().timestamp();
281    // A small allowance for clock skew between client and server on the
282    // "not yet valid" side; the "too old" side is exactly `max_age`.
283    const CLOCK_SKEW_ALLOWANCE_SECS: i64 = 5;
284    if claims.iat > now + CLOCK_SKEW_ALLOWANCE_SECS || now - claims.iat > max_age.num_seconds() {
285        return Err(DpopError::Stale);
286    }
287
288    if let Some(expected) = expected_ath {
289        let actual = claims.ath.as_deref().ok_or(DpopError::AthMismatch)?;
290        if !crate::token::cert_binding::constant_time_eq(actual, expected) {
291            return Err(DpopError::AthMismatch);
292        }
293    }
294
295    let jkt = compute_jwk_thumbprint(&jwk)?;
296
297    Ok(VerifiedDpopProof {
298        jkt,
299        jti: claims.jti,
300        iat: claims.iat,
301    })
302}
303
304/// Computes the RFC 7638 SHA-256 JWK thumbprint of a DPoP proof's embedded
305/// public key — the value that becomes (or is compared against) `cnf.jkt`.
306///
307/// Unlike `authkestra_op::attestation::compute_cnf_jkt`, this does not need
308/// a `catch_unwind` guard: verified directly against the pinned
309/// `jsonwebtoken = "11"` source (`jwk.rs`), `Jwk::thumbprint()` in this
310/// version returns `Err(InvalidKeyFormat)` for a structurally-inconsistent
311/// key (e.g. `kty: EC` with `crv: Ed25519`) rather than panicking — that
312/// panic was specific to the 10.4.0-era implementation.
313pub fn compute_jwk_thumbprint(jwk: &Jwk) -> Result<String, DpopError> {
314    jwk.thumbprint(jsonwebtoken::jwk::ThumbprintHash::SHA256)
315        .map_err(|e| DpopError::ThumbprintFailed(e.to_string()))
316}
317
318/// The algorithm a given public key can plausibly have been used with —
319/// derived from the key itself, never trusted from the (attacker-supplied)
320/// header, mirroring `authkestra_op::attestation::expected_algorithm`.
321fn expected_algorithm(jwk: &Jwk) -> Result<Algorithm, DpopError> {
322    match &jwk.algorithm {
323        AlgorithmParameters::EllipticCurve(params) => match &params.curve {
324            EllipticCurve::P256 => Ok(Algorithm::ES256),
325            EllipticCurve::P384 => Ok(Algorithm::ES384),
326            other => Err(DpopError::UnsupportedAlgorithm(format!("{other:?}"))),
327        },
328        AlgorithmParameters::RSA(_) => Ok(Algorithm::RS256),
329        AlgorithmParameters::OctetKeyPair(params) => {
330            if params.curve == EllipticCurve::Ed25519 {
331                Ok(Algorithm::EdDSA)
332            } else {
333                Err(DpopError::UnsupportedAlgorithm(format!(
334                    "OKP curve {:?}",
335                    params.curve
336                )))
337            }
338        }
339        other => Err(DpopError::UnsupportedAlgorithm(format!("{other:?}"))),
340    }
341}
342
343/// Strips query and fragment from a URI for `htu` comparison (RFC 9449
344/// §4.3 step 6). Falls back to comparing the original, unmodified string
345/// (no case-folding or other normalization) if it does not parse as a URL
346/// at all — that failure mode still compares two unparsed strings
347/// consistently rather than panicking or silently accepting a mismatch.
348fn canonicalize_htu(uri: &str) -> String {
349    match url::Url::parse(uri) {
350        Ok(mut url) => {
351            url.set_query(None);
352            url.set_fragment(None);
353            url.to_string()
354        }
355        Err(_) => uri.to_string(),
356    }
357}
358
359fn base64_decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
360    use base64::Engine;
361    base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s)
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use base64::Engine;
368    use ed25519_dalek::{Signer, SigningKey};
369
370    fn b64(bytes: &[u8]) -> String {
371        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
372    }
373    fn b64_json(v: &Value) -> String {
374        b64(serde_json::to_vec(v).unwrap().as_slice())
375    }
376
377    /// Builds a real, well-formed, genuinely-signed DPoP proof so tests
378    /// exercise the actual signature path rather than stubbing it out.
379    struct ProofBuilder {
380        signing_key: SigningKey,
381        htm: String,
382        htu: String,
383        iat: i64,
384        jti: String,
385        ath: Option<String>,
386        typ_override: Option<String>,
387        alg_override: Option<String>,
388    }
389
390    impl ProofBuilder {
391        fn new() -> Self {
392            Self {
393                signing_key: SigningKey::from_bytes(&[7u8; 32]),
394                htm: "POST".to_string(),
395                htu: "https://as.example.com/token".to_string(),
396                iat: chrono::Utc::now().timestamp(),
397                jti: "proof-jti-1".to_string(),
398                ath: None,
399                typ_override: None,
400                alg_override: None,
401            }
402        }
403
404        fn build(self) -> String {
405            let verifying = self.signing_key.verifying_key();
406            let jwk = serde_json::json!({
407                "kty": "OKP",
408                "crv": "Ed25519",
409                "x": b64(verifying.as_bytes()),
410            });
411            let header = serde_json::json!({
412                "typ": self.typ_override.unwrap_or_else(|| "dpop+jwt".to_string()),
413                "alg": self.alg_override.unwrap_or_else(|| "EdDSA".to_string()),
414                "jwk": jwk,
415            });
416            let mut payload = serde_json::json!({
417                "htm": self.htm,
418                "htu": self.htu,
419                "iat": self.iat,
420                "jti": self.jti,
421            });
422            if let Some(ath) = self.ath {
423                payload["ath"] = serde_json::Value::String(ath);
424            }
425
426            let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
427            let signature = self.signing_key.sign(signing_input.as_bytes());
428            format!("{signing_input}.{}", b64(&signature.to_bytes()))
429        }
430    }
431
432    #[test]
433    fn accepts_a_genuine_fresh_proof_and_reports_the_correct_jkt() {
434        let builder = ProofBuilder::new();
435        let expected_jkt = compute_jwk_thumbprint(
436            &serde_json::from_value(serde_json::json!({
437                "kty": "OKP",
438                "crv": "Ed25519",
439                "x": b64(builder.signing_key.verifying_key().as_bytes()),
440            }))
441            .unwrap(),
442        )
443        .unwrap();
444        let proof = ProofBuilder::new().build();
445
446        let verified = verify_dpop_proof(
447            &proof,
448            "POST",
449            Some("https://as.example.com/token"),
450            None,
451            chrono::Duration::seconds(60),
452        )
453        .expect("a genuine, fresh proof must be accepted");
454
455        assert_eq!(verified.jkt, expected_jkt);
456        assert_eq!(verified.jti, "proof-jti-1");
457    }
458
459    #[test]
460    fn htm_comparison_is_case_insensitive() {
461        let proof = ProofBuilder::new().build();
462        verify_dpop_proof(
463            &proof,
464            "post",
465            Some("https://as.example.com/token"),
466            None,
467            chrono::Duration::seconds(60),
468        )
469        .expect("htm must compare case-insensitively");
470    }
471
472    #[test]
473    fn rejects_wrong_htm() {
474        let proof = ProofBuilder::new().build();
475        let err = verify_dpop_proof(
476            &proof,
477            "GET",
478            Some("https://as.example.com/token"),
479            None,
480            chrono::Duration::seconds(60),
481        )
482        .expect_err("a mismatched htm must be refused");
483        assert!(matches!(err, DpopError::WrongHtm));
484    }
485
486    #[test]
487    fn htu_comparison_ignores_query_and_fragment() {
488        let proof = ProofBuilder::new().build();
489        verify_dpop_proof(
490            &proof,
491            "POST",
492            Some("https://as.example.com/token?foo=bar#frag"),
493            None,
494            chrono::Duration::seconds(60),
495        )
496        .expect("htu must compare ignoring query/fragment");
497    }
498
499    #[test]
500    fn rejects_wrong_htu() {
501        let proof = ProofBuilder::new().build();
502        let err = verify_dpop_proof(
503            &proof,
504            "POST",
505            Some("https://as.example.com/other-path"),
506            None,
507            chrono::Duration::seconds(60),
508        )
509        .expect_err("a mismatched htu must be refused");
510        assert!(matches!(err, DpopError::WrongHtu));
511    }
512
513    /// A resource server protecting many routes behind a proxy or load
514    /// balancer often can't reliably reconstruct the exact absolute URL a
515    /// client used — `expected_htu: None` lets such a caller skip this one
516    /// check while still getting every other one (`htm`, freshness, and
517    /// whatever `ath`/replay checks it layers on top itself).
518    #[test]
519    fn expected_htu_none_skips_the_check_entirely() {
520        let proof = ProofBuilder::new().build();
521        verify_dpop_proof(&proof, "POST", None, None, chrono::Duration::seconds(60))
522            .expect("None must skip the htu check regardless of the proof's own htu");
523    }
524
525    #[test]
526    fn rejects_a_stale_proof() {
527        let mut builder = ProofBuilder::new();
528        builder.iat = chrono::Utc::now().timestamp() - 120;
529        let proof = builder.build();
530
531        let err = verify_dpop_proof(
532            &proof,
533            "POST",
534            Some("https://as.example.com/token"),
535            None,
536            chrono::Duration::seconds(60),
537        )
538        .expect_err("a proof older than max_age must be refused");
539        assert!(matches!(err, DpopError::Stale));
540    }
541
542    #[test]
543    fn rejects_a_proof_too_far_in_the_future() {
544        let mut builder = ProofBuilder::new();
545        builder.iat = chrono::Utc::now().timestamp() + 3600;
546        let proof = builder.build();
547
548        let err = verify_dpop_proof(
549            &proof,
550            "POST",
551            Some("https://as.example.com/token"),
552            None,
553            chrono::Duration::seconds(60),
554        )
555        .expect_err("a proof from the future must be refused");
556        assert!(matches!(err, DpopError::Stale));
557    }
558
559    /// authkestra#277 review: none of `verify_dpop_proof`'s six distinct
560    /// `Malformed` production sites (missing header/payload/signature
561    /// segment, too many segments, header not valid base64url, header not
562    /// valid JSON) had test coverage. Covers the structural (segment-count)
563    /// cases here; the two content-level cases (bad base64url, bad JSON)
564    /// get their own tests below since they need a well-formed segment
565    /// count to reach.
566    #[test]
567    fn rejects_malformed_compact_jws_segment_shapes() {
568        let cases: [(&str, &str); 4] = [
569            ("", "empty string has no segments at all"),
570            ("only-one-segment", "missing payload and signature segments"),
571            ("header-only.payload-only", "missing signature segment"),
572            ("a.b.c.d", "more than three segments"),
573        ];
574        for (proof, description) in cases {
575            let err = verify_dpop_proof(
576                proof,
577                "POST",
578                Some("https://as.example.com/token"),
579                None,
580                chrono::Duration::seconds(60),
581            )
582            .expect_err(&format!(
583                "case ({description}) must be rejected as malformed"
584            ));
585            assert!(
586                matches!(err, DpopError::Malformed(_)),
587                "case ({description}): expected Malformed, got {err:?}"
588            );
589        }
590    }
591
592    #[test]
593    fn rejects_a_header_segment_that_is_not_valid_base64url() {
594        // `!` is outside the base64url alphabet (A-Z, a-z, 0-9, -, _).
595        let proof = "!!!not-base64url!!!.payload.signature";
596        let err = verify_dpop_proof(
597            proof,
598            "POST",
599            Some("https://as.example.com/token"),
600            None,
601            chrono::Duration::seconds(60),
602        )
603        .expect_err("a header segment that isn't valid base64url must be rejected");
604        assert!(matches!(err, DpopError::Malformed(_)));
605    }
606
607    #[test]
608    fn rejects_a_header_segment_that_decodes_but_is_not_valid_json() {
609        let header_b64 = b64(b"not valid json at all");
610        let proof = format!("{header_b64}.payload.signature");
611        let err = verify_dpop_proof(
612            &proof,
613            "POST",
614            Some("https://as.example.com/token"),
615            None,
616            chrono::Duration::seconds(60),
617        )
618        .expect_err("a header that decodes but isn't valid JSON must be rejected");
619        assert!(matches!(err, DpopError::Malformed(_)));
620    }
621
622    #[test]
623    fn rejects_wrong_typ() {
624        let mut builder = ProofBuilder::new();
625        builder.typ_override = Some("JWT".to_string());
626        let proof = builder.build();
627
628        let err = verify_dpop_proof(
629            &proof,
630            "POST",
631            Some("https://as.example.com/token"),
632            None,
633            chrono::Duration::seconds(60),
634        )
635        .expect_err("a non-dpop+jwt typ must be refused");
636        assert!(matches!(err, DpopError::WrongTyp));
637    }
638
639    /// The algorithm-confusion gate (RFC 9449 requires an asymmetric
640    /// signature) rejects `alg: none` and every HMAC algorithm, regardless
641    /// of case — `alg_override` lets a test declare a header `alg` the
642    /// embedded (still-Ed25519) key never actually signs with, exercising
643    /// the peek-before-parse rejection in isolation from the key itself.
644    #[test]
645    fn rejects_alg_none_and_hmac_algorithms_case_insensitively() {
646        for bad_alg in ["none", "None", "NONE", "HS256", "hs256", "Hs384", "HS512"] {
647            let mut builder = ProofBuilder::new();
648            builder.alg_override = Some(bad_alg.to_string());
649            let proof = builder.build();
650
651            let err = verify_dpop_proof(
652                &proof,
653                "POST",
654                Some("https://as.example.com/token"),
655                None,
656                chrono::Duration::seconds(60),
657            )
658            .expect_err(&format!("alg {bad_alg:?} must be refused"));
659            assert!(
660                matches!(err, DpopError::UnsupportedAlgorithm(_)),
661                "alg {bad_alg:?}: expected UnsupportedAlgorithm, got {err:?}"
662            );
663        }
664    }
665
666    #[test]
667    fn checks_ath_when_requested() {
668        let mut builder = ProofBuilder::new();
669        builder.ath = Some("correct-ath".to_string());
670        let proof = builder.build();
671
672        verify_dpop_proof(
673            &proof,
674            "POST",
675            Some("https://as.example.com/token"),
676            Some("correct-ath"),
677            chrono::Duration::seconds(60),
678        )
679        .expect("a matching ath must be accepted");
680
681        let err = verify_dpop_proof(
682            &proof,
683            "POST",
684            Some("https://as.example.com/token"),
685            Some("wrong-ath"),
686            chrono::Duration::seconds(60),
687        )
688        .expect_err("a mismatched ath must be refused");
689        assert!(matches!(err, DpopError::AthMismatch));
690    }
691
692    #[test]
693    fn requires_ath_when_caller_expects_one() {
694        let proof = ProofBuilder::new().build(); // no ath in the proof
695        let err = verify_dpop_proof(
696            &proof,
697            "POST",
698            Some("https://as.example.com/token"),
699            Some("expected-ath"),
700            chrono::Duration::seconds(60),
701        )
702        .expect_err("a missing ath must be refused when the caller expects one");
703        assert!(matches!(err, DpopError::AthMismatch));
704    }
705
706    #[test]
707    fn rejects_a_low_order_key() {
708        // The identity point: the canonical universal low-order vector.
709        let identity = {
710            let mut b = [0u8; 32];
711            b[0] = 1;
712            b
713        };
714        let jwk = serde_json::json!({
715            "kty": "OKP",
716            "crv": "Ed25519",
717            "x": b64(&identity),
718        });
719        let header = serde_json::json!({
720            "typ": "dpop+jwt",
721            "alg": "EdDSA",
722            "jwk": jwk,
723        });
724        let payload = serde_json::json!({
725            "htm": "POST",
726            "htu": "https://as.example.com/token",
727            "iat": chrono::Utc::now().timestamp(),
728            "jti": "j1",
729        });
730        let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
731        // The universal forgery: R = identity, S = 0.
732        let mut forged_sig = [0u8; 64];
733        forged_sig[..32].copy_from_slice(&identity);
734        let proof = format!("{signing_input}.{}", b64(&forged_sig));
735
736        let err = verify_dpop_proof(
737            &proof,
738            "POST",
739            Some("https://as.example.com/token"),
740            None,
741            chrono::Duration::seconds(60),
742        )
743        .expect_err("a low-order key must be refused");
744        assert!(matches!(err, DpopError::WeakKey(_)));
745    }
746
747    #[test]
748    fn rejects_a_private_jwk_member() {
749        let builder = ProofBuilder::new();
750        let header = serde_json::json!({
751            "typ": "dpop+jwt",
752            "alg": "EdDSA",
753            "jwk": {
754                "kty": "OKP",
755                "crv": "Ed25519",
756                "x": b64(builder.signing_key.verifying_key().as_bytes()),
757                "d": "smuggled-private-scalar",
758            },
759        });
760        let payload = serde_json::json!({
761            "htm": "POST",
762            "htu": "https://as.example.com/token",
763            "iat": chrono::Utc::now().timestamp(),
764            "jti": "j1",
765        });
766        let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
767        let signature = builder.signing_key.sign(signing_input.as_bytes());
768        let proof = format!("{signing_input}.{}", b64(&signature.to_bytes()));
769
770        let err = verify_dpop_proof(
771            &proof,
772            "POST",
773            Some("https://as.example.com/token"),
774            None,
775            chrono::Duration::seconds(60),
776        )
777        .expect_err("a jwk carrying a private component must be refused");
778        assert!(matches!(err, DpopError::PrivateOrSymmetricJwk(_)));
779    }
780
781    #[test]
782    fn rejects_a_missing_jwk() {
783        let header = serde_json::json!({
784            "typ": "dpop+jwt",
785            "alg": "EdDSA",
786        });
787        let payload = serde_json::json!({
788            "htm": "POST",
789            "htu": "https://as.example.com/token",
790            "iat": chrono::Utc::now().timestamp(),
791            "jti": "j1",
792        });
793        let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
794        // The signature content doesn't matter — this must be rejected
795        // before signature verification, for lack of a key to verify with.
796        let proof = format!("{signing_input}.{}", b64(&[0u8; 64]));
797
798        let err = verify_dpop_proof(
799            &proof,
800            "POST",
801            Some("https://as.example.com/token"),
802            None,
803            chrono::Duration::seconds(60),
804        )
805        .expect_err("a proof with no embedded jwk must be refused");
806        assert!(matches!(err, DpopError::MissingOrInvalidJwk(_)));
807    }
808
809    /// Every other test in this module signs with Ed25519 via
810    /// `ProofBuilder`. ES256 (an EC P-256 key) is the algorithm most
811    /// real-world DPoP clients actually use, and exercises a genuinely
812    /// different code path: `expected_algorithm`'s `EllipticCurve` arm and
813    /// `jsonwebtoken`'s ECDSA verifier, neither of which the strict-EdDSA
814    /// gate (a no-op for non-OKP keys) touches at all.
815    #[test]
816    fn accepts_a_genuine_es256_proof() {
817        use jsonwebtoken::{Algorithm as JwtAlgorithm, EncodingKey, Header};
818        use p256::ecdsa::SigningKey as P256SigningKey;
819        use p256::elliptic_curve::{JwkEcKey, PublicKey as P256PublicKey};
820        use p256::pkcs8::EncodePrivateKey;
821        use rand_core::OsRng;
822
823        let signing_key = P256SigningKey::random(&mut OsRng);
824        let public_key: P256PublicKey<p256::NistP256> = signing_key.verifying_key().into();
825        let public_jwk: Jwk =
826            serde_json::from_value(serde_json::to_value(JwkEcKey::from(&public_key)).unwrap())
827                .expect("a p256 public JwkEcKey must parse as a jsonwebtoken Jwk");
828
829        let mut header = Header::new(JwtAlgorithm::ES256);
830        header.typ = Some("dpop+jwt".to_string());
831        header.jwk = Some(public_jwk);
832
833        let claims = serde_json::json!({
834            "htm": "POST",
835            "htu": "https://as.example.com/token",
836            "iat": chrono::Utc::now().timestamp(),
837            "jti": "es256-proof-1",
838        });
839
840        let pkcs8_der = signing_key.to_pkcs8_der().unwrap().as_bytes().to_vec();
841        let proof = jsonwebtoken::encode(&header, &claims, &EncodingKey::from_ec_der(&pkcs8_der))
842            .expect("encoding a genuine ES256 JWS must succeed");
843
844        let verified = verify_dpop_proof(
845            &proof,
846            "POST",
847            Some("https://as.example.com/token"),
848            None,
849            chrono::Duration::seconds(60),
850        )
851        .expect("a genuine ES256 proof must be accepted");
852        assert_eq!(verified.jti, "es256-proof-1");
853    }
854
855    /// authkestra#277 review: a `jti` this module hands to a downstream
856    /// replay-guard store must not be unbounded — the MySQL `AtomicInsert`
857    /// backend's key column is `VARCHAR(255)`, so a longer `jti` would
858    /// otherwise fail there instead of being cleanly refused here.
859    #[test]
860    fn rejects_a_jti_longer_than_the_max() {
861        let mut builder = ProofBuilder::new();
862        builder.jti = "j".repeat(MAX_JTI_LEN + 1);
863        let proof = builder.build();
864
865        let err = verify_dpop_proof(
866            &proof,
867            "POST",
868            Some("https://as.example.com/token"),
869            None,
870            chrono::Duration::seconds(60),
871        )
872        .expect_err("an over-long jti must be refused");
873        assert!(matches!(err, DpopError::JtiTooLong(len) if len == MAX_JTI_LEN + 1));
874    }
875
876    #[test]
877    fn a_jti_at_exactly_the_max_length_is_accepted() {
878        let mut builder = ProofBuilder::new();
879        builder.jti = "j".repeat(MAX_JTI_LEN);
880        let proof = builder.build();
881
882        verify_dpop_proof(
883            &proof,
884            "POST",
885            Some("https://as.example.com/token"),
886            None,
887            chrono::Duration::seconds(60),
888        )
889        .expect("a jti at exactly the max length must be accepted");
890    }
891
892    /// `canonicalize_htu`'s non-URL fallback compares the original strings
893    /// as-is — no case-folding, matching the (corrected) doc comment. Calls
894    /// the private function directly since there is no other way to
895    /// observe this branch: both sides of `verify_dpop_proof`'s comparison
896    /// always run through the same fallback together.
897    #[test]
898    fn canonicalize_htu_falls_back_to_exact_comparison_for_non_urls() {
899        assert_eq!(canonicalize_htu("not-a-url"), "not-a-url");
900        assert_ne!(canonicalize_htu("not-a-url"), canonicalize_htu("Not-A-Url"));
901    }
902
903    #[test]
904    fn thumbprint_matches_a_known_rfc7638_test_vector() {
905        // The exact example key and thumbprint from RFC 7638 §3.1.
906        let jwk: Jwk = serde_json::from_value(serde_json::json!({
907            "kty": "RSA",
908            "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
909            "e": "AQAB",
910        }))
911        .unwrap();
912        assert_eq!(
913            compute_jwk_thumbprint(&jwk).unwrap(),
914            "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
915        );
916    }
917}