Repository navigation
Commit f983f21
authored
Resend a 401 that refuses only the DPoP proof, with no refresh (#268)
* Test: a 401 refusing the DPoP proof or asking a nonce is resent once with no refresh (red)
Six of the seven cases fail on main: a refused proof refreshes the token,
a redirected write whose hop's proof was refused is sent again, and no
nonce HEY hands out ever reaches the strategy. The seventh (a nonce
another origin handed out never signs for this one) passes vacuously
until nonces are read, and guards the fix.
HttpRequestBuilder.dpopNonce is added here so the cases compile; nothing
sets it yet.
* Resend a 401 that refuses only the DPoP proof, with no refresh
A DPoP-bound token signs each request with a proof of its own. When HEY
refused the proof and not the token -- its time or jti (RFC 9449, 7.1,
invalid_dpop_proof), or a nonce it did not name (9, use_dpop_nonce) --
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.
Now such a 401 is sent once more with a fresh proof, and the token is
not refreshed; a second 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 as
HttpRequestBuilder.dpopNonce while it signs; 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.
The challenge's error is read by bearerError, quoted or bare, never from
error_description.
Verified: the seven new CredentialRefreshTest cases (six red before
this), a parser case, and make kt-check kt-check-drift.
Not done: no conformance case. DPoP signing exists only in Kotlin; the
other runners have no strategy hook to hand a nonce to, so a shared case
would hold them to a surface they do not have.
* Hear a proof refusal in any challenge header, and say a redirected write may be made
Review findings:
- A server that takes Bearer and DPoP may send each challenge in a
WWW-Authenticate header of its own. Ktor's Headers.get answers the
first only, so `Bearer realm="hey"` hid a second header's
`DPoP error="invalid_dpop_proof"` and the client refreshed a good
token. Every value is read now. Shown red on the first-only read:
refreshes expected 0, was 1.
- A 302 or 303 answers a write that went through; a 307 or 308 repeats
it at the hop, where a 401 refused it. The client holds both back,
which is safe for either; the comment and README now say "may already
be made" rather than "went through".
- Two cases the suite left open: a POST whose own first send had its
proof refused is resent (flipping the guard's || to && passed before),
and a read whose redirect hop had its proof refused is read again.
* Read every challenge in a WWW-Authenticate field, and no parameter inside a quoted value
Review: the error was taken from the first error= the field held, by a
regex that did not know challenges or quoted strings. Two cases read
wrong:
- `Bearer error="invalid_token", DPoP error="use_dpop_nonce"`, one
field listing both challenges (RFC 9110, 11.6.1), was heard for the
Bearer error, and refreshed a good token instead of answering the
nonce.
- `error_description="prior error=invalid_dpop_proof", error=...`
found an error inside the quoted description and suppressed a refresh
the token needed.
With the second-header case before them, these are three ways one regex
misread the header, so it is replaced rather than patched again:
challengeErrors reads the field as RFC 9110 auth-params, each quoted
value whole, and answers every error it names. A 401 is a proof refusal
when any challenge in any field names one.
Shown red on the old reading: the combined field refreshed (expected 0,
was 1); the quoted description did not (expected 1, was 0).
* Take a 401 for a refused proof only when the request sent one
Review: reading every challenge in a field let a Bearer request answered
with `Bearer error="invalid_token", DPoP error="use_dpop_nonce"` be
taken for a refused proof. It was resent with the same stale token and
never refreshed. A proof refusal now needs the request to have gone out
under a DPoP credential (Authorization: DPoP ...); a Bearer request
meets any 401 with the refresh it always had. Shown red without the
gate: the resend carried the stale token again.
* Resend a proof refused after a redirect only when no hop changed the method
What was wrong: the proof-refusal resend keyed on "redirected" and on the
operation being idempotent. A DELETE answered with a 303 then had its GET
hop's refused proof resend the DELETE, which answers a deletion already
made with a 404; and a POST sent on by a 307 or 308, refused at the
destination and so not made, surfaced the 401 instead of going again.
What it is now: the redirect loop records whether any hop changed the
method (a 303, or a 301 or 302 to a POST). Only such a hop answered the
request first sent; a refused proof after it is the caller's, idempotent
or not. A hop that kept the method asks for the same request elsewhere,
and the operation goes again with a fresh proof.
Verified: two new CredentialRefreshTest cases, a 303'd DELETE not resent
and a 307'd POST resent, fail against the old condition and pass now;
make kt-check passes.
Rejected: limiting the resend to GET operations, as one review
suggested: it would also refuse the 307/308 write the other asked for.
* Hold a refused proof after a redirect to the rule the refresh path keeps
The previous commit resent a proof refused after a redirect only when
no hop changed the method. That disagreed with the refresh path, which
resends a redirected operation when it is idempotent (as main always
has) and holds back a redirected non-idempotent write. Under it, a PUT
answered with a 303 whose hop's proof was refused came back a 401 for a
write that succeeded, where a refreshed token resends it and succeeds.
One rule now serves both paths: resend when the operation is
idempotent or no redirect was followed. The hop-method tracking goes;
it only ever decided the 307/308 case, and HEY answers no write with a
307 or 308: its write redirects are all 302. Repeating an idempotent
write is safe (RFC 9110, 9.2.2), though a DELETE already made may then
answer 404.
The previous commit's two cases stay, with the rule's expectations: a
303'd DELETE whose hop's proof was refused is sent again; a 307'd POST
whose hop's proof was refused is the caller's 401, not resent. The
README says non-idempotent.
Verified: CredentialRefreshTest 32/32, make kt-check.
* Say a redirect can answer a write that went through, not that it does
Outside review: some of HEY's 302s answer a write that was not made (a
shared topic's trash asks for confirmation; a refused send redirects).
The comments, READMEs and the conformance case said a 302 or 303
answers a write that went through, or that the write has been made. They
now say it can: the write may already have taken effect, and the
redirect does not say whether it did. That uncertainty is the reason a
non-idempotent write is held back; behaviour is unchanged.1 parent 1d989b7 commit f983f21
4 files changed
Lines changed: 385 additions & 1 deletion
File tree
- kotlin
- sdk/src
- commonMain/kotlin/com/basecamp/hey
- commonTest/kotlin/com/basecamp/hey
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
35 | 44 | | |
36 | 45 | | |
37 | 46 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
| |||
90 | 91 | | |
91 | 92 | | |
92 | 93 | | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
263 | 275 | | |
264 | 276 | | |
265 | 277 | | |
| |||
564 | 576 | | |
565 | 577 | | |
566 | 578 | | |
| 579 | + | |
567 | 580 | | |
568 | 581 | | |
569 | 582 | | |
| |||
608 | 621 | | |
609 | 622 | | |
610 | 623 | | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
611 | 646 | | |
612 | 647 | | |
613 | | - | |
| 648 | + | |
614 | 649 | | |
615 | 650 | | |
616 | 651 | | |
| |||
802 | 837 | | |
803 | 838 | | |
804 | 839 | | |
| 840 | + | |
| 841 | + | |
805 | 842 | | |
806 | 843 | | |
807 | 844 | | |
| |||
893 | 930 | | |
894 | 931 | | |
895 | 932 | | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
896 | 937 | | |
897 | 938 | | |
898 | 939 | | |
| |||
1223 | 1264 | | |
1224 | 1265 | | |
1225 | 1266 | | |
| 1267 | + | |
| 1268 | + | |
| 1269 | + | |
| 1270 | + | |
| 1271 | + | |
0 commit comments