Copilot review rounds 8 and 9. - A cached metadata document with no `issuer` was letting every issuer-bound token go to its single revocation endpoint. Absence establishes nothing, so that is now a mismatch: an issuer-bound grant must match, and only a legacy unkeyed grant — which nothing binds to a different authorization server — proceeds without the comparison. This was a credential-disclosure risk, not just a correctness one. - The ctx-less grant read was skipped whenever it carried an issuer stamp, on the assumption that slot had already been enumerated. If `listIssuers` fails there is no enumerated slot behind the stamp, so the one readable grant went unrevoked while `clear` deleted it. Both reads now share one issuer+token key, and the stamped issuer is retained rather than discarded. - `AuthTab`'s repeat guard read `clearState`, which is not updated until React re-renders, so two `s` events in one input turn both started a clear against the same store entry. It is a ref now, set before the call. - A rejected clear left `ownClearRef` set — `handleClearOAuth` never reached its `oauthRevision` bump — so the next unrelated revision was swallowed and the failure banner stranded. Cleared on that path. - The web `clearServerOAuthAndDisconnect` had the staleness the TUI path was already fixed for: `isActive`/`inspectorClient` are captured before a five-second await, so a switch during it disconnected the session the user had just moved to and ran the session-wide cleanup against it. Revalidated against `sessionRef` after each await. 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>
586 lines
17 KiB
TypeScript
586 lines
17 KiB
TypeScript
import React from "react";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render } from "ink-testing-library";
|
|
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
|
|
|
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
|
|
|
import { AuthTab } from "../src/components/AuthTab.js";
|
|
|
|
const tick = async () => {
|
|
for (let i = 0; i < 8; i++)
|
|
await new Promise((resolve) => setTimeout(resolve, 4));
|
|
};
|
|
|
|
const ESC = String.fromCharCode(27);
|
|
const UP = `${ESC}[A`;
|
|
const DOWN = `${ESC}[B`;
|
|
const PAGE_UP = `${ESC}[5~`;
|
|
const PAGE_DOWN = `${ESC}[6~`;
|
|
|
|
const sampleOAuthState: OAuthConnectionState = {
|
|
authorized: true,
|
|
protocol: "standard",
|
|
serverUrl: "http://x/mcp",
|
|
client: {
|
|
clientId: "abc123",
|
|
registrationKind: "dcr",
|
|
hasClientSecret: false,
|
|
},
|
|
tokens: {
|
|
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
|
|
token_type: "Bearer",
|
|
},
|
|
authorizationServerMetadata: {
|
|
issuer: "https://auth.example.com",
|
|
authorization_endpoint: "https://auth.example.com/authorize",
|
|
token_endpoint: "https://auth.example.com/token",
|
|
response_types_supported: ["code"],
|
|
},
|
|
configuredScope: "read write",
|
|
};
|
|
|
|
function makeClient(oauthState?: OAuthConnectionState) {
|
|
const listeners = new Map<string, Set<() => void>>();
|
|
const getOAuthState = vi.fn(async () => oauthState);
|
|
const client = {
|
|
getOAuthState,
|
|
addEventListener: (event: string, fn: () => void) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event)!.add(fn);
|
|
},
|
|
removeEventListener: (event: string, fn: () => void) => {
|
|
listeners.get(event)?.delete(fn);
|
|
},
|
|
};
|
|
const fire = (event: string) => {
|
|
listeners.get(event)?.forEach((fn) => fn());
|
|
};
|
|
return {
|
|
client: client as unknown as InspectorClient,
|
|
getOAuthState,
|
|
fire,
|
|
listeners,
|
|
};
|
|
}
|
|
|
|
const baseProps = {
|
|
serverName: "srv" as string | null,
|
|
serverConfig: null,
|
|
width: 120,
|
|
height: 30,
|
|
oauthRevision: 0,
|
|
onClearOAuth: vi.fn(),
|
|
connectionStatus: "disconnected" as const,
|
|
};
|
|
|
|
const pendingStepUp = {
|
|
challenge: {
|
|
reason: "insufficient_scope" as const,
|
|
requiredScopes: ["env:read"],
|
|
authorizationScopes: ["tools:read", "env:read"],
|
|
},
|
|
enterpriseManaged: true,
|
|
};
|
|
|
|
describe("AuthTab", () => {
|
|
it("renders the placeholder when there is no server", () => {
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
serverName={null}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain(
|
|
"Select a server to view authentication",
|
|
);
|
|
});
|
|
|
|
// #2144 — the clear is now a bounded network request, so announcing "cleared"
|
|
// on the keypress would say it while the work was still in flight.
|
|
it("shows a pending state until the clear settles, and ignores repeats", async () => {
|
|
let settle: () => void = () => {};
|
|
const onClearOAuth = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
settle = resolve;
|
|
}),
|
|
);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("Clearing OAuth state");
|
|
expect(lastFrame() ?? "").not.toContain("OAuth state cleared");
|
|
|
|
// A second press while one is in flight would race the first over the same
|
|
// store entry, and nothing on screen tells the user the first is running.
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(onClearOAuth).toHaveBeenCalledTimes(1);
|
|
|
|
settle();
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
|
});
|
|
|
|
// A state-based guard is not effective until React re-renders, so two `s`
|
|
// events in the SAME input turn both read the last-rendered "idle" and start
|
|
// concurrent clears against one store entry. No tick between the writes.
|
|
it("ignores a repeat delivered in the same input turn", async () => {
|
|
const onClearOAuth = vi.fn(() => new Promise<void>(() => {}));
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
|
|
stdin.write("s");
|
|
stdin.write("s");
|
|
await tick();
|
|
|
|
expect(onClearOAuth).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// A rejection releases the lock, so the user can retry.
|
|
it("allows a retry after a rejected clear", async () => {
|
|
const onClearOAuth = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockRejectedValueOnce(new Error("nope"))
|
|
.mockResolvedValue(undefined);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("s");
|
|
await tick();
|
|
stdin.write("s");
|
|
await tick();
|
|
|
|
expect(onClearOAuth).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
// A rejection is NOT a revocation failure — those come back as outcomes and
|
|
// are reported through the message line. This is the local clear or the
|
|
// disconnect itself failing, so reporting success would be a plain lie.
|
|
it("reports a rejected clear as a failure, not as success", async () => {
|
|
const onClearOAuth = vi.fn(() =>
|
|
Promise.reject(new Error("keychain locked")),
|
|
);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("s");
|
|
await tick();
|
|
const frame = lastFrame() ?? "";
|
|
expect(frame).toContain("Could not clear OAuth state");
|
|
expect(frame).toContain("keychain locked");
|
|
expect(frame).not.toContain("OAuth state cleared.");
|
|
expect(frame).not.toContain("Clearing OAuth state");
|
|
});
|
|
|
|
// A clear started on server A must not confirm under server B: it is a
|
|
// bounded network request now, so it can settle after the user has moved on.
|
|
it("does not confirm a clear that settles after the server changed", async () => {
|
|
let settle: () => void = () => {};
|
|
const onClearOAuth = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
settle = resolve;
|
|
}),
|
|
);
|
|
const { lastFrame, stdin, rerender } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
serverName="a"
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("Clearing OAuth state");
|
|
|
|
rerender(
|
|
<AuthTab
|
|
{...baseProps}
|
|
serverName="b"
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
settle();
|
|
await tick();
|
|
|
|
const frame = lastFrame() ?? "";
|
|
expect(frame).not.toContain("OAuth state cleared");
|
|
expect(frame).not.toContain("Clearing OAuth state");
|
|
});
|
|
|
|
// #2144 — a revocation failure is a *partial* success: the local state really
|
|
// was cleared, so it is not an `error` status, but the grant may still be
|
|
// live at the authorization server and the informational tone understates
|
|
// that. Both tones are exercised so neither branch can rot.
|
|
it("renders an idle message in the warning tone when asked", () => {
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage="Cleared locally, but revoking failed."
|
|
oauthMessageTone="warning"
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Cleared locally");
|
|
});
|
|
|
|
it("renders an idle message in the default informational tone", () => {
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage="Stored OAuth state cleared."
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Stored OAuth state cleared.");
|
|
});
|
|
|
|
it("renders OAuth details from getOAuthState", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
const frame = lastFrame() ?? "";
|
|
expect(frame).toContain("OAuth Details");
|
|
expect(frame).toContain("Authorized");
|
|
expect(frame).toContain("abc123");
|
|
expect(frame).toContain("Dynamic (DCR)");
|
|
expect(frame).toContain("read, write");
|
|
expect(frame).toContain("tok-abcdefghijklmnopqrst");
|
|
});
|
|
|
|
it("shows the not-yet-authorized hint when getOAuthState is empty", async () => {
|
|
const { client } = makeClient(undefined);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
const frame = lastFrame() ?? "";
|
|
expect(frame).toContain("No OAuth information yet");
|
|
expect(frame).toContain("Connect (C) to authorize");
|
|
});
|
|
|
|
it("renders authenticating and error status messages", async () => {
|
|
const { client } = makeClient(undefined);
|
|
const { lastFrame, rerender } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="authenticating"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Authenticating");
|
|
|
|
rerender(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="error"
|
|
oauthMessage="Something went wrong"
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Something went wrong");
|
|
});
|
|
|
|
it("clears OAuth state on S and shows confirmation", async () => {
|
|
const onClearOAuth = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
onClearOAuth={onClearOAuth}
|
|
/>,
|
|
);
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(onClearOAuth).toHaveBeenCalled();
|
|
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
|
});
|
|
|
|
it("shows clear+disconnect label when connected", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
connectionStatus="connected"
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("clear+disconnect");
|
|
});
|
|
|
|
it("shows the focused footer when focused", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("S clear");
|
|
});
|
|
|
|
it("scrolls with arrow and page keys when focused", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
height={80}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write(UP);
|
|
await tick();
|
|
stdin.write(DOWN);
|
|
await tick();
|
|
stdin.write(PAGE_UP);
|
|
await tick();
|
|
stdin.write(PAGE_DOWN);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
|
});
|
|
|
|
it("navigates step-up choices with arrows and activates with Enter", async () => {
|
|
const onAuthorizeStepUp = vi.fn();
|
|
const onCancelStepUp = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
|
onCancelStepUp={onCancelStepUp}
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("\r");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onCancelStepUp).not.toHaveBeenCalled();
|
|
|
|
onAuthorizeStepUp.mockClear();
|
|
stdin.write(DOWN);
|
|
await tick();
|
|
stdin.write("\r");
|
|
await tick();
|
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onAuthorizeStepUp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows step-up footer with selection hints when focused", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={vi.fn()}
|
|
onCancelStepUp={vi.fn()}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("↑/↓ select, Enter confirm");
|
|
});
|
|
|
|
it("refreshes OAuth state when connection becomes connected", async () => {
|
|
const { client, getOAuthState } = makeClient(undefined);
|
|
const { lastFrame, rerender } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
connectionStatus="disconnected"
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalledTimes(1);
|
|
expect(lastFrame() ?? "").toContain("No OAuth information yet");
|
|
|
|
getOAuthState.mockResolvedValue(sampleOAuthState);
|
|
rerender(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
connectionStatus="connected"
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalledTimes(2);
|
|
expect(lastFrame() ?? "").toContain("Authorized");
|
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
|
});
|
|
|
|
it("moves the step-up selection back up with the up arrow", async () => {
|
|
const onAuthorizeStepUp = vi.fn();
|
|
const onCancelStepUp = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
|
onCancelStepUp={onCancelStepUp}
|
|
/>,
|
|
);
|
|
await tick();
|
|
// Move down to "cancel" (index 1), then back up to "authorize" (index 0).
|
|
stdin.write(DOWN);
|
|
await tick();
|
|
stdin.write(UP);
|
|
await tick();
|
|
stdin.write("\r");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onCancelStepUp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("authorizes step-up with 'a', cancels with 'c', and ignores other keys", async () => {
|
|
const onAuthorizeStepUp = vi.fn();
|
|
const onCancelStepUp = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
|
onCancelStepUp={onCancelStepUp}
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("a");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
|
|
stdin.write("c");
|
|
await tick();
|
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
|
|
|
// An unrelated key is swallowed while the step-up prompt is pending.
|
|
stdin.write("x");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refreshes OAuth state when oauthComplete fires", async () => {
|
|
const { client, getOAuthState, fire, listeners } =
|
|
makeClient(sampleOAuthState);
|
|
const { unmount } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalled();
|
|
expect(listeners.get("oauthComplete")?.size).toBe(1);
|
|
getOAuthState.mockClear();
|
|
fire("oauthComplete");
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalled();
|
|
unmount();
|
|
expect(listeners.get("oauthComplete")?.size).toBe(0);
|
|
});
|
|
});
|