fix(auth): keep the failure path total, and align the docs with the new order (#2144)

Copilot review round 14. Two code fixes and a documentation sweep.

- `buildRevocationRequest` ran outside `revokeToken`'s best-effort try.
  `encodeURIComponent` throws on a lone UTF-16 surrogate — valid JSON, so it
  can reach there from a persisted client id or secret — and every caller has
  already cleared its local state by then, so a rejection instead of a `failed`
  outcome would break the documented guarantee. Construction is inside the try
  now, and the catch reports `params.endpoint` rather than a `url` that may not
  exist on that path.
- The fixture's Basic decode turned a malformed percent escape into an Express
  500, reporting a bad credential as a server fault rather than as the
  `invalid_client` 401 the endpoint means. Decoding failure is failed
  authentication.
- Ten places still said revocation happens *before* the clear, which the
  reorder in the previous commit made false: the settings-form description, the
  `--no-revoke` help text, both READMEs, `docs/mcp-server-configuration.md`,
  three JSDoc blocks, and the PR body. Swept, and the ordering is stated where
  it is load-bearing rather than asserted in passing. Also merged the two
  adjacent JSDoc blocks on `InspectorClient.clearOAuthTokens`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 05:55:00 -04:00
commit 4429c0c0bf
3 changed files with 62 additions and 20 deletions

View file

@ -930,7 +930,7 @@ export function ServerSettingsForm({
/>
<Checkbox
label="Revoke tokens on clear"
description="Calls the authorization server's RFC 7009 revocation endpoint before clearing the stored OAuth state, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
description="Calls the authorization server's RFC 7009 revocation endpoint when the stored OAuth state is cleared, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
checked={settings.oauthRevokeOnClear ?? true}
onChange={handleRevokeOnClearChange}
/>

View file

@ -16,8 +16,8 @@ export interface ClearServerOAuthStateParams {
/** Shared web OAuth store; required so clear hits the same blob as connect. */
oauthStorage: OAuthStorage;
/**
* Whether to revoke the grant at the authorization server first (RFC 7009,
* #2144). Defaults to on. Two callers turn it off: a server whose settings
* Whether to revoke the grant at the authorization server (RFC 7009, #2144).
* Defaults to on. Two callers turn it off: a server whose settings
* opted out, and `lost_authorization_state` recovery — that path clears a
* half-finished flow in order to retry it, so there is no completed grant to
* revoke and the request would be noise at best.

View file

@ -282,6 +282,27 @@ describe("revokeToken", () => {
});
});
// A lone UTF-16 surrogate is valid JSON, so it can reach here from a
// persisted client id or secret and makes `encodeURIComponent` throw. Every
// caller has already cleared its local state by the time this runs, so a
// rejection here would break the documented best-effort guarantee.
it("reports an unencodable credential as failed rather than throwing", async () => {
const outcome = await revokeToken({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
clientInformation: {
client_id: "cid",
// Lone high surrogate.
client_secret: `bad${String.fromCharCode(0xd800)}`,
},
supportedAuthMethods: ["client_secret_basic"],
fetchFn: vi.fn<typeof fetch>(),
});
expect(outcome).toMatchObject({ status: "failed", endpoint: REVOKE_URL });
});
it("reports a network failure as failed", async () => {
const fetchFn = vi.fn<typeof fetch>(async () => {
throw new Error("connect ECONNREFUSED");