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

@ -819,8 +819,8 @@ async function authenticateRevocationClient(
// turn into a 500 — so a bad credential would be reported as a server
// fault rather than as the `invalid_client` 401 this endpoint means.
try {
clientId = decodeURIComponent(decoded.slice(0, separator));
clientSecret = decodeURIComponent(decoded.slice(separator + 1));
clientId = formUrlDecode(decoded.slice(0, separator));
clientSecret = formUrlDecode(decoded.slice(separator + 1));
} catch {
return null;
}
@ -841,6 +841,35 @@ async function authenticateRevocationClient(
return ok ? clientId : null;
}
/**
* Decode one half of a Basic credential the way a compliant authorization
* server does — the `application/x-www-form-urlencoded` algorithm RFC 6749
* §2.3.1 names, not `decodeURIComponent`.
*
* The distinction is the whole point of this helper, and #2222 is what it cost
* to learn: this fixture used to decode with `decodeURIComponent`, the exact
* inverse of the encoder it was testing. The round trip then succeeded for
* **every** input — so no test here could have failed on an encoding mistake,
* and the suite's apparent coverage of client authentication was really a
* statement that the encoder is self-consistent. (The encoder was in fact
* fine; that was established by reasoning and a sweep over the code-point
* space, not by anything this fixture asserted, which is the gap being
* closed.)
*
* A form-urldecoder reads a bare `+` as a space, so that substitution happens
* **before** percent-decoding; doing it after would turn a legitimate escaped
* `%2B` into a space too. `%20` still decodes to a space, which is why the
* Inspector's `encodeURIComponent` output — which escapes `+` and spaces both,
* and never emits a bare `+` — round-trips through this decoder unchanged.
*
* `decodeURIComponent` remains the right primitive for the percent half, and it
* still throws on a malformed escape — which the caller catches, keeping a bad
* credential a 401 rather than an Express 500.
*/
function formUrlDecode(value: string): string {
return decodeURIComponent(value.replace(/\+/g, "%20"));
}
/**
* Set up Dynamic Client Registration endpoint
*/