All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
5 changed files with 11437 additions and 1212 deletions

View file

@ -55,6 +55,9 @@ const h = vi.hoisted(() => {
clearOAuthTokens: vi.fn(),
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
getOAuthState: vi.fn(async () => undefined),
callTool: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(async () => false),
handleAuthChallenge: vi.fn(async () => ({ kind: "satisfied" as const })),
};
// Captured options from the most recent callbackServer.start(), so a test can
// drive the onCallback / onError handlers the OAuth flows register.
@ -95,6 +98,11 @@ const h = vi.hoisted(() => {
completeOAuthFlow = (...a: unknown[]) =>
clientSpies.completeOAuthFlow(...a);
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
callTool = (...a: unknown[]) => clientSpies.callTool(...a);
checkAuthChallengeSatisfied = (...a: unknown[]) =>
clientSpies.checkAuthChallengeSatisfied(...a);
handleAuthChallenge = (...a: unknown[]) =>
clientSpies.handleAuthChallenge(...a);
readResource = vi.fn(async () => ({
result: { contents: [{ uri: "file://x", text: "hello" }] },
}));
@ -185,16 +193,25 @@ vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
},
};
});
vi.mock("@inspector/core/auth/node/index.js", () => ({
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
}));
vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/auth/node/index.js")>();
return {
...actual,
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
};
});
vi.mock("../src/utils/openUrl.js", () => ({
openUrl: h.openUrl,
}));
import App from "../src/App.js";
import type { TuiServer } from "../src/tui-servers.js";
import {
AuthRecoveryRequiredError,
EMA_STEP_UP_PENDING_URL,
} from "@inspector/core/auth/challenge.js";
const tick = () => new Promise((r) => setTimeout(r, 25));
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
@ -234,6 +251,27 @@ function oneHttp(): Record<string, TuiServer> {
};
}
function oneEmaHttp(): Record<string, TuiServer> {
return {
ema: {
config: { type: "streamable-http", url: "http://localhost:8080/mcp" },
settings: {
requestTimeout: 0,
metadata: [],
headers: [],
env: [],
roots: [],
maxFetchRequests: 1000,
taskTtl: 0,
connectionTimeout: 0,
oauthClientId: "client_ema_test",
oauthScopes: "tools:read",
enterpriseManaged: true,
},
} as never,
};
}
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
function httpThenStdio(): Record<string, TuiServer> {
@ -466,6 +504,11 @@ beforeEach(() => {
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
h.clientSpies.getOAuthState.mockReset();
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
h.clientSpies.callTool.mockReset();
h.clientSpies.checkAuthChallengeSatisfied.mockReset();
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
h.clientSpies.handleAuthChallenge.mockReset();
h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" });
});
afterEach(() => {
@ -936,4 +979,65 @@ describe("App (OAuth flows)", () => {
await h.cb.opts!.onCallback({ code: "x" });
await expectFrame(r, "cb-string");
});
it("shows EMA step-up confirmation on tool auth recovery instead of auto OAuth", async () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["env:read"],
authorizationScopes: ["tools:read", "env:read"],
context: { toolName: "get-env" },
};
h.clientSpies.callTool.mockRejectedValue(
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
emaStepUpConfirm: true,
}),
);
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneEmaHttp());
await press(r, ["t", TAB, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ENTER]);
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
await expectFrame(r, "organization before it can continue");
const frame = r.lastFrame() ?? "";
expect(frame).toMatch(/organization|get-env/i);
expect(frame).not.toMatch(/opens browser/i);
expect(frame).not.toMatch(/OAuth: authenticating/i);
expect(h.callbackStart).not.toHaveBeenCalled();
});
it("runs confirmed EMA step-up via handleAuthChallenge when user authorizes", async () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["env:read"],
authorizationScopes: ["tools:read", "env:read"],
context: { toolName: "get-env" },
};
h.clientSpies.callTool.mockRejectedValue(
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
emaStepUpConfirm: true,
}),
);
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" });
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneEmaHttp());
await press(r, ["t", TAB, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ENTER]);
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
await expectFrame(r, "organization before it can continue");
await press(r, ["a"]);
await waitUntil(
() => h.clientSpies.handleAuthChallenge.mock.calls.length > 0,
);
expect(h.clientSpies.handleAuthChallenge).toHaveBeenCalledWith(challenge, {
confirmedStepUp: true,
});
expect(h.callbackStart).not.toHaveBeenCalled();
await expectFrame(r, "Step-up authorization succeeded");
});
});

View file

@ -71,6 +71,15 @@ const baseProps = {
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(
@ -220,6 +229,86 @@ describe("AuthTab", () => {
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("refreshes OAuth state when oauthComplete fires", async () => {
const { client, getOAuthState, fire, listeners } =
makeClient(sampleOAuthState);

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
// ScrollView: passthrough so the results JSX actually mounts (and is counted
@ -341,4 +342,34 @@ describe("PromptTestModal", () => {
await tick();
expect(true).toBe(true);
});
it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => {
const recovery = new AuthRecoveryRequiredError(
new URL("https://auth.example.com/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
);
const getPrompt = vi.fn().mockRejectedValue(recovery);
const onClose = vi.fn();
const onAuthRecoveryRequired = vi.fn();
setFormSubmitValue({ topic: "x" });
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={{ getPrompt } as unknown as InspectorClient}
width={120}
height={30}
onClose={onClose}
onAuthRecoveryRequired={onAuthRecoveryRequired}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
// ScrollView passthrough so the results JSX actually mounts (and is covered).
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
@ -264,4 +265,32 @@ describe("ResourceTestModal", () => {
await tick();
api.unmount();
});
it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => {
const recovery = new AuthRecoveryRequiredError(
new URL("https://auth.example.com/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
);
const read = vi.fn().mockRejectedValue(recovery);
const onClose = vi.fn();
const onAuthRecoveryRequired = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={onClose}
onAuthRecoveryRequired={onAuthRecoveryRequired}
/>,
);
await tick();
setSubmitValue({ name: "world" });
api.stdin.write("\r");
await tick();
await tick();
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery);
expect(onClose).toHaveBeenCalledTimes(1);
api.unmount();
});
});

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import {
isStandardOAuthStepUp,
isStepUpConfirmation,
stepUpConfirmMessage,
stepUpInsufficientScopeMessage,
} from "../src/utils/tuiOAuth.js";
describe("tuiOAuth", () => {
it("detects standard OAuth step-up", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: false },
),
).toBe(true);
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: true },
),
).toBe(false);
});
it("detects EMA step-up confirmation", () => {
expect(
isStepUpConfirmation(
{ reason: "insufficient_scope", requiredScopes: ["env:read"] },
{ enterpriseManaged: true },
),
).toBe(true);
expect(
isStepUpConfirmation(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: false },
),
).toBe(true);
});
it("formats step-up messages", () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
context: { toolName: "get_temp" },
};
expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/);
expect(stepUpInsufficientScopeMessage(challenge)).toMatch(/get_temp/);
});
});