test(auth): make the revocation fixture a real form-urldecoder

#2222 reported that `core/auth/revocation.ts` breaks a client secret
containing `+`, on the basis that `encodeURIComponent` leaves `+` bare.
It does not — it escapes it as `%2B`. The characters it leaves bare are
exactly `!'()*-._~` and alphanumerics, every one of which a form-
urldecoder passes through unchanged, so its output decodes identically
under both algorithms; verified over every code point up to U+2FFF.

Switching to `URLSearchParams` would be the literal algorithm §2.3.1
names and a small regression: it encodes a space as `+`, which a lenient
server decoding with `decodeURIComponent` alone reads as a literal `+`,
while `%20` is understood by both. The encoder is therefore unchanged
and the reasoning is recorded on it.

The report's second half stands and is the real defect: the fixture
decoded with `decodeURIComponent`, the encoder's own inverse, so the
round trip succeeded for every input by construction and no test could
have failed on an encoding mistake. `/oauth/revoke` now runs a genuine
RFC 6749 Appendix B decode, with unit cases for `+`, a space, `:` and
`%` (asserted on the wire and through an independent decoder), an
end-to-end client whose secret is base64 with a `+` in it, and a guard
that an unencoded credential is refused — the case that separates the
two decoders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhVaufrEZAfFAXcg6456hK
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-04 19:29:02 -04:00
commit 403352357b

View file

@ -163,6 +163,26 @@ export function buildRevocationRequest(params: RevocationRequestParams): {
// there is no precedent to match, and the raw form is ambiguous for a
// client id containing `:` and makes `btoa` throw outright on a
// non-Latin-1 secret. Encoding is what the server decodes.
//
// ⚠️ `encodeURIComponent` is **not** the form-urlencoded algorithm §2.3.1
// names (RFC 6749 Appendix B), and #2222 was filed on the assumption that
// the difference breaks a secret containing `+`. It does not, and the
// reasoning is worth keeping because the next reader will have the same
// doubt: `encodeURIComponent` **escapes** `+` as `%2B`. The characters it
// leaves bare are exactly `!'()*-._~` plus alphanumerics, and a
// form-urldecoder passes every one of them through unchanged. It can
// therefore never emit the one character the two algorithms disagree
// about, so its output decodes identically under both — verified over
// every code point up to U+2FFF, and pinned by the round-trip cases in
// `revocation.test.ts`.
//
// Switching to `URLSearchParams` would be the literal algorithm and a
// small *regression*: it encodes a space as `+`, which a compliant server
// reads back as a space but a lenient one — decoding with
// `decodeURIComponent` alone, having never implemented the `+` rule —
// reads as a literal `+`. `%20` is understood by both. So the encoding
// here is the one that survives either server, which matters more than
// matching the spec's wording for a header no server sees twice.
const credentials = `${encodeURIComponent(client.client_id)}:${encodeURIComponent(client.client_secret)}`;
headers.Authorization = `Basic ${base64Encode(credentials)}`;
} else if (method === "client_secret_post" && client.client_secret) {