dpop_verifier/
verify.rs

1use crate::uri::{normalize_htu, normalize_method};
2use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
3use base64::Engine;
4use serde::Deserialize;
5use sha2::{Digest, Sha256};
6use subtle::ConstantTimeEq;
7use time::OffsetDateTime;
8
9use crate::jwk::{thumbprint_ec_p256, verifying_key_from_p256_xy};
10use crate::replay::{ReplayContext, ReplayStore};
11use crate::DpopError;
12use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
13
14#[derive(Deserialize)]
15struct DpopHeader {
16    typ: String,
17    alg: String,
18    jwk: Jwk,
19}
20#[derive(Deserialize)]
21struct Jwk {
22    kty: String,
23    crv: String,
24    x: String,
25    y: String,
26}
27
28#[derive(Clone, Debug)]
29pub enum NonceMode {
30    Disabled,
31    /// Require exact equality against `expected_nonce`
32    RequireEqual {
33        expected_nonce: String, // the nonce you previously issued
34    },
35    /// Stateless HMAC-based nonces: encode ts+rand+ctx and MAC it
36    Hmac {
37        secret: std::sync::Arc<[u8]>, // server secret
38        max_age_secs: i64,            // window (e.g., 300)
39        bind_htu_htm: bool,
40        bind_jkt: bool,
41    },
42}
43
44#[derive(Debug, Clone)]
45pub struct VerifyOptions {
46    pub max_age_secs: i64,
47    pub future_skew_secs: i64,
48    pub nonce_mode: NonceMode,
49}
50impl Default for VerifyOptions {
51    fn default() -> Self {
52        Self {
53            max_age_secs: 300,
54            future_skew_secs: 5,
55            nonce_mode: NonceMode::Disabled,
56        }
57    }
58}
59
60#[derive(Debug)]
61pub struct VerifiedDpop {
62    pub jkt: String,
63    pub jti: String,
64    pub iat: i64,
65}
66
67/// Verify DPoP proof and record the jti to prevent replays.
68/// Optionally scope replay protection by the OAuth client identifier.
69pub async fn verify_proof<S: ReplayStore + ?Sized>(
70    store: &mut S,
71    client_id: Option<&str>,
72    dpop_compact_jws: &str,
73    expected_htu: &str,
74    expected_htm: &str,
75    maybe_access_token: Option<&str>,
76    opts: VerifyOptions,
77) -> Result<VerifiedDpop, DpopError> {
78    let mut it = dpop_compact_jws.split('.');
79    let (h_b64, p_b64, s_b64) = match (it.next(), it.next(), it.next()) {
80        (Some(h), Some(p), Some(s)) if it.next().is_none() => (h, p, s),
81        _ => return Err(DpopError::MalformedJws),
82    };
83
84    // Decode JOSE header (as Value first, to reject private 'd')
85    let hdr: DpopHeader = {
86        let bytes = B64.decode(h_b64).map_err(|_| DpopError::MalformedJws)?;
87        let val: serde_json::Value =
88            serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?;
89        // MUST NOT include private JWK material
90        if val.get("jwk").and_then(|j| j.get("d")).is_some() {
91            return Err(DpopError::BadJwk("jwk must not include 'd'"));
92        }
93        serde_json::from_value(val).map_err(|_| DpopError::MalformedJws)?
94    };
95
96    if hdr.typ != "dpop+jwt" {
97        return Err(DpopError::MalformedJws);
98    }
99    // JOSE algorithm must be ES256 (P-256 + SHA-256)
100
101    match hdr.alg.as_str() {
102        "ES256" => { /* ok */ }
103        // "EdDSA" if cfg!(feature = "eddsa") => { /* ok */ } <-- Will maybe add later
104        "none" => return Err(DpopError::InvalidAlg("none".into())),
105        a if a.starts_with("HS") => return Err(DpopError::InvalidAlg(a.into())),
106        other => return Err(DpopError::UnsupportedAlg(other.into())),
107    }
108    if hdr.jwk.kty != "EC" || hdr.jwk.crv != "P-256" {
109        return Err(DpopError::BadJwk("expect EC P-256"));
110    }
111
112    let vk: VerifyingKey = verifying_key_from_p256_xy(&hdr.jwk.x, &hdr.jwk.y)?;
113
114    // Verify ECDSA signature over "<header>.<payload>"
115    let signing_input = {
116        let mut s = String::with_capacity(h_b64.len() + 1 + p_b64.len());
117        s.push_str(h_b64);
118        s.push('.');
119        s.push_str(p_b64);
120        s
121    };
122
123    let sig_bytes = B64.decode(s_b64).map_err(|_| DpopError::InvalidSignature)?;
124    // JOSE (JWS ES256) requires raw r||s (64 bytes). Do NOT accept DER.
125    if sig_bytes.len() != 64 {
126        return Err(DpopError::InvalidSignature);
127    }
128    let sig = Signature::from_slice(&sig_bytes).map_err(|_| DpopError::InvalidSignature)?;
129    vk.verify(signing_input.as_bytes(), &sig)
130        .map_err(|_| DpopError::InvalidSignature)?;
131
132    let claims: serde_json::Value = {
133        let bytes = B64.decode(p_b64).map_err(|_| DpopError::MalformedJws)?;
134        serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?
135    };
136
137    let jti = claims
138        .get("jti")
139        .and_then(|v| v.as_str())
140        .ok_or(DpopError::MissingClaim("jti"))?;
141    if jti.len() > 512 {
142        return Err(DpopError::JtiTooLong);
143    }
144    let iat = claims
145        .get("iat")
146        .and_then(|v| v.as_i64())
147        .ok_or(DpopError::MissingClaim("iat"))?;
148    let htm = claims
149        .get("htm")
150        .and_then(|v| v.as_str())
151        .ok_or(DpopError::MissingClaim("htm"))?;
152    let htu = claims
153        .get("htu")
154        .and_then(|v| v.as_str())
155        .ok_or(DpopError::MissingClaim("htu"))?;
156
157    // Strict method & URI checks (normalize both sides, then exact compare)
158    let want_htm = normalize_method(expected_htm)?; // e.g., "GET"
159    let got_htm = normalize_method(htm)?; // from claims
160    if got_htm != want_htm {
161        return Err(DpopError::HtmMismatch);
162    }
163
164    let want_htu = normalize_htu(expected_htu)?; // scheme://host[:port]/path (no q/frag)
165    let got_htu = normalize_htu(htu)?;
166    if got_htu != want_htu {
167        return Err(DpopError::HtuMismatch);
168    }
169
170    // Optional ath (only when an access token is being presented)
171    if let Some(at) = maybe_access_token {
172        // Compute expected SHA-256 bytes of the exact token octets:
173        let want = Sha256::digest(at.as_bytes());
174        // Decode provided ath (must be base64url no-pad):
175        let got_b64 = claims
176            .get("ath")
177            .and_then(|v| v.as_str())
178            .ok_or(DpopError::MissingAth)?;
179        let got = B64
180            .decode(got_b64.as_bytes())
181            .map_err(|_| DpopError::AthMalformed)?;
182        // Constant-time compare of raw digests:
183        if got.len() != want.len() || !bool::from(got.ct_eq(&want[..])) {
184            return Err(DpopError::AthMismatch);
185        }
186    }
187
188    // Freshness (iat)
189    let now = OffsetDateTime::now_utc().unix_timestamp();
190    if iat > now + opts.future_skew_secs {
191        return Err(DpopError::FutureSkew);
192    }
193    if now - iat > opts.max_age_secs {
194        return Err(DpopError::Stale);
195    }
196
197    // Replay prevention (store SHA-256(jti))
198    let mut hasher = Sha256::new();
199    hasher.update(jti.as_bytes());
200    let mut jti_hash = [0u8; 32];
201    jti_hash.copy_from_slice(&hasher.finalize());
202
203    let jkt = thumbprint_ec_p256(&hdr.jwk.x, &hdr.jwk.y)?;
204
205    let nonce_claim = claims.get("nonce").and_then(|v| v.as_str());
206
207    match &opts.nonce_mode {
208        NonceMode::Disabled => { /* do nothing */ }
209        NonceMode::RequireEqual { expected_nonce } => {
210            let n = nonce_claim.ok_or(DpopError::MissingNonce)?;
211            if n != expected_nonce {
212                let fresh = expected_nonce.to_string(); // or issue a new one if you prefer
213                return Err(DpopError::UseDpopNonce { nonce: fresh });
214            }
215        }
216        NonceMode::Hmac {
217            secret,
218            max_age_secs,
219            bind_htu_htm,
220            bind_jkt,
221        } => {
222            let n = match nonce_claim {
223                Some(s) => s,
224                None => {
225                    // Missing → ask client to retry with nonce
226                    let now = time::OffsetDateTime::now_utc().unix_timestamp();
227                    let ctx = crate::nonce::NonceCtx {
228                        htu: if *bind_htu_htm {
229                            Some(want_htu.as_str())
230                        } else {
231                            None
232                        },
233                        htm: if *bind_htu_htm {
234                            Some(want_htm.as_str())
235                        } else {
236                            None
237                        },
238                        jkt: if *bind_jkt { Some(jkt.as_str()) } else { None },
239                    };
240                    let fresh = crate::nonce::issue_nonce(secret, now, &ctx);
241                    return Err(DpopError::UseDpopNonce { nonce: fresh });
242                }
243            };
244            let now = time::OffsetDateTime::now_utc().unix_timestamp();
245            let ctx = crate::nonce::NonceCtx {
246                htu: if *bind_htu_htm {
247                    Some(want_htu.as_str())
248                } else {
249                    None
250                },
251                htm: if *bind_htu_htm {
252                    Some(want_htm.as_str())
253                } else {
254                    None
255                },
256                jkt: if *bind_jkt { Some(jkt.as_str()) } else { None },
257            };
258            if let Err(_) = crate::nonce::verify_nonce(secret, n, now, *max_age_secs, &ctx) {
259                // On invalid/stale → emit NEW nonce so client can retry immediately
260                let fresh = crate::nonce::issue_nonce(secret, now, &ctx);
261                return Err(DpopError::UseDpopNonce { nonce: fresh });
262            }
263        }
264    }
265
266    let ok = store
267        .insert_once(
268            jti_hash,
269            ReplayContext {
270                client_id,
271                jkt: Some(&jkt),
272                htm: Some(htm),
273                htu: Some(htu),
274                iat,
275            },
276        )
277        .await?;
278    if !ok {
279        return Err(DpopError::Replay);
280    }
281
282    Ok(VerifiedDpop {
283        jkt,
284        jti: jti.to_string(),
285        iat,
286    })
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::jwk::thumbprint_ec_p256;
293    use crate::nonce::issue_nonce;
294    use p256::ecdsa::{signature::Signer, Signature, SigningKey};
295    use rand_core::OsRng;
296    use std::sync::Arc;
297
298    // ---- helpers ----------------------------------------------------------------
299
300    fn gen_es256_key() -> (SigningKey, String, String) {
301        let sk = SigningKey::random(&mut OsRng);
302        let vk = VerifyingKey::from(&sk);
303        let ep = vk.to_encoded_point(false);
304        let x = B64.encode(ep.x().unwrap());
305        let y = B64.encode(ep.y().unwrap());
306        (sk, x, y)
307    }
308
309    fn make_jws(
310        sk: &SigningKey,
311        header_val: serde_json::Value,
312        claims_val: serde_json::Value,
313    ) -> String {
314        let h = serde_json::to_vec(&header_val).unwrap();
315        let p = serde_json::to_vec(&claims_val).unwrap();
316        let h_b64 = B64.encode(h);
317        let p_b64 = B64.encode(p);
318        let signing_input = format!("{h_b64}.{p_b64}");
319        let sig: Signature = sk.sign(signing_input.as_bytes());
320        let s_b64 = B64.encode(sig.to_bytes());
321        format!("{h_b64}.{p_b64}.{s_b64}")
322    }
323
324    #[derive(Default)]
325    struct MemoryStore(std::collections::HashSet<[u8; 32]>);
326
327    #[async_trait::async_trait]
328    impl ReplayStore for MemoryStore {
329        async fn insert_once(
330            &mut self,
331            jti_hash: [u8; 32],
332            _ctx: ReplayContext<'_>,
333        ) -> Result<bool, DpopError> {
334            Ok(self.0.insert(jti_hash))
335        }
336    }
337    // ---- tests ------------------------------------------------------------------
338    #[test]
339    fn thumbprint_has_expected_length_and_no_padding() {
340        // 32 zero bytes -> base64url = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" (43 chars)
341        let x = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
342        let y = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
343        let t1 = thumbprint_ec_p256(x, y).expect("thumbprint");
344        let t2 = thumbprint_ec_p256(x, y).expect("thumbprint");
345        // deterministic and base64url w/out '=' padding; sha256 -> 43 chars
346        assert_eq!(t1, t2);
347        assert_eq!(t1.len(), 43);
348        assert!(!t1.contains('='));
349    }
350
351    #[test]
352    fn decoding_key_rejects_wrong_sizes() {
353        // 31-byte x (trimmed), 32-byte y
354        let bad_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 31]);
355        let good_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
356        let res = crate::jwk::verifying_key_from_p256_xy(&bad_x, &good_y);
357        assert!(res.is_err(), "expected error for bad y");
358
359        // 32-byte x, 33-byte y
360        let good_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
361        let bad_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 33]);
362        let res = crate::jwk::verifying_key_from_p256_xy(&good_x, &bad_y);
363        assert!(res.is_err(), "expected error for bad y");
364    }
365
366    #[tokio::test]
367    async fn replay_store_trait_basic() {
368        use async_trait::async_trait;
369        use std::collections::HashSet;
370
371        struct MemoryStore(HashSet<[u8; 32]>);
372
373        #[async_trait]
374        impl ReplayStore for MemoryStore {
375            async fn insert_once(
376                &mut self,
377                jti_hash: [u8; 32],
378                _ctx: ReplayContext<'_>,
379            ) -> Result<bool, DpopError> {
380                Ok(self.0.insert(jti_hash))
381            }
382        }
383
384        let mut s = MemoryStore(HashSet::new());
385        let first = s
386            .insert_once(
387                [42u8; 32],
388                ReplayContext {
389                    client_id: None,
390                    jkt: Some("j"),
391                    htm: Some("POST"),
392                    htu: Some("https://ex"),
393                    iat: 0,
394                },
395            )
396            .await
397            .unwrap();
398        let second = s
399            .insert_once(
400                [42u8; 32],
401                ReplayContext {
402                    client_id: None,
403                    jkt: Some("j"),
404                    htm: Some("POST"),
405                    htu: Some("https://ex"),
406                    iat: 0,
407                },
408            )
409            .await
410            .unwrap();
411        assert!(first);
412        assert!(!second); // replay detected
413    }
414    #[tokio::test]
415    async fn verify_valid_es256_proof() {
416        let (sk, x, y) = gen_es256_key();
417        let now = OffsetDateTime::now_utc().unix_timestamp();
418        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
419        let p = serde_json::json!({"jti":"j1","iat":now,"htm":"GET","htu":"https://api.example.com/resource"});
420        let jws = make_jws(&sk, h, p);
421
422        let mut store = MemoryStore::default();
423        let res = verify_proof(
424            &mut store,
425            None,
426            &jws,
427            "https://api.example.com/resource",
428            "GET",
429            None,
430            VerifyOptions::default(),
431        )
432        .await;
433        assert!(res.is_ok(), "{res:?}");
434    }
435
436    #[tokio::test]
437    async fn client_id_is_forwarded_to_replay_store() {
438        struct CaptureStore {
439            captured_client: Option<String>,
440        }
441
442        #[async_trait::async_trait]
443        impl ReplayStore for CaptureStore {
444            async fn insert_once(
445                &mut self,
446                _jti_hash: [u8; 32],
447                ctx: ReplayContext<'_>,
448            ) -> Result<bool, DpopError> {
449                self.captured_client = ctx.client_id.map(str::to_string);
450                Ok(true)
451            }
452        }
453
454        let (sk, x, y) = gen_es256_key();
455        let now = OffsetDateTime::now_utc().unix_timestamp();
456        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
457        let p = serde_json::json!({"jti":"client-test","iat":now,"htm":"GET","htu":"https://client.example/token"});
458        let jws = make_jws(&sk, h, p);
459
460        let mut store = CaptureStore {
461            captured_client: None,
462        };
463        verify_proof(
464            &mut store,
465            Some("client-123"),
466            &jws,
467            "https://client.example/token",
468            "GET",
469            None,
470            VerifyOptions::default(),
471        )
472        .await
473        .expect("verification succeeds");
474        assert_eq!(store.captured_client.as_deref(), Some("client-123"));
475    }
476
477    #[tokio::test]
478    async fn method_normalization_allows_lowercase_claim() {
479        let (sk, x, y) = gen_es256_key();
480        let now = OffsetDateTime::now_utc().unix_timestamp();
481        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
482        let p = serde_json::json!({"jti":"j2","iat":now,"htm":"get","htu":"https://ex.com/a"});
483        let jws = make_jws(&sk, h, p);
484
485        let mut store = MemoryStore::default();
486        assert!(verify_proof(
487            &mut store,
488            None,
489            &jws,
490            "https://ex.com/a",
491            "GET",
492            None,
493            VerifyOptions::default()
494        )
495        .await
496        .is_ok());
497    }
498
499    #[tokio::test]
500    async fn htu_normalizes_dot_segments_and_default_ports_and_strips_qf() {
501        let (sk, x, y) = gen_es256_key();
502        let now = OffsetDateTime::now_utc().unix_timestamp();
503        // claim has :443, dot-segment, query and fragment
504        let claim_htu = "https://EX.COM:443/a/../b?q=1#frag";
505        let expect_htu = "https://ex.com/b";
506        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
507        let p = serde_json::json!({"jti":"j3","iat":now,"htm":"GET","htu":claim_htu});
508        let jws = make_jws(&sk, h, p);
509
510        let mut store = MemoryStore::default();
511        assert!(verify_proof(
512            &mut store,
513            None,
514            &jws,
515            expect_htu,
516            "GET",
517            None,
518            VerifyOptions::default()
519        )
520        .await
521        .is_ok());
522    }
523
524    #[tokio::test]
525    async fn htu_path_case_mismatch_fails() {
526        let (sk, x, y) = gen_es256_key();
527        let now = OffsetDateTime::now_utc().unix_timestamp();
528        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
529        let p = serde_json::json!({"jti":"j4","iat":now,"htm":"GET","htu":"https://ex.com/API"});
530        let jws = make_jws(&sk, h, p);
531
532        let mut store = MemoryStore::default();
533        let err = verify_proof(
534            &mut store,
535            None,
536            &jws,
537            "https://ex.com/api",
538            "GET",
539            None,
540            VerifyOptions::default(),
541        )
542        .await
543        .unwrap_err();
544        matches!(err, DpopError::HtuMismatch);
545    }
546
547    #[tokio::test]
548    async fn alg_none_rejected() {
549        let (sk, x, y) = gen_es256_key();
550        let now = OffsetDateTime::now_utc().unix_timestamp();
551        // still sign, but "alg":"none" must be rejected before/independent of signature
552        let h = serde_json::json!({"typ":"dpop+jwt","alg":"none","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
553        let p = serde_json::json!({"jti":"j5","iat":now,"htm":"GET","htu":"https://ex.com/a"});
554        let jws = make_jws(&sk, h, p);
555
556        let mut store = MemoryStore::default();
557        let err = verify_proof(
558            &mut store,
559            None,
560            &jws,
561            "https://ex.com/a",
562            "GET",
563            None,
564            VerifyOptions::default(),
565        )
566        .await
567        .unwrap_err();
568        matches!(err, DpopError::InvalidAlg(_));
569    }
570
571    #[tokio::test]
572    async fn alg_hs256_rejected() {
573        let (sk, x, y) = gen_es256_key();
574        let now = OffsetDateTime::now_utc().unix_timestamp();
575        let h = serde_json::json!({"typ":"dpop+jwt","alg":"HS256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
576        let p = serde_json::json!({"jti":"j6","iat":now,"htm":"GET","htu":"https://ex.com/a"});
577        let jws = make_jws(&sk, h, p);
578
579        let mut store = MemoryStore::default();
580        let err = verify_proof(
581            &mut store,
582            None,
583            &jws,
584            "https://ex.com/a",
585            "GET",
586            None,
587            VerifyOptions::default(),
588        )
589        .await
590        .unwrap_err();
591        matches!(err, DpopError::InvalidAlg(_));
592    }
593
594    #[tokio::test]
595    async fn jwk_with_private_d_rejected() {
596        let (sk, x, y) = gen_es256_key();
597        let now = OffsetDateTime::now_utc().unix_timestamp();
598        // inject "d" (any string) -> must be rejected
599        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y,"d":"AAAA"}});
600        let p = serde_json::json!({"jti":"j7","iat":now,"htm":"GET","htu":"https://ex.com/a"});
601        let jws = make_jws(&sk, h, p);
602
603        let mut store = MemoryStore::default();
604        let err = verify_proof(
605            &mut store,
606            None,
607            &jws,
608            "https://ex.com/a",
609            "GET",
610            None,
611            VerifyOptions::default(),
612        )
613        .await
614        .unwrap_err();
615        matches!(err, DpopError::BadJwk(_));
616    }
617
618    #[tokio::test]
619    async fn ath_binding_ok_and_mismatch_and_padded_rejected() {
620        let (sk, x, y) = gen_es256_key();
621        let now = OffsetDateTime::now_utc().unix_timestamp();
622        let at = "access.token.string";
623        let ath = B64.encode(Sha256::digest(at.as_bytes()));
624        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
625
626        // OK
627        let p_ok = serde_json::json!({"jti":"j8","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
628        let jws_ok = make_jws(&sk, h.clone(), p_ok);
629        let mut store = MemoryStore::default();
630        assert!(verify_proof(
631            &mut store,
632            None,
633            &jws_ok,
634            "https://ex.com/a",
635            "GET",
636            Some(at),
637            VerifyOptions::default()
638        )
639        .await
640        .is_ok());
641
642        // Mismatch
643        let p_bad = serde_json::json!({"jti":"j9","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
644        let jws_bad = make_jws(&sk, h.clone(), p_bad);
645        let mut store2 = MemoryStore::default();
646        let err = verify_proof(
647            &mut store2,
648            None,
649            &jws_bad,
650            "https://ex.com/a",
651            "GET",
652            Some("different.token"),
653            VerifyOptions::default(),
654        )
655        .await
656        .unwrap_err();
657        matches!(err, DpopError::AthMismatch);
658
659        // Padded ath should be rejected as malformed (engine is URL_SAFE_NO_PAD)
660        let ath_padded = format!("{ath}==");
661        let p_pad = serde_json::json!({"jti":"j10","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath_padded});
662        let jws_pad = make_jws(&sk, h.clone(), p_pad);
663        let mut store3 = MemoryStore::default();
664        let err = verify_proof(
665            &mut store3,
666            None,
667            &jws_pad,
668            "https://ex.com/a",
669            "GET",
670            Some(at),
671            VerifyOptions::default(),
672        )
673        .await
674        .unwrap_err();
675        matches!(err, DpopError::AthMalformed);
676    }
677
678    #[tokio::test]
679    async fn freshness_future_skew_and_stale() {
680        let (sk, x, y) = gen_es256_key();
681        let now = OffsetDateTime::now_utc().unix_timestamp();
682        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
683
684        // Future skew just over limit
685        let p_future =
686            serde_json::json!({"jti":"jf","iat":now + 6,"htm":"GET","htu":"https://ex.com/a"});
687        let jws_future = make_jws(&sk, h.clone(), p_future);
688        let mut store1 = MemoryStore::default();
689        let opts = VerifyOptions {
690            max_age_secs: 300,
691            future_skew_secs: 5,
692            nonce_mode: NonceMode::Disabled,
693        };
694        let err = verify_proof(
695            &mut store1,
696            None,
697            &jws_future,
698            "https://ex.com/a",
699            "GET",
700            None,
701            opts,
702        )
703        .await
704        .unwrap_err();
705        matches!(err, DpopError::FutureSkew);
706
707        // Stale just over limit
708        let p_stale =
709            serde_json::json!({"jti":"js","iat":now - 301,"htm":"GET","htu":"https://ex.com/a"});
710        let jws_stale = make_jws(&sk, h.clone(), p_stale);
711        let mut store2 = MemoryStore::default();
712        let opts = VerifyOptions {
713            max_age_secs: 300,
714            future_skew_secs: 5,
715            nonce_mode: NonceMode::Disabled,
716        };
717        let err = verify_proof(
718            &mut store2,
719            None,
720            &jws_stale,
721            "https://ex.com/a",
722            "GET",
723            None,
724            opts,
725        )
726        .await
727        .unwrap_err();
728        matches!(err, DpopError::Stale);
729    }
730
731    #[tokio::test]
732    async fn replay_same_jti_is_rejected() {
733        let (sk, x, y) = gen_es256_key();
734        let now = OffsetDateTime::now_utc().unix_timestamp();
735        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
736        let p = serde_json::json!({"jti":"jr","iat":now,"htm":"GET","htu":"https://ex.com/a"});
737        let jws = make_jws(&sk, h, p);
738
739        let mut store = MemoryStore::default();
740        let ok1 = verify_proof(
741            &mut store,
742            None,
743            &jws,
744            "https://ex.com/a",
745            "GET",
746            None,
747            VerifyOptions::default(),
748        )
749        .await;
750        assert!(ok1.is_ok());
751        let err = verify_proof(
752            &mut store,
753            None,
754            &jws,
755            "https://ex.com/a",
756            "GET",
757            None,
758            VerifyOptions::default(),
759        )
760        .await
761        .unwrap_err();
762        matches!(err, DpopError::Replay);
763    }
764
765    #[tokio::test]
766    async fn signature_tamper_detected() {
767        let (sk, x, y) = gen_es256_key();
768        let now = OffsetDateTime::now_utc().unix_timestamp();
769        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
770        let p = serde_json::json!({"jti":"jt","iat":now,"htm":"GET","htu":"https://ex.com/a"});
771        let mut jws = make_jws(&sk, h, p);
772
773        // Flip one byte in the payload section (keep base64url valid length)
774        let bytes = unsafe { jws.as_bytes_mut() }; // alternative: rebuild string
775                                                   // Find the second '.' and flip a safe ASCII char before it
776        let mut dot_count = 0usize;
777        for i in 0..bytes.len() {
778            if bytes[i] == b'.' {
779                dot_count += 1;
780                if dot_count == 2 && i > 10 {
781                    bytes[i - 5] ^= 0x01; // tiny flip
782                    break;
783                }
784            }
785        }
786
787        let mut store = MemoryStore::default();
788        let err = verify_proof(
789            &mut store,
790            None,
791            &jws,
792            "https://ex.com/a",
793            "GET",
794            None,
795            VerifyOptions::default(),
796        )
797        .await
798        .unwrap_err();
799        matches!(err, DpopError::InvalidSignature);
800    }
801
802    #[tokio::test]
803    async fn method_mismatch_rejected() {
804        let (sk, x, y) = gen_es256_key();
805        let now = OffsetDateTime::now_utc().unix_timestamp();
806        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
807        let p = serde_json::json!({"jti":"jm","iat":now,"htm":"POST","htu":"https://ex.com/a"});
808        let jws = make_jws(&sk, h, p);
809
810        let mut store = MemoryStore::default();
811        let err = verify_proof(
812            &mut store,
813            None,
814            &jws,
815            "https://ex.com/a",
816            "GET",
817            None,
818            VerifyOptions::default(),
819        )
820        .await
821        .unwrap_err();
822        matches!(err, DpopError::HtmMismatch);
823    }
824
825    #[test]
826    fn normalize_helpers_examples() {
827        // sanity checks for helpers used by verify_proof
828        assert_eq!(
829            normalize_htu("https://EX.com:443/a/./b/../c?x=1#frag").unwrap(),
830            "https://ex.com/a/c"
831        );
832        assert_eq!(normalize_method("get").unwrap(), "GET");
833        assert!(normalize_method("CUSTOM").is_err());
834    }
835
836    #[tokio::test]
837    async fn jti_too_long_rejected() {
838        let (sk, x, y) = gen_es256_key();
839        let now = OffsetDateTime::now_utc().unix_timestamp();
840        let too_long = "x".repeat(513);
841        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
842        let p = serde_json::json!({"jti":too_long,"iat":now,"htm":"GET","htu":"https://ex.com/a"});
843        let jws = make_jws(&sk, h, p);
844
845        let mut store = MemoryStore::default();
846        let err = verify_proof(
847            &mut store,
848            None,
849            &jws,
850            "https://ex.com/a",
851            "GET",
852            None,
853            VerifyOptions::default(),
854        )
855        .await
856        .unwrap_err();
857        matches!(err, DpopError::JtiTooLong);
858    }
859    // ----------------------- Nonce: RequireEqual -------------------------------
860
861    #[tokio::test]
862    async fn nonce_require_equal_ok() {
863        let (sk, x, y) = gen_es256_key();
864        let now = OffsetDateTime::now_utc().unix_timestamp();
865        let expected_htu = "https://ex.com/a";
866        let expected_htm = "GET";
867
868        let expected_nonce = "nonce-123";
869        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
870        let p = serde_json::json!({
871            "jti":"n-reqeq-ok",
872            "iat":now,
873            "htm":expected_htm,
874            "htu":expected_htu,
875            "nonce": expected_nonce
876        });
877        let jws = make_jws(&sk, h, p);
878
879        let mut store = MemoryStore::default();
880        let opts = VerifyOptions {
881            max_age_secs: 300,
882            future_skew_secs: 5,
883            nonce_mode: NonceMode::RequireEqual {
884                expected_nonce: expected_nonce.to_string(),
885            },
886        };
887        assert!(verify_proof(
888            &mut store,
889            None,
890            &jws,
891            expected_htu,
892            expected_htm,
893            None,
894            opts
895        )
896        .await
897        .is_ok());
898    }
899
900    #[tokio::test]
901    async fn nonce_require_equal_missing_claim() {
902        let (sk, x, y) = gen_es256_key();
903        let now = OffsetDateTime::now_utc().unix_timestamp();
904        let expected_htu = "https://ex.com/a";
905        let expected_htm = "GET";
906
907        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
908        let p = serde_json::json!({
909            "jti":"n-reqeq-miss",
910            "iat":now,
911            "htm":expected_htm,
912            "htu":expected_htu
913        });
914        let jws = make_jws(&sk, h, p);
915
916        let mut store = MemoryStore::default();
917        let opts = VerifyOptions {
918            max_age_secs: 300,
919            future_skew_secs: 5,
920            nonce_mode: NonceMode::RequireEqual {
921                expected_nonce: "x".into(),
922            },
923        };
924        let err = verify_proof(
925            &mut store,
926            None,
927            &jws,
928            expected_htu,
929            expected_htm,
930            None,
931            opts,
932        )
933        .await
934        .unwrap_err();
935        matches!(err, DpopError::MissingNonce);
936    }
937
938    #[tokio::test]
939    async fn nonce_require_equal_mismatch_yields_usedpopnonce() {
940        let (sk, x, y) = gen_es256_key();
941        let now = OffsetDateTime::now_utc().unix_timestamp();
942        let expected_htu = "https://ex.com/a";
943        let expected_htm = "GET";
944
945        let claim_nonce = "client-value";
946        let expected_nonce = "server-expected";
947        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
948        let p = serde_json::json!({
949            "jti":"n-reqeq-mis",
950            "iat":now,
951            "htm":expected_htm,
952            "htu":expected_htu,
953            "nonce": claim_nonce
954        });
955        let jws = make_jws(&sk, h, p);
956
957        let mut store = MemoryStore::default();
958        let opts = VerifyOptions {
959            max_age_secs: 300,
960            future_skew_secs: 5,
961            nonce_mode: NonceMode::RequireEqual {
962                expected_nonce: expected_nonce.into(),
963            },
964        };
965        let err = verify_proof(
966            &mut store,
967            None,
968            &jws,
969            expected_htu,
970            expected_htm,
971            None,
972            opts,
973        )
974        .await
975        .unwrap_err();
976        // Server should respond with UseDpopNonce carrying a fresh/expected nonce
977        if let DpopError::UseDpopNonce { nonce } = err {
978            assert_eq!(nonce, expected_nonce);
979        } else {
980            panic!("expected UseDpopNonce, got {err:?}");
981        }
982    }
983
984    // -------------------------- Nonce: HMAC ------------------------------------
985
986    #[tokio::test]
987    async fn nonce_hmac_ok_bound_all() {
988        let (sk, x, y) = gen_es256_key();
989        let now = OffsetDateTime::now_utc().unix_timestamp();
990        let expected_htu = "https://ex.com/a";
991        let expected_htm = "GET";
992
993        // Compute jkt from header jwk x/y to match verifier's jkt
994        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
995
996        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
997        let ctx = crate::nonce::NonceCtx {
998            htu: Some(expected_htu),
999            htm: Some(expected_htm),
1000            jkt: Some(&jkt),
1001        };
1002        let nonce = issue_nonce(&secret, now, &ctx);
1003
1004        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1005        let p = serde_json::json!({
1006            "jti":"n-hmac-ok",
1007            "iat":now,
1008            "htm":expected_htm,
1009            "htu":expected_htu,
1010            "nonce": nonce
1011        });
1012        let jws = make_jws(&sk, h, p);
1013
1014        let mut store = MemoryStore::default();
1015        let opts = VerifyOptions {
1016            max_age_secs: 300,
1017            future_skew_secs: 5,
1018            nonce_mode: NonceMode::Hmac {
1019                secret: secret.clone(),
1020                max_age_secs: 300,
1021                bind_htu_htm: true,
1022                bind_jkt: true,
1023            },
1024        };
1025        assert!(verify_proof(
1026            &mut store,
1027            None,
1028            &jws,
1029            expected_htu,
1030            expected_htm,
1031            None,
1032            opts
1033        )
1034        .await
1035        .is_ok());
1036    }
1037
1038    #[tokio::test]
1039    async fn nonce_hmac_missing_claim_prompts_use_dpop_nonce() {
1040        let (sk, x, y) = gen_es256_key();
1041        let now = OffsetDateTime::now_utc().unix_timestamp();
1042        let expected_htu = "https://ex.com/a";
1043        let expected_htm = "GET";
1044
1045        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
1046
1047        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1048        let p = serde_json::json!({
1049            "jti":"n-hmac-miss",
1050            "iat":now,
1051            "htm":expected_htm,
1052            "htu":expected_htu
1053        });
1054        let jws = make_jws(&sk, h, p);
1055
1056        let mut store = MemoryStore::default();
1057        let opts = VerifyOptions {
1058            max_age_secs: 300,
1059            future_skew_secs: 5,
1060            nonce_mode: NonceMode::Hmac {
1061                secret: secret.clone(),
1062                max_age_secs: 300,
1063                bind_htu_htm: true,
1064                bind_jkt: true,
1065            },
1066        };
1067        let err = verify_proof(
1068            &mut store,
1069            None,
1070            &jws,
1071            expected_htu,
1072            expected_htm,
1073            None,
1074            opts,
1075        )
1076        .await
1077        .unwrap_err();
1078        matches!(err, DpopError::UseDpopNonce { .. });
1079    }
1080
1081    #[tokio::test]
1082    async fn nonce_hmac_wrong_htu_prompts_use_dpop_nonce() {
1083        let (sk, x, y) = gen_es256_key();
1084        let now = OffsetDateTime::now_utc().unix_timestamp();
1085        let expected_htm = "GET";
1086        let expected_htu = "https://ex.com/correct";
1087
1088        // Bind nonce to a different HTU to force mismatch
1089        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1090        let secret: Arc<[u8]> = Arc::from(&b"k"[..]);
1091        let ctx_wrong = crate::nonce::NonceCtx {
1092            htu: Some("https://ex.com/wrong"),
1093            htm: Some(expected_htm),
1094            jkt: Some(&jkt),
1095        };
1096        let nonce = issue_nonce(&secret, now, &ctx_wrong);
1097
1098        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1099        let p = serde_json::json!({
1100            "jti":"n-hmac-htu-mis",
1101            "iat":now,
1102            "htm":expected_htm,
1103            "htu":expected_htu,
1104            "nonce": nonce
1105        });
1106        let jws = make_jws(&sk, h, p);
1107
1108        let mut store = MemoryStore::default();
1109        let opts = VerifyOptions {
1110            max_age_secs: 300,
1111            future_skew_secs: 5,
1112            nonce_mode: NonceMode::Hmac {
1113                secret: secret.clone(),
1114                max_age_secs: 300,
1115                bind_htu_htm: true,
1116                bind_jkt: true,
1117            },
1118        };
1119        let err = verify_proof(
1120            &mut store,
1121            None,
1122            &jws,
1123            expected_htu,
1124            expected_htm,
1125            None,
1126            opts,
1127        )
1128        .await
1129        .unwrap_err();
1130        matches!(err, DpopError::UseDpopNonce { .. });
1131    }
1132
1133    #[tokio::test]
1134    async fn nonce_hmac_wrong_jkt_prompts_use_dpop_nonce() {
1135        // Create two keys; mint nonce with jkt from key A, but sign proof with key B
1136        let (_sk_a, x_a, y_a) = gen_es256_key();
1137        let (sk_b, x_b, y_b) = gen_es256_key();
1138        let now = OffsetDateTime::now_utc().unix_timestamp();
1139        let expected_htu = "https://ex.com/a";
1140        let expected_htm = "GET";
1141
1142        let jkt_a = thumbprint_ec_p256(&x_a, &y_a).unwrap();
1143        let secret: Arc<[u8]> = Arc::from(&b"secret-2"[..]);
1144        let ctx = crate::nonce::NonceCtx {
1145            htu: Some(expected_htu),
1146            htm: Some(expected_htm),
1147            jkt: Some(&jkt_a), // bind nonce to A's jkt
1148        };
1149        let nonce = issue_nonce(&secret, now, &ctx);
1150
1151        // Build proof with key B (=> jkt != jkt_a)
1152        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x_b,"y":y_b}});
1153        let p = serde_json::json!({
1154            "jti":"n-hmac-jkt-mis",
1155            "iat":now,
1156            "htm":expected_htm,
1157            "htu":expected_htu,
1158            "nonce": nonce
1159        });
1160        let jws = make_jws(&sk_b, h, p);
1161
1162        let mut store = MemoryStore::default();
1163        let opts = VerifyOptions {
1164            max_age_secs: 300,
1165            future_skew_secs: 5,
1166            nonce_mode: NonceMode::Hmac {
1167                secret: secret.clone(),
1168                max_age_secs: 300,
1169                bind_htu_htm: true,
1170                bind_jkt: true,
1171            },
1172        };
1173        let err = verify_proof(
1174            &mut store,
1175            None,
1176            &jws,
1177            expected_htu,
1178            expected_htm,
1179            None,
1180            opts,
1181        )
1182        .await
1183        .unwrap_err();
1184        matches!(err, DpopError::UseDpopNonce { .. });
1185    }
1186
1187    #[tokio::test]
1188    async fn nonce_hmac_stale_prompts_use_dpop_nonce() {
1189        let (sk, x, y) = gen_es256_key();
1190        let now = OffsetDateTime::now_utc().unix_timestamp();
1191        let expected_htu = "https://ex.com/a";
1192        let expected_htm = "GET";
1193
1194        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1195        let secret: Arc<[u8]> = Arc::from(&b"secret-3"[..]);
1196        // Issue with ts older than max_age
1197        let issued_ts = now - 400;
1198        let nonce = issue_nonce(
1199            &secret,
1200            issued_ts,
1201            &crate::nonce::NonceCtx {
1202                htu: Some(expected_htu),
1203                htm: Some(expected_htm),
1204                jkt: Some(&jkt),
1205            },
1206        );
1207
1208        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1209        let p = serde_json::json!({
1210            "jti":"n-hmac-stale",
1211            "iat":now,
1212            "htm":expected_htm,
1213            "htu":expected_htu,
1214            "nonce": nonce
1215        });
1216        let jws = make_jws(&sk, h, p);
1217
1218        let mut store = MemoryStore::default();
1219        let opts = VerifyOptions {
1220            max_age_secs: 300,
1221            future_skew_secs: 5,
1222            nonce_mode: NonceMode::Hmac {
1223                secret: secret.clone(),
1224                max_age_secs: 300,
1225                bind_htu_htm: true,
1226                bind_jkt: true,
1227            },
1228        };
1229        let err = verify_proof(
1230            &mut store,
1231            None,
1232            &jws,
1233            expected_htu,
1234            expected_htm,
1235            None,
1236            opts,
1237        )
1238        .await
1239        .unwrap_err();
1240        matches!(err, DpopError::UseDpopNonce { .. });
1241    }
1242
1243    #[tokio::test]
1244    async fn nonce_hmac_future_skew_prompts_use_dpop_nonce() {
1245        let (sk, x, y) = gen_es256_key();
1246        let now = OffsetDateTime::now_utc().unix_timestamp();
1247        let expected_htu = "https://ex.com/a";
1248        let expected_htm = "GET";
1249
1250        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1251        let secret: Arc<[u8]> = Arc::from(&b"secret-4"[..]);
1252        // Issue with ts in the future beyond 5s tolerance
1253        let issued_ts = now + 10;
1254        let nonce = issue_nonce(
1255            &secret,
1256            issued_ts,
1257            &crate::nonce::NonceCtx {
1258                htu: Some(expected_htu),
1259                htm: Some(expected_htm),
1260                jkt: Some(&jkt),
1261            },
1262        );
1263
1264        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1265        let p = serde_json::json!({
1266            "jti":"n-hmac-future",
1267            "iat":now,
1268            "htm":expected_htm,
1269            "htu":expected_htu,
1270            "nonce": nonce
1271        });
1272        let jws = make_jws(&sk, h, p);
1273
1274        let mut store = MemoryStore::default();
1275        let opts = VerifyOptions {
1276            max_age_secs: 300,
1277            future_skew_secs: 5,
1278            nonce_mode: NonceMode::Hmac {
1279                secret: secret.clone(),
1280                max_age_secs: 300,
1281                bind_htu_htm: true,
1282                bind_jkt: true,
1283            },
1284        };
1285        let err = verify_proof(
1286            &mut store,
1287            None,
1288            &jws,
1289            expected_htu,
1290            expected_htm,
1291            None,
1292            opts,
1293        )
1294        .await
1295        .unwrap_err();
1296        matches!(err, DpopError::UseDpopNonce { .. });
1297    }
1298}