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:
parent
15f381c6ad
commit
7f27d7e9c7
1 changed files with 617 additions and 87 deletions
|
|
@ -4,6 +4,7 @@ import {
|
|||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
revokeStoredOAuthTokens,
|
||||
selectRevocableToken,
|
||||
type TokenRevocationOutcome,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
|
||||
|
|
@ -40,13 +41,17 @@ export async function clearStoredAuthForRelogin(
|
|||
// this is about to empty. Best-effort — the outcome is returned for the
|
||||
// caller to report, never thrown, so `--relogin` succeeds regardless.
|
||||
//
|
||||
// Only the key that actually holds the state is revoked from: the two keys
|
||||
// below are two spellings of one server, so revoking from both would send a
|
||||
// second request for a grant the first one already ended.
|
||||
// Both spellings are cleared below, so both are revoked from — a stale entry
|
||||
// under the other key is a live grant at the authorization server, and
|
||||
// deleting it locally without revoking is exactly the leak this closes. The
|
||||
// normalised key goes first because that is the precedence `findStoredServerState`
|
||||
// reads with, so the grant actually in use is the one whose outcome is
|
||||
// reported; a second key holding the *same* token is skipped rather than
|
||||
// revoked twice.
|
||||
const revocation =
|
||||
options?.revoke === false
|
||||
? undefined
|
||||
: await revokeFirstStoredKey(storage, [raw, normalized]);
|
||||
: await revokeStoredKeys(storage, [normalized, raw]);
|
||||
await storage.clear(raw);
|
||||
if (normalized !== raw) {
|
||||
await storage.clear(normalized);
|
||||
|
|
@ -58,27 +63,43 @@ export async function clearStoredAuthForRelogin(
|
|||
}
|
||||
|
||||
/**
|
||||
* Revoke against the first of `keys` that actually has a revocable token,
|
||||
* returning that attempt's outcome. Reports the last "nothing to do" answer
|
||||
* when no key holds one, so the caller can still distinguish "no tokens" from
|
||||
* "this authorization server advertises no revocation endpoint".
|
||||
* Revoke every distinct grant held under `keys`, in order, and report the first
|
||||
* key that had something to revoke.
|
||||
*
|
||||
* Both keys are about to be deleted, so both are revoked from: a stale entry
|
||||
* under the other spelling is still a live grant at the authorization server,
|
||||
* and deleting it locally without revoking is the leak this whole change
|
||||
* closes. Two keys holding the *same* token are one grant, so the duplicate is
|
||||
* skipped rather than producing a second request for something already ended.
|
||||
*
|
||||
* The reported outcome is the first key's that was not "nothing to do", which
|
||||
* with `keys` in `findStoredServerState` precedence means the grant the CLI
|
||||
* would actually have connected with. When no key holds a token, the last
|
||||
* "nothing to do" answer is returned so the caller can still tell "no tokens"
|
||||
* from "this authorization server advertises no revocation endpoint".
|
||||
*/
|
||||
async function revokeFirstStoredKey(
|
||||
async function revokeStoredKeys(
|
||||
storage: NodeOAuthStorage,
|
||||
keys: string[],
|
||||
): Promise<TokenRevocationOutcome | undefined> {
|
||||
const fetchFn = createProxyFetch() ?? fetch;
|
||||
let last: TokenRevocationOutcome | undefined;
|
||||
const revokedTokens = new Set<string>();
|
||||
let reported: TokenRevocationOutcome | undefined;
|
||||
let lastSkip: TokenRevocationOutcome | undefined;
|
||||
for (const key of new Set(keys)) {
|
||||
const token = selectRevocableToken(await storage.getTokens(key))?.token;
|
||||
if (token !== undefined && revokedTokens.has(token)) continue;
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: key,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
if (!(outcome.status === "skipped" && outcome.reason === "no_tokens")) {
|
||||
return outcome;
|
||||
if (outcome.status === "skipped" && outcome.reason === "no_tokens") {
|
||||
lastSkip = outcome;
|
||||
continue;
|
||||
}
|
||||
last = outcome;
|
||||
if (token !== undefined) revokedTokens.add(token);
|
||||
reported ??= outcome;
|
||||
}
|
||||
return last;
|
||||
return reported ?? lastSkip;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue