Skip to main content

feather_reader/oauth/
dpop.rs

1//! DPoP (RFC 9449) — proof-of-possession for the per-session key.
2//!
3//! Every OAuth request carries a fresh `DPoP` proof: a short-lived JWS, signed
4//! by the session's own key, binding the request to that key. The access token
5//! is issued bound to the key's RFC 7638 thumbprint (`jkt`), so a stolen bearer
6//! token is useless without the private half.
7//!
8//! Three details are easy to get wrong and are pinned by tests here:
9//!
10//! * **`htu` is the request URI with userinfo, query and fragment removed**
11//!   (RFC 9449 §4.2). Leaving any of them on means the proof does not match what
12//!   the server canonicalizes, and every request is rejected — and userinfo
13//!   would additionally sign a password into a claim sent in the clear.
14//! * **the embedded `jwk` is the PUBLIC key only.** It is transmitted in the
15//!   clear in the JWS header; a private member here would publish the session's
16//!   signing key to the PDS and to anything on the path.
17//! * **a challenge belongs to its own scheme.** RFC 9449 §7.2 has a resource
18//!   server returning a `Bearer` and a `DPoP` challenge in ONE header, so
19//!   reading the first `error=` found attributes one scheme's error to the
20//!   other.
21//!
22//! The server may demand a nonce at any time. That is normal operation, not an
23//! error: the caller retries ONCE with the supplied nonce.
24//!
25//! It is signalled two different ways depending on which endpoint answered —
26//! the authorization server uses a `400` and a JSON body, the resource server a
27//! `401` and a `WWW-Authenticate` header. Handling only the header misses every
28//! challenge from PAR, token and refresh, which is everything this client talks
29//! to first. See [`nonce_challenge`].
30
31use anyhow::{anyhow, bail, Context as _, Result};
32use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33use base64::Engine;
34use serde_json::{json, Map, Value};
35
36use super::jwt;
37use super::keys::SigningKey;
38
39/// The one challenge that means "retry with a nonce".
40///
41/// `invalid_dpop_proof` is deliberately NOT here. RFC 9449 registers it (§12.2)
42/// for a proof rejected on its merits against the §4.3 checks — bad `htu`, clock
43/// skew, an unacceptable `alg`. Retrying spends the single permitted attempt
44/// replaying an equivalent proof and reports the failure as a nonce problem,
45/// hiding the real cause.
46const NONCE_CHALLENGE: &str = "use_dpop_nonce";
47
48/// A fresh, unguessable `jti`. 16 bytes of CSPRNG output is 22 base64url
49/// characters — well past what a replay cache needs to be collision-free.
50fn new_jti() -> String {
51    let mut bytes = [0u8; 16];
52    getrandom::fill(&mut bytes).expect("OS CSPRNG unavailable; refusing to mint a DPoP proof");
53    URL_SAFE_NO_PAD.encode(bytes)
54}
55
56/// Every character legal in an HTTP method, per RFC 9110's `token` rule.
57fn is_tchar(b: u8) -> bool {
58    b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
59}
60
61/// The `htu` claim: the request URI with **userinfo**, query and fragment
62/// removed, per RFC 9449 §4.2.
63///
64/// Userinfo matters beyond tidiness. RFC 9110 §7.1 target URIs have no userinfo
65/// component, so a server comparing `htu` against the target could never match
66/// one — and since the proof is transmitted in the clear, leaving it in would
67/// sign a password into a claim on the wire.
68///
69/// The scheme is checked here rather than assumed: a `file:` or `data:` target
70/// cannot be a real HTTP request, and would be signed verbatim into a claim.
71fn htu(url: &str) -> Result<String> {
72    let mut parsed = url::Url::parse(url).with_context(|| format!("not a valid URL {url:?}"))?;
73    if !matches!(parsed.scheme(), "http" | "https") {
74        bail!("DPoP target must be http(s), got {:?}", parsed.scheme());
75    }
76    parsed
77        .set_username("")
78        .map_err(|()| anyhow!("cannot strip userinfo from the DPoP target"))?;
79    parsed
80        .set_password(None)
81        .map_err(|()| anyhow!("cannot strip userinfo from the DPoP target"))?;
82    parsed.set_query(None);
83    parsed.set_fragment(None);
84    Ok(parsed.to_string())
85}
86
87/// The public key as RFC 9449 wants it embedded: the required members only.
88///
89/// `public_jwk` also carries `kid`/`alg`/`use`, which are meaningful in a JWKS
90/// but not here, and some servers are strict about extras. Building a fresh map
91/// from the four required members also means a private member cannot reach this
92/// header by construction, not merely by remembering to strip it.
93fn embedded_public_jwk(key: &SigningKey) -> Result<Value> {
94    let full = key.public_jwk()?;
95    let mut minimal = Map::new();
96    for name in ["kty", "crv", "x", "y"] {
97        let value = full
98            .get(name)
99            .cloned()
100            .with_context(|| format!("public JWK is missing `{name}`"))?;
101        minimal.insert(name.to_string(), value);
102    }
103    Ok(Value::Object(minimal))
104}
105
106/// Build a DPoP proof for one request.
107///
108/// `access_token` binds the proof to that token via `ath`; pass it for every
109/// resource request. Without it a captured proof can be replayed alongside a
110/// different token.
111///
112/// `nonce` is the value from a previous `DPoP-Nonce` response header, supplied
113/// on the retry after a [`nonce_challenge`].
114pub fn proof(
115    key: &SigningKey,
116    method: &str,
117    url: &str,
118    access_token: Option<&str>,
119    nonce: Option<&str>,
120) -> Result<String> {
121    let header = json!({
122        "typ": "dpop+jwt",
123        "alg": "ES256",
124        "jwk": embedded_public_jwk(key)?,
125    });
126
127    if method.is_empty() || !method.bytes().all(is_tchar) {
128        bail!("{method:?} is not a valid HTTP method token");
129    }
130
131    let mut claims = Map::new();
132    claims.insert("jti".into(), json!(new_jti()));
133    claims.insert("htm".into(), json!(method.to_ascii_uppercase()));
134    claims.insert("htu".into(), json!(htu(url)?));
135    claims.insert("iat".into(), json!(chrono::Utc::now().timestamp()));
136    if let Some(token) = access_token {
137        let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
138        claims.insert("ath".into(), json!(URL_SAFE_NO_PAD.encode(digest.as_ref())));
139    }
140    if let Some(nonce) = nonce {
141        claims.insert("nonce".into(), json!(nonce));
142    }
143
144    jwt::sign(key, &header, &Value::Object(claims))
145}
146
147/// One parsed `WWW-Authenticate` challenge.
148struct Challenge {
149    scheme: String,
150    params: Vec<(String, String)>,
151}
152
153/// Split a header value on commas that are OUTSIDE a quoted string.
154///
155/// Returns `None` for a malformed value — specifically an unterminated quoted
156/// string, which would un-protect every following comma and let server-supplied
157/// free text splice in a challenge that was never sent.
158fn split_segments(header: &str) -> Option<Vec<String>> {
159    let mut out = Vec::new();
160    let mut current = String::new();
161    let mut chars = header.chars();
162    let mut in_quotes = false;
163
164    while let Some(c) = chars.next() {
165        if in_quotes {
166            match c {
167                // A quoted-pair escapes the next character, whatever it is.
168                '\\' => {
169                    current.push('\\');
170                    current.push(chars.next()?);
171                }
172                '"' => {
173                    in_quotes = false;
174                    current.push(c);
175                }
176                _ => current.push(c),
177            }
178        } else {
179            match c {
180                '"' => {
181                    in_quotes = true;
182                    current.push(c);
183                }
184                ',' => out.push(std::mem::take(&mut current)),
185                _ => current.push(c),
186            }
187        }
188    }
189    if in_quotes {
190        return None;
191    }
192    out.push(current);
193    Some(out)
194}
195
196/// Byte index of the first `=` outside a quoted string.
197fn first_unquoted_eq(s: &str) -> Option<usize> {
198    let mut in_quotes = false;
199    let mut escaped = false;
200    for (i, c) in s.char_indices() {
201        if in_quotes {
202            if escaped {
203                escaped = false;
204            } else if c == '\\' {
205                escaped = true;
206            } else if c == '"' {
207                in_quotes = false;
208            }
209        } else if c == '"' {
210            in_quotes = true;
211        } else if c == '=' {
212            return Some(i);
213        }
214    }
215    None
216}
217
218/// Decode an auth-param value: either a quoted-string (unescaping quoted-pairs)
219/// or a bare token. `None` if it is neither.
220fn unquote(raw: &str) -> Option<String> {
221    let Some(inner) = raw.strip_prefix('"') else {
222        // A bare token: no quotes, no whitespace, not empty.
223        if raw.is_empty() || raw.contains('"') || raw.chars().any(char::is_whitespace) {
224            return None;
225        }
226        return Some(raw.to_string());
227    };
228    let inner = inner.strip_suffix('"')?;
229    let mut out = String::new();
230    let mut chars = inner.chars();
231    while let Some(c) = chars.next() {
232        match c {
233            '\\' => out.push(chars.next()?),
234            // A bare quote inside the string means the quoting is not what it
235            // appears to be; refuse rather than guess.
236            '"' => return None,
237            _ => out.push(c),
238        }
239    }
240    Some(out)
241}
242
243/// Parse a `WWW-Authenticate` value into its challenges.
244///
245/// Scheme tracking is the point. RFC 9449 §7.2 has a resource server returning
246/// a `Bearer` **and** a `DPoP` challenge in one header, so a parser that just
247/// hunts for the first `error=` will attribute one scheme's error to the other
248/// — either missing the nonce handshake entirely, or inventing one.
249///
250/// A segment is a new challenge when the text before its `=` is two tokens
251/// (`DPoP error=…`), and a continuation of the current one when it is a single
252/// token (`algs=…`). RFC 7235 allows bad whitespace around the `=`, so the
253/// split is on the `=` rather than on the first space.
254///
255/// `None` means malformed; callers must treat that as "no challenge".
256fn parse_challenges(header: &str) -> Option<Vec<Challenge>> {
257    let mut challenges: Vec<Challenge> = Vec::new();
258
259    for segment in split_segments(header)? {
260        let segment = segment.trim();
261        if segment.is_empty() {
262            continue;
263        }
264        let Some(eq) = first_unquoted_eq(segment) else {
265            // A bare token: a challenge carrying no parameters.
266            challenges.push(Challenge {
267                scheme: segment.to_string(),
268                params: Vec::new(),
269            });
270            continue;
271        };
272        let left = segment[..eq].trim();
273        let Some(value) = unquote(segment[eq + 1..].trim()) else {
274            // An unreadable VALUE is not grounds to discard the header. The
275            // common cause is `token68`, which RFC 9110 §11.6.1 permits in place
276            // of auth-params and which ends in `=` — so `Negotiate YII=` looks
277            // like a parameter with an empty value.
278            //
279            // The asymmetry with `split_segments` returning `None` is
280            // deliberate, and the direction is the reason: an unbalanced quote
281            // can manufacture a challenge that was never sent (a FALSE
282            // POSITIVE), so it fails closed; skipping a segment we cannot read
283            // can only ever miss one (a FALSE NEGATIVE), so it degrades to
284            // "this challenge has no readable parameters" and leaves the others
285            // intact.
286            if let Some((scheme, _)) = left.split_once(char::is_whitespace) {
287                challenges.push(Challenge {
288                    scheme: scheme.trim().to_string(),
289                    params: Vec::new(),
290                });
291            }
292            continue;
293        };
294
295        match left.split_once(char::is_whitespace) {
296            Some((scheme, name)) => challenges.push(Challenge {
297                scheme: scheme.trim().to_string(),
298                params: vec![(name.trim().to_ascii_lowercase(), value)],
299            }),
300            // A parameter before any scheme has been named is malformed.
301            None => challenges
302                .last_mut()?
303                .params
304                .push((left.to_ascii_lowercase(), value)),
305        }
306    }
307    Some(challenges)
308}
309
310/// Which kind of endpoint produced a response.
311///
312/// Passed in rather than inferred: we always know which we called, and the two
313/// signal a nonce requirement completely differently (see [`nonce_challenge`]).
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum Endpoint {
316    /// PAR, token and refresh — RFC 9449 §8.
317    AuthorizationServer,
318    /// The PDS's XRPC endpoints — RFC 9449 §9.
319    ResourceServer,
320}
321
322/// Whether an authorization-server error body is a nonce challenge.
323///
324/// The size guard is [`super::error_body_worth_parsing`] — this file had the only
325/// copy of it until a review found two more error peeks with none.
326fn body_asks_for_nonce(body: &[u8]) -> bool {
327    if !super::error_body_worth_parsing(body) {
328        return false;
329    }
330    serde_json::from_slice::<Value>(body)
331        .ok()
332        .as_ref()
333        .and_then(|v| v.get("error"))
334        .and_then(Value::as_str)
335        == Some(NONCE_CHALLENGE)
336}
337
338/// Whether a `WWW-Authenticate` value carries a **DPoP** nonce challenge.
339fn header_asks_for_nonce(www_authenticate: &str) -> bool {
340    let Some(challenges) = parse_challenges(www_authenticate) else {
341        return false;
342    };
343    challenges.iter().any(|c| {
344        c.scheme.eq_ignore_ascii_case("DPoP")
345            && c.params
346                .iter()
347                .any(|(name, value)| name == "error" && value == NONCE_CHALLENGE)
348    })
349}
350
351/// The nonce to retry the request with, or `None` if this is not a nonce
352/// challenge.
353///
354/// **RFC 9449 signals this two different ways**, and which one applies depends
355/// on the endpoint, not on what happens to be in the response:
356///
357/// * **Authorization server** (§8) — PAR, token, refresh. `400` with an
358///   RFC 6749 §5.2 JSON body `{"error":"use_dpop_nonce"}`, and typically NO
359///   `WWW-Authenticate` header at all.
360/// * **Resource server** (§9) — the PDS's XRPC endpoints. `401` with
361///   `WWW-Authenticate: DPoP …error="use_dpop_nonce"`.
362///
363/// Reading only the header would miss every challenge on the authorization
364/// server — which is the first thing this client talks to — and the token
365/// exchange would fail permanently. `@atproto/oauth-client`'s
366/// `isUseDpopNonceError` branches on the same distinction.
367///
368/// A `DPoP-Nonce` must also actually be present: without one there is nothing to
369/// retry *with*, so retrying would replay an equivalent proof and report the
370/// wrong cause.
371///
372/// Callers must bound the retry at ONE. A server answering every request with
373/// `use_dpop_nonce` would otherwise spin forever.
374pub fn nonce_challenge(
375    endpoint: Endpoint,
376    status: u16,
377    www_authenticate: Option<&str>,
378    body: &[u8],
379    dpop_nonce: Option<&str>,
380) -> Option<String> {
381    let nonce = dpop_nonce.filter(|n| !n.is_empty())?;
382    let asked = match endpoint {
383        Endpoint::AuthorizationServer => status == 400 && body_asks_for_nonce(body),
384        Endpoint::ResourceServer => {
385            status == 401 && www_authenticate.is_some_and(header_asks_for_nonce)
386        }
387    };
388    asked.then(|| nonce.to_string())
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::oauth::jwt::verify;
395
396    const KID: &str = "dpop-1";
397
398    fn parts(jws: &str) -> (Value, Value) {
399        let seg: Vec<&str> = jws.split('.').collect();
400        (
401            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(seg[0]).unwrap()).unwrap(),
402            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(seg[1]).unwrap()).unwrap(),
403        )
404    }
405
406    // ── header ───────────────────────────────────────────────────────────────
407
408    #[test]
409    fn the_proof_header_is_a_dpop_jwt_with_an_embedded_public_key() {
410        let key = SigningKey::generate(KID);
411        let proof = proof(&key, "POST", "https://bsky.social/oauth/token", None, None).unwrap();
412        let (header, _) = parts(&proof);
413        assert_eq!(header["typ"], "dpop+jwt");
414        assert_eq!(header["alg"], "ES256");
415        assert_eq!(header["jwk"]["kty"], "EC");
416        assert_eq!(header["jwk"]["crv"], "P-256");
417        assert!(header["jwk"]["x"].is_string());
418        assert!(header["jwk"]["y"].is_string());
419    }
420
421    /// **The leak that would matter most.** The header `jwk` travels in the
422    /// clear to the PDS. A `d` member here publishes the session's private key.
423    #[test]
424    fn the_embedded_jwk_never_carries_the_private_scalar() {
425        let key = SigningKey::generate(KID);
426        let proof = proof(&key, "GET", "https://bsky.social/xrpc/x", None, None).unwrap();
427        let (header, _) = parts(&proof);
428        assert!(
429            header["jwk"].get("d").is_none(),
430            "private scalar in DPoP header"
431        );
432        let rendered = serde_json::to_string(&header).unwrap();
433        assert!(
434            !rendered.contains("\"d\""),
435            "private scalar in header: {rendered}"
436        );
437    }
438
439    /// RFC 9449 embeds only the public key members; `kid`/`alg`/`use` are not
440    /// wanted here and some servers are strict about extras.
441    #[test]
442    fn the_embedded_jwk_is_the_minimal_public_key() {
443        let key = SigningKey::generate(KID);
444        let (header, _) = parts(&proof(&key, "GET", "https://x.example/a", None, None).unwrap());
445        let members: Vec<&String> = header["jwk"].as_object().unwrap().keys().collect();
446        assert_eq!(members.len(), 4, "unexpected members: {members:?}");
447    }
448
449    // ── claims ───────────────────────────────────────────────────────────────
450
451    /// RFC 9449 §4.2: `htu` is the request URI WITHOUT query or fragment.
452    #[test]
453    fn htu_strips_the_query_and_fragment() {
454        let key = SigningKey::generate(KID);
455        for (url, want) in [
456            (
457                "https://bsky.social/oauth/token?a=1&b=2",
458                "https://bsky.social/oauth/token",
459            ),
460            (
461                "https://bsky.social/xrpc/get#frag",
462                "https://bsky.social/xrpc/get",
463            ),
464            ("https://bsky.social/x?q=1#f", "https://bsky.social/x"),
465            ("https://bsky.social/plain", "https://bsky.social/plain"),
466        ] {
467            let (_, claims) = parts(&proof(&key, "GET", url, None, None).unwrap());
468            assert_eq!(claims["htu"], want, "for {url}");
469        }
470    }
471
472    #[test]
473    fn htm_carries_the_method_and_iat_is_current() {
474        let key = SigningKey::generate(KID);
475        let (_, claims) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
476        assert_eq!(claims["htm"], "POST");
477        let now = chrono::Utc::now().timestamp();
478        let iat = claims["iat"].as_i64().unwrap();
479        assert!((now - iat).abs() < 5, "iat {iat} is not close to {now}");
480    }
481
482    /// `jti` is the server's replay defence; it must be unpredictable and fresh
483    /// per proof. Note this also means proofs are NOT deterministic even though
484    /// the underlying ES256 signature is.
485    #[test]
486    fn every_proof_gets_a_fresh_unpredictable_jti() {
487        let key = SigningKey::generate(KID);
488        let mut seen = std::collections::HashSet::new();
489        for _ in 0..64 {
490            let (_, claims) =
491                parts(&proof(&key, "GET", "https://x.example/a", None, None).unwrap());
492            let jti = claims["jti"].as_str().unwrap().to_string();
493            assert!(jti.len() >= 22, "jti too short to be unguessable: {jti}");
494            assert!(seen.insert(jti), "jti repeated");
495        }
496    }
497
498    // ── access-token binding ─────────────────────────────────────────────────
499
500    /// When a request carries an access token, the proof must bind to it with
501    /// `ath` = base64url(SHA-256(token)). Omitting it on a resource request
502    /// lets a captured proof be replayed with a different token.
503    #[test]
504    fn ath_is_the_base64url_sha256_of_the_access_token_when_present() {
505        let key = SigningKey::generate(KID);
506        let token = "an-access-token";
507        let (_, claims) =
508            parts(&proof(&key, "GET", "https://x.example/a", Some(token), None).unwrap());
509
510        let want = URL_SAFE_NO_PAD
511            .encode(ring::digest::digest(&ring::digest::SHA256, token.as_bytes()).as_ref());
512        assert_eq!(claims["ath"], want);
513    }
514
515    #[test]
516    fn ath_is_absent_when_there_is_no_access_token() {
517        let key = SigningKey::generate(KID);
518        let (_, claims) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
519        assert!(claims.get("ath").is_none());
520    }
521
522    #[test]
523    fn the_nonce_claim_appears_only_when_the_server_supplied_one() {
524        let key = SigningKey::generate(KID);
525        let (_, without) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
526        assert!(without.get("nonce").is_none());
527
528        let (_, with) =
529            parts(&proof(&key, "POST", "https://x.example/t", None, Some("srv-nonce")).unwrap());
530        assert_eq!(with["nonce"], "srv-nonce");
531    }
532
533    #[test]
534    fn a_proof_verifies_against_its_own_key() {
535        let key = SigningKey::generate(KID);
536        let p = proof(&key, "POST", "https://x.example/t", None, None).unwrap();
537        assert!(verify(&key, &p).is_ok());
538        assert!(verify(&SigningKey::generate(KID), &p).is_err());
539    }
540
541    #[test]
542    fn an_unparseable_target_url_is_an_error_not_a_panic() {
543        let key = SigningKey::generate(KID);
544        assert!(proof(&key, "GET", "not a url", None, None).is_err());
545        assert!(proof(&key, "GET", "", None, None).is_err());
546    }
547
548    /// A non-http(s) target has no business in a DPoP proof, and the request it
549    /// describes could not go through the SSRF guard anyway.
550    #[test]
551    fn a_non_http_scheme_is_rejected() {
552        let key = SigningKey::generate(KID);
553        for url in [
554            "file:///etc/passwd",
555            "ftp://x.example/a",
556            "data:text/plain,x",
557        ] {
558            assert!(
559                proof(&key, "GET", url, None, None).is_err(),
560                "allowed {url}"
561            );
562        }
563    }
564
565    /// `htm` is an HTTP method token. An empty or non-token method would be
566    /// signed verbatim into a claim the server compares literally.
567    #[test]
568    fn a_non_token_method_is_rejected() {
569        let key = SigningKey::generate(KID);
570        for method in ["", "gé t", "GET POST", "GET\n", "GE\tT"] {
571            assert!(
572                proof(&key, method, "https://x.example/a", None, None).is_err(),
573                "allowed method {method:?}"
574            );
575        }
576    }
577
578    /// **Credentials must not be signed into a transmitted claim.** RFC 9110
579    /// target URIs have no userinfo component, so RFC 9449 §4.3's comparison
580    /// could never match one either — this is both a leak and a conformance break.
581    #[test]
582    fn htu_strips_userinfo() {
583        let key = SigningKey::generate(KID);
584        let (_, claims) = parts(
585            &proof(
586                &key,
587                "POST",
588                "https://Alice:s3cr3t@PDS.Example.COM:443/oauth/token?a=1#f",
589                None,
590                None,
591            )
592            .unwrap(),
593        );
594        let htu = claims["htu"].as_str().unwrap();
595        assert_eq!(htu, "https://pds.example.com/oauth/token");
596        assert!(!htu.contains("s3cr3t"), "password leaked into htu: {htu}");
597        assert!(!htu.contains("Alice"), "username leaked into htu: {htu}");
598    }
599
600    /// The signature is worthless if the key advertised in the header is not the
601    /// key that signed (RFC 9449 §4.3 step 6). Nothing else pins this.
602    #[test]
603    fn the_embedded_jwk_is_the_key_that_actually_signed() {
604        let key = SigningKey::generate(KID);
605        let p = proof(&key, "POST", "https://x.example/t", None, None).unwrap();
606        let (header, _) = parts(&p);
607        let embedded = serde_json::to_string(&header["jwk"]).unwrap();
608        assert_eq!(
609            SigningKey::public_thumbprint_of(&embedded).unwrap(),
610            key.thumbprint().unwrap(),
611            "the embedded jwk is not the signing key"
612        );
613    }
614
615    #[test]
616    fn htm_is_upcased() {
617        let key = SigningKey::generate(KID);
618        for (given, want) in [("get", "GET"), ("Post", "POST"), ("delete", "DELETE")] {
619            let (_, claims) =
620                parts(&proof(&key, given, "https://x.example/a", None, None).unwrap());
621            assert_eq!(claims["htm"], want, "for {given}");
622        }
623    }
624
625    // ── nonce negotiation ────────────────────────────────────────────────────
626
627    /// Shorthand for the RESOURCE-server signalling path, which is what the
628    /// `WWW-Authenticate` parser tests below exercise.
629    fn rs(www_authenticate: &str, nonce: Option<&str>) -> Option<String> {
630        nonce_challenge(
631            Endpoint::ResourceServer,
632            401,
633            Some(www_authenticate),
634            b"",
635            nonce,
636        )
637    }
638
639    /// **RFC 9449 signals a nonce two different ways, and the authorization
640    /// server's way is the one this client hits first.**
641    ///
642    /// §8 (AS): `400` with an RFC 6749 §5.2 JSON body `{"error":"use_dpop_nonce"}`
643    /// and NO `WWW-Authenticate` at all. §9 (RS): `401` with the header.
644    ///
645    /// PAR, token exchange and refresh all go to the authorization server, so an
646    /// implementation that reads only `WWW-Authenticate` never sees the
647    /// challenge and the token exchange fails permanently. Cross-checked against
648    /// `@atproto/oauth-client`'s `isUseDpopNonceError`, which branches on
649    /// exactly this.
650    #[test]
651    fn the_authorization_server_signals_with_a_400_and_a_json_body() {
652        assert_eq!(
653            nonce_challenge(
654                Endpoint::AuthorizationServer,
655                400,
656                None,
657                br#"{"error":"use_dpop_nonce"}"#,
658                Some("n1"),
659            )
660            .as_deref(),
661            Some("n1")
662        );
663        // With the description RFC 6749 §5.2 allows alongside it.
664        assert_eq!(
665            nonce_challenge(
666                Endpoint::AuthorizationServer,
667                400,
668                None,
669                br#"{"error":"use_dpop_nonce","error_description":"nonce required"}"#,
670                Some("n1"),
671            )
672            .as_deref(),
673            Some("n1")
674        );
675    }
676
677    #[test]
678    fn the_authorization_server_path_ignores_other_errors_and_statuses() {
679        for (status, body) in [
680            (400u16, &br#"{"error":"invalid_grant"}"#[..]),
681            (400, br#"{"error":"invalid_dpop_proof"}"#),
682            (400, b"not json"),
683            (400, b""),
684            (400, br#"{"error":123}"#),
685            (400, br#"[]"#),
686            // Right error, wrong status.
687            (401, br#"{"error":"use_dpop_nonce"}"#),
688            (200, br#"{"error":"use_dpop_nonce"}"#),
689            (500, br#"{"error":"use_dpop_nonce"}"#),
690        ] {
691            assert!(
692                nonce_challenge(
693                    Endpoint::AuthorizationServer,
694                    status,
695                    None,
696                    body,
697                    Some("n1")
698                )
699                .is_none(),
700                "acted on status {status} body {:?}",
701                String::from_utf8_lossy(body)
702            );
703        }
704    }
705
706    /// The two paths must not bleed into each other: the AS path does not read
707    /// `WWW-Authenticate`, and the RS path does not read the body.
708    #[test]
709    fn the_two_signalling_paths_are_independent() {
710        // AS status/body are wrong, but a resource-server-shaped header is set.
711        assert!(nonce_challenge(
712            Endpoint::AuthorizationServer,
713            400,
714            Some(r#"DPoP error="use_dpop_nonce""#),
715            br#"{"error":"invalid_grant"}"#,
716            Some("n1"),
717        )
718        .is_none());
719
720        // RS header is absent, but an AS-shaped body is present.
721        assert!(nonce_challenge(
722            Endpoint::ResourceServer,
723            401,
724            None,
725            br#"{"error":"use_dpop_nonce"}"#,
726            Some("n1"),
727        )
728        .is_none());
729    }
730
731    /// The resource-server path requires 401 specifically.
732    #[test]
733    fn the_resource_server_path_requires_a_401() {
734        for status in [400u16, 403, 200, 500] {
735            assert!(
736                nonce_challenge(
737                    Endpoint::ResourceServer,
738                    status,
739                    Some(r#"DPoP error="use_dpop_nonce""#),
740                    b"",
741                    Some("n1"),
742                )
743                .is_none(),
744                "acted on status {status}"
745            );
746        }
747    }
748
749    /// **Differential against the reference client.** Each expectation below is
750    /// what `@atproto/oauth-client`'s `isUseDpopNonceError` returns for the same
751    /// response, captured by running that function verbatim out of
752    /// `oauth-sidecar/node_modules/@atproto/oauth-client/dist/fetch-dpop.js`.
753    ///
754    /// This is the check that would have caught the original defect: the
755    /// implementation read only `WWW-Authenticate`, so every authorization-server
756    /// case here (the first nine) was wrong, and no amount of reading the parser
757    /// would have shown it.
758    #[test]
759    fn agrees_with_the_reference_client_on_nonce_detection() {
760        use Endpoint::{AuthorizationServer as As, ResourceServer as Rs};
761        /// (endpoint, status, WWW-Authenticate, body, reference verdict)
762        type Case = (Endpoint, u16, Option<&'static str>, &'static [u8], bool);
763        let cases: &[Case] = &[
764            (As, 400, None, br#"{"error":"use_dpop_nonce"}"#, true),
765            (
766                As,
767                400,
768                None,
769                br#"{"error":"use_dpop_nonce","error_description":"x"}"#,
770                true,
771            ),
772            (As, 400, None, br#"{"error":"invalid_grant"}"#, false),
773            (As, 400, None, br#"{"error":"invalid_dpop_proof"}"#, false),
774            (As, 400, None, b"not json", false),
775            (As, 400, None, b"", false),
776            (As, 401, None, br#"{"error":"use_dpop_nonce"}"#, false),
777            (As, 200, None, br#"{"error":"use_dpop_nonce"}"#, false),
778            (
779                As,
780                400,
781                Some(r#"DPoP error="use_dpop_nonce""#),
782                br#"{"error":"invalid_grant"}"#,
783                false,
784            ),
785            (Rs, 401, Some(r#"DPoP error="use_dpop_nonce""#), b"", true),
786            (
787                Rs,
788                401,
789                Some(r#"DPoP algs="ES256", error="use_dpop_nonce""#),
790                b"",
791                true,
792            ),
793            (
794                Rs,
795                401,
796                Some(r#"Bearer error="use_dpop_nonce""#),
797                b"",
798                false,
799            ),
800            (
801                Rs,
802                401,
803                Some(r#"DPoP error="invalid_dpop_proof""#),
804                b"",
805                false,
806            ),
807            (Rs, 400, Some(r#"DPoP error="use_dpop_nonce""#), b"", false),
808            (Rs, 401, None, br#"{"error":"use_dpop_nonce"}"#, false),
809        ];
810
811        for (i, (endpoint, status, header, body, expected)) in cases.iter().enumerate() {
812            let got = nonce_challenge(*endpoint, *status, *header, body, Some("n1")).is_some();
813            assert_eq!(
814                got, *expected,
815                "case {i} ({endpoint:?}, {status}, {header:?}) disagrees with the reference"
816            );
817        }
818    }
819
820    /// An oversized body is not parsed. A `use_dpop_nonce` error is a few dozen
821    /// bytes; anything large is a different response, and parsing it would let a
822    /// server spend our memory on every failed request.
823    #[test]
824    fn an_oversized_error_body_is_not_parsed() {
825        let mut body = br#"{"error":"use_dpop_nonce","pad":""#.to_vec();
826        body.extend(std::iter::repeat_n(b'a', 32 * 1024));
827        body.extend(br#""}"#);
828        assert!(
829            nonce_challenge(Endpoint::AuthorizationServer, 400, None, &body, Some("n1")).is_none()
830        );
831    }
832
833    /// A `use_dpop_nonce` challenge is normal operation — the server is telling
834    /// us to retry with its nonce, not reporting a failure.
835    #[test]
836    fn a_dpop_nonce_challenge_yields_the_nonce_to_retry_with() {
837        for header in [
838            r#"DPoP error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#,
839            r#"DPoP algs="ES256", error="use_dpop_nonce""#,
840            r#"dpop error="use_dpop_nonce""#,
841            // RFC 7235 permits bad whitespace around `=`.
842            r#"DPoP algs="ES256", error = "use_dpop_nonce""#,
843            r#"DPoP error	=	"use_dpop_nonce""#,
844        ] {
845            assert_eq!(
846                rs(header, Some("n1")).as_deref(),
847                Some("n1"),
848                "should match: {header}"
849            );
850        }
851    }
852
853    /// **RFC 9449 §7.2.** A resource server supporting both schemes returns both
854    /// challenges in ONE header. Attributing the wrong scheme's `error` either
855    /// misses the nonce handshake entirely (every request then fails forever) or
856    /// invents one that was never asked for.
857    #[test]
858    fn challenges_are_matched_to_their_own_scheme() {
859        // The DPoP challenge is present but not first: must still be found.
860        assert_eq!(
861            rs(
862                r#"Bearer error="invalid_token", DPoP error="use_dpop_nonce", algs="ES256""#,
863                Some("n1")
864            )
865            .as_deref(),
866            Some("n1")
867        );
868        // A `use_dpop_nonce` on a NON-DPoP scheme is not ours to act on.
869        assert!(rs(r#"Bearer error="use_dpop_nonce""#, Some("n1")).is_none());
870        assert!(rs(
871            r#"Basic realm="r", Bearer error="use_dpop_nonce""#,
872            Some("n1")
873        )
874        .is_none());
875        // Params after a scheme belong to that scheme, not the previous one.
876        assert!(rs(
877            r#"DPoP algs="ES256", Bearer error="use_dpop_nonce""#,
878            Some("n1")
879        )
880        .is_none());
881    }
882
883    /// **RFC 9449 §7.1**: `invalid_dpop_proof` means the proof was rejected on
884    /// its merits (bad `htu`, clock skew, unacceptable `alg`) — a real failure,
885    /// not a request to retry. Retrying burns the one attempt and reports the
886    /// wrong cause.
887    #[test]
888    fn invalid_dpop_proof_is_a_failure_not_a_rs() {
889        assert!(rs(r#"DPoP error="invalid_dpop_proof""#, Some("n1")).is_none());
890    }
891
892    /// Without a nonce there is nothing to retry WITH; retrying would replay the
893    /// same proof and mask the real error.
894    #[test]
895    fn no_retry_without_a_supplied_nonce() {
896        assert!(rs(r#"DPoP error="use_dpop_nonce""#, None).is_none());
897        assert!(rs(r#"DPoP error="use_dpop_nonce""#, Some("")).is_none());
898    }
899
900    #[test]
901    fn unrelated_challenges_do_not_trigger_a_retry() {
902        for header in [
903            r#"Bearer error="invalid_token""#,
904            r#"DPoP error="invalid_grant""#,
905            r#"DPoP algs="ES256""#,
906            "",
907            "garbage",
908            // Must not fire on a mere substring in an unrelated field.
909            r#"DPoP error="x", error_description="do not use_dpop_nonce here""#,
910        ] {
911            assert!(
912                rs(header, Some("n1")).is_none(),
913                "should not match: {header}"
914            );
915        }
916    }
917
918    /// **A `token68` challenge must not poison the rest of the header.**
919    /// RFC 9110 §11.6.1 allows `challenge = auth-scheme [ 1*SP ( token68 /
920    /// #auth-param ) ]`, and `token68` ends with `*"="` — so `Negotiate YII=`
921    /// parses as a parameter with an empty value. Discarding the whole header
922    /// over that would silently drop a valid DPoP nonce challenge sitting
923    /// beside it, which is the same permanent-failure shape as reading the
924    /// wrong scheme's error.
925    #[test]
926    fn a_token68_challenge_does_not_discard_the_other_challenges() {
927        for header in [
928            r#"DPoP error="use_dpop_nonce", Negotiate YII="#,
929            r#"Negotiate YII=, DPoP error="use_dpop_nonce""#,
930            r#"Basic realm=x, Negotiate abc==, DPoP error="use_dpop_nonce""#,
931            // A parameter we cannot read must not sink its own challenge either.
932            r#"DPoP foo=, error="use_dpop_nonce""#,
933            r#"DPoP error="use_dpop_nonce", bad="#,
934        ] {
935            assert_eq!(
936                rs(header, Some("n1")).as_deref(),
937                Some("n1"),
938                "token68 or unreadable param discarded the header: {header}"
939            );
940        }
941    }
942
943    /// Skipping an unreadable segment can only ever cause a FALSE NEGATIVE.
944    /// An unbalanced quote is different in kind — it can manufacture a
945    /// challenge that was never sent — so that still fails closed.
946    #[test]
947    fn a_token68_challenge_cannot_manufacture_a_rs() {
948        for header in [
949            r#"Negotiate YII="#,
950            r#"Basic realm=x, Negotiate abc=="#,
951            r#"Bearer error="use_dpop_nonce", Negotiate YII="#,
952        ] {
953            assert!(
954                rs(header, Some("n1")).is_none(),
955                "invented a challenge from: {header}"
956            );
957        }
958    }
959
960    /// A malformed header must fail CLOSED. An unbalanced quote un-protects the
961    /// commas that follow, letting server free text splice in a challenge that
962    /// was never sent.
963    #[test]
964    fn a_malformed_header_does_not_trigger_a_retry() {
965        for header in [
966            r#"DPoP error_description="he said "x, junk error="use_dpop_nonce""#,
967            r#"DPoP realm="r", error_description="unbalanced " here, y error="use_dpop_nonce""#,
968            r#"DPoP error="use_dpop_nonce"#,
969            r#"DPoP error=""use_dpop_nonce"""#,
970        ] {
971            assert!(
972                rs(header, Some("n1")).is_none(),
973                "malformed header was acted on: {header}"
974            );
975        }
976    }
977
978    /// A quoted value may legally contain a comma and escaped quotes; neither
979    /// may split a segment or corrupt the value.
980    #[test]
981    fn quoted_values_may_contain_commas_and_escaped_quotes() {
982        assert_eq!(
983            rs(
984                r#"DPoP error_description="one, two, three", error="use_dpop_nonce""#,
985                Some("n1")
986            )
987            .as_deref(),
988            Some("n1")
989        );
990        assert_eq!(
991            rs(
992                r#"DPoP error_description="he said \"hi\", ok", error="use_dpop_nonce""#,
993                Some("n1")
994            )
995            .as_deref(),
996            Some("n1")
997        );
998    }
999
1000    /// An unquoted token value is legal per RFC 7235.
1001    #[test]
1002    fn an_unquoted_token_value_is_accepted() {
1003        assert_eq!(
1004            rs(r#"DPoP error=use_dpop_nonce"#, Some("n1")).as_deref(),
1005            Some("n1")
1006        );
1007    }
1008
1009    /// Pathological input must terminate, not hang.
1010    #[test]
1011    fn a_pathological_header_terminates() {
1012        let big = format!("DPoP error=\"{}", "a,".repeat(20_000));
1013        assert!(rs(&big, Some("n1")).is_none());
1014        let quotes = "\"".repeat(20_000);
1015        assert!(rs(&quotes, Some("n1")).is_none());
1016    }
1017}