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

@ -39,6 +39,16 @@ const REDIRECT_URL = "http://localhost:3000/oauth/callback";
/** A second registered client, used to prove tokens are not cross-revocable. */
const OTHER_CLIENT_ID = "test-2144-other";
const OTHER_CLIENT_SECRET = "test-2144-other-secret";
/**
* A third client whose secret carries the character #2222 was filed about — a
* `+`, which the two encoding algorithms treat differently and which base64
* secrets (the kind an authorization server most often hands out) contain
* routinely. The Inspector's encoding turns out to handle it, but nothing here
* demonstrated that before, so this client is what makes the claim testable
* rather than argued.
*/
const PLUS_CLIENT_ID = "test-2222-plus";
const PLUS_CLIENT_SECRET = "aG9sZA+bXk/beer=";
function base64Url(buffer: Buffer): string {
return buffer
@ -71,6 +81,11 @@ describe("OAuth token revocation (RFC 7009)", () => {
clientSecret: OTHER_CLIENT_SECRET,
redirectUris: [REDIRECT_URL],
},
{
clientId: PLUS_CLIENT_ID,
clientSecret: PLUS_CLIENT_SECRET,
redirectUris: [REDIRECT_URL],
},
],
}),
});
@ -93,7 +108,10 @@ describe("OAuth token revocation (RFC 7009)", () => {
}, 30_000);
/** Run a real authorization-code exchange and return the issued tokens. */
async function authorize(): Promise<{
async function authorize(
clientId: string = CLIENT_ID,
clientSecret: string = CLIENT_SECRET,
): Promise<{
access_token: string;
refresh_token: string;
}> {
@ -105,7 +123,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
redirect: "manual",
body: new URLSearchParams({
client_id: CLIENT_ID,
client_id: clientId,
redirect_uri: REDIRECT_URL,
response_type: "code",
scope: "mcp",
@ -125,8 +143,8 @@ describe("OAuth token revocation (RFC 7009)", () => {
grant_type: "authorization_code",
code: code!,
redirect_uri: REDIRECT_URL,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
client_id: clientId,
client_secret: clientSecret,
code_verifier: verifier,
}),
});
@ -146,10 +164,14 @@ describe("OAuth token revocation (RFC 7009)", () => {
return response.status !== 401;
}
async function seededStorage(tokens: {
access_token: string;
refresh_token?: string;
}): Promise<NodeOAuthStorage> {
async function seededStorage(
tokens: {
access_token: string;
refresh_token?: string;
},
clientId: string = CLIENT_ID,
clientSecret: string = CLIENT_SECRET,
): Promise<NodeOAuthStorage> {
const storage = new NodeOAuthStorage(join(storageDir, "oauth.json"));
await storage.clear(serverUrl);
// Issuer-bound and matching the discovered metadata: an unkeyed grant
@ -164,8 +186,8 @@ describe("OAuth token revocation (RFC 7009)", () => {
// server with `oauth.clientId` uses. That is the slot the revocation path
// must read first, or a confidential client sends no authentication at all.
await storage.savePreregisteredClientInformation(serverUrl, {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
client_id: clientId,
client_secret: clientSecret,
});
return storage;
}
@ -269,4 +291,47 @@ describe("OAuth token revocation (RFC 7009)", () => {
status: "revoked",
});
});
// #2222. The fixture used to decode with `decodeURIComponent`, the exact
// inverse of the encoder under test, so the round trip succeeded for every
// input and no case in this file could tell a correct encoder from a wrong
// one. `/oauth/revoke` now runs the form-urldecode a compliant authorization
// server runs, which is what gives this case teeth: it passes only because
// `encodeURIComponent` escapes `+` as `%2B`, and would fail against an
// encoder that emitted a bare one.
it("revokes a grant whose client secret contains a plus", async () => {
const tokens = await authorize(PLUS_CLIENT_ID, PLUS_CLIENT_SECRET);
expect(await tokenAccepted(tokens.access_token)).toBe(true);
const outcome = await clearAndRevoke(
await seededStorage(tokens, PLUS_CLIENT_ID, PLUS_CLIENT_SECRET),
);
expect(outcome).toMatchObject({
status: "revoked",
tokenTypeHint: "refresh_token",
});
expect(await tokenAccepted(tokens.access_token)).toBe(false);
});
// The guard on the fixture itself, and the one case that separates the two
// decoders. An *unencoded* credential is what the SDK's `applyBasicAuth`
// sends, and a compliant server form-urldecodes it anyway — so a `+` in the
// secret comes back as a space and the credential is refused. Under the old
// `decodeURIComponent` fixture this same request was accepted (a string with
// no `%` in it decodes to itself), which is precisely why that fixture could
// not fail on an encoding mistake. Revert `/oauth/revoke` and this test goes
// red.
it("refuses an unencoded Basic credential whose secret contains a plus", async () => {
const raw = `${PLUS_CLIENT_ID}:${PLUS_CLIENT_SECRET}`;
const response = await fetch(`${serverUrl}/oauth/revoke`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(raw).toString("base64")}`,
},
body: new URLSearchParams({ token: "anything" }),
});
expect(response.status).toBe(401);
});
});