Resend a 401 that refuses only the DPoP proof, with no refresh
Merged
JE
4d ago+385+385+012345678901234567890123456789-1-1-0123456789
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) oruse_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-Nonceeach origin (scheme, host, port) hands out with any answer, a redirect's included, and exposes it to the strategy while it signs asHttpRequestBuilder.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.
bearerErrorreads a challenge'serror, quoted or bare, never fromerror_description.kotlin/README.mdsays what a DPoP-signing strategy does withdpopNonce.
Red first
The first commit adds the tests (and the dpopNonce accessor, so they compile) on unfixed code. 6 of the 7 cases failed:
| case | on main |
|---|---|
| proof refused → resent once, no refresh | refreshed (expected 0, was 1) |
| proof refused twice → surfaced, no refresh | refreshed |
| redirected write, hop's proof refused → not resent | write 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 one | passed (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 checkotherwise fails on two things main already fails on: cargo deny's advisory forwebpki-root-certs1.0.9 (main'sRust Testsjob is red for it), and, locally only, Swift 6.3's warnings-as-errors inStalledSuccessTransportTests.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-Nonceeach origin hands out with any answer, including a redirect's, and exposes it asHttpRequestBuilder.dpopNoncewhile signing; a nonce never crosses origins. - A challenge naming
invalid_dpop_prooforuse_dpop_noncetriggers one resend with a fresh proof; a second refusal surfaces to the caller. The refusal is heard in anyWWW-Authenticateheader, 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.
challengeErrorsreads every challenge in aWWW-Authenticatefield, itserrorquoted or bare with quoted values read whole so no parameter inside one is mistaken for a challenge, and never fromerror_description.- Adds
CredentialRefreshTestcases, several failing before the fix, and documents the behavior inkotlin/README.md; no conformance case since only the Kotlin client has a DPoP-signing strategy hook.