Resend a 401 that refuses only the DPoP proof, with no refresh

Merged
JE
4d ago
+385-1

A DPoP-bound token signs every request with a proof of its own (RFC 9449). When HEY refuses the proof rather than the token, the Kotlin client spent a refresh on a token that was fine, and had no way to hand the strategy the nonce HEY asked for.

What changes (Kotlin)

  • A 401 whose challenge names invalid_dpop_proof (the proof's time or jti, RFC 9449 §7.1) or use_dpop_nonce (§9) is sent once more with a fresh proof and never refreshes. A second such refusal is the caller's.
  • The client keeps the DPoP-Nonce each origin (scheme, host, port) hands out with any answer, a redirect's included, and exposes it to the strategy while it signs as HttpRequestBuilder.dpopNonce. A nonce is never offered to another origin.
  • A write HEY answered with a redirect is not sent again for its hop's refused proof: the write went through.
  • bearerError reads a challenge's error, quoted or bare, never from error_description.
  • kotlin/README.md says what a DPoP-signing strategy does with dpopNonce.

Red first

The first commit adds the tests (and the dpopNonce accessor, so they compile) on unfixed code. 6 of the 7 cases failed:

caseon main
proof refused → resent once, no refreshrefreshed (expected 0, was 1)
proof refused twice → surfaced, no refreshrefreshed
redirected write, hop's proof refused → not resentwrite resent and succeeded
use_dpop_nonce → resent with the server's nonce[nonce=null, nonce=null]
a nonce from any answer signs the next request[nonce=null, nonce=null]
a same-origin redirect's nonce signs its hop[nonce=null, nonce=null]
another origin's nonce never signs for this onepassed (vacuous until nonces are read; guards the fix)

Verified

  • CredentialRefreshTest: 23/23 after the fix, plus a parser case.
  • make kt-check kt-check-drift, and Go, Rust and Kotlin conformance (219/219 each), TypeScript conformance 215/215.
  • make check otherwise fails on two things main already fails on: cargo deny's advisory for webpki-root-certs 1.0.9 (main's Rust Tests job is red for it), and, locally only, Swift 6.3's warnings-as-errors in StalledSuccessTransportTests.swift (CI's Swift 6.0 and macOS jobs are green). Neither touches this change.

Not done

No conformance case. DPoP signing exists only in the Kotlin client; the other runners have no strategy hook to hand a nonce to, so a shared case would test them against a surface they don't have.


Summary by cubic

Resends a 401 that refuses only the DPoP proof (not the token) once with a fresh proof and never refreshes the token, and hands the server's DPoP nonce to the signing strategy.

  • Tracks the DPoP-Nonce each origin hands out with any answer, including a redirect's, and exposes it as HttpRequestBuilder.dpopNonce while signing; a nonce never crosses origins.
  • A challenge naming invalid_dpop_proof or use_dpop_nonce triggers one resend with a fresh proof; a second refusal surfaces to the caller. The refusal is heard in any WWW-Authenticate header, and only when the request went out under a DPoP credential; a Bearer request refreshes as for any 401.
  • A refused proof after a redirect follows the refresh path's rule: the operation is resent when it is idempotent or no redirect was followed; a non-idempotent write answered with a redirect is not, since a redirect does not say whether the write went through.
  • challengeErrors reads every challenge in a WWW-Authenticate field, its error quoted or bare with quoted values read whole so no parameter inside one is mistaken for a challenge, and never from error_description.
  • Adds CredentialRefreshTest cases, several failing before the fix, and documents the behavior in kotlin/README.md; no conformance case since only the Kotlin client has a DPoP-signing strategy hook.