All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
5 changed files with 11437 additions and 1212 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
clients/tui/__tests__/tuiOAuth.test.ts
Normal file
49
clients/tui/__tests__/tuiOAuth.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue