fix(auth): address Copilot review round 1 on the RFC 7009 revocation (#2144)

- RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported`
  to `client_secret_basic`; it does not inherit the token endpoint's list.
  Inheriting made metadata advertising only `client_secret_post` there send
  POST credentials to an endpoint that never advertised the method.
- The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the
  call as a POST to `/api/fetch` and drops `init.signal`, and the backend's
  outbound fetch gets none either, so a wedged AS could hold the teardown open
  indefinitely. A wall-clock race now enforces the deadline; the signal is kept
  because it genuinely cancels the direct-fetch paths.
- The settings-modal Clear read `settingsModalTarget`, which comes from the
  persisted `servers` list, while edits live in `settingsDraft` until the save
  debounce. Toggling the checkbox and clearing immediately used the previous
  value. It now merges the draft.
- `--relogin` clears both key spellings, so it now revokes from both rather
  than stopping at the first populated one — a stale entry under the other
  spelling is a live grant. The normalised key goes first, matching
  `findStoredServerState`, and a second key holding the same token is skipped.
- The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client
  authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the
  Inspector authenticates correctly is actually tested; two negative cases
  assert a wrong secret and an unidentified client are refused.
- Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger`
  double, and a spy on the real storage instead of a spread-and-cast.
- Added CLI-level tests through `runCli` for `--no-revoke`, the per-server
  `oauth.revokeOnClear`, and the stderr warning, and TUI tests for the
  forwarded opt-out and the rendered failure.

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 01:30:09 -04:00
commit 7f27d7e9c7

View file

@ -87,20 +87,23 @@ export function selectRevocableToken(
}
/**
* The client-authentication methods to consider for the revocation endpoint.
* The client-authentication methods the revocation endpoint advertises.
*
* RFC 8414 gives `revocation_endpoint_auth_methods_supported` the same default
* as the token endpoint's when it is omitted, so fall through to
* `token_endpoint_auth_methods_supported` before giving up. An empty result
* lets {@link selectClientAuthMethod} apply the SDK's own default rather than
* this module inventing a second one.
* RFC 8414 §2 gives `revocation_endpoint_auth_methods_supported` a default of
* **`client_secret_basic`** when it is omitted — it does *not* inherit
* `token_endpoint_auth_methods_supported`. Falling through to the token
* endpoint's list would make metadata advertising only `client_secret_post`
* there send POST credentials to a revocation endpoint that never advertised
* that method, which a strict server rejects.
*
* An empty result is returned rather than a literal `["client_secret_basic"]`
* because that is what makes {@link selectClientAuthMethod} apply exactly the
* RFC's default — `client_secret_basic` when the client holds a secret, `none`
* when it does not — while still honoring a `token_endpoint_auth_method` the
* client's own registration declares.
*/
export function revocationAuthMethods(metadata: OAuthMetadata): string[] {
return (
metadata.revocation_endpoint_auth_methods_supported ??
metadata.token_endpoint_auth_methods_supported ??
[]
);
return metadata.revocation_endpoint_auth_methods_supported ?? [];
}
export interface RevocationRequestParams {
@ -195,10 +198,18 @@ export async function revokeToken(
const { url, init } = buildRevocationRequest(params);
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
try {
const response = await params.fetchFn(url, {
...init,
signal: AbortSignal.timeout(timeoutMs),
});
// The signal alone is not enough to bound this. In the browser the fetch is
// `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch`
// and does not forward `init.signal`; the backend's outbound fetch gets no
// signal either. So a wedged authorization server would hold the teardown
// open indefinitely on exactly the path the timeout exists for. The race is
// what actually enforces the deadline; the signal is kept because it does
// cancel the direct-fetch paths (CLI, TUI, backend) rather than merely
// abandoning them.
const response = await withDeadline(
params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
timeoutMs,
);
if (!response.ok) {
return {
status: "failed",
@ -221,6 +232,34 @@ export async function revokeToken(
}
}
/**
* Reject with a timeout error if `promise` has not settled within `timeoutMs`.
*
* The underlying request is abandoned rather than cancelled — nothing here can
* cancel a fetch the proxy already stripped the signal from. That is the right
* trade for this caller: the point is that the *teardown* proceeds, and the
* response, if it ever arrives, is a revocation we no longer need to wait for.
* The timer is cleared on the settled path so a caller is never held awake by it.
*/
async function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() =>
reject(new Error(`revocation request timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
return await Promise.race([promise, deadline]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
export interface RevokeStoredOAuthTokensParams {
serverUrl: string;
storage: OAuthStorage;