All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
1 changed files with 11437 additions and 1212 deletions
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* Mid-session OAuth recovery over direct (TUI/CLI) transport.
|
||||
* Uses createAuthChallengeInterceptFetch + handleAuthChallenge on InspectorClient.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
TestServerHttp,
|
||||
waitForOAuthWellKnown,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
clearOAuthTestData,
|
||||
invalidateAccessToken,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
createOAuthClientConfig,
|
||||
completeOAuthAuthorization,
|
||||
} from "../helpers/oauth-client-fixtures.js";
|
||||
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
|
||||
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
const oauthTestStatePath = join(
|
||||
tmpdir(),
|
||||
`mcp-oauth-${process.pid}-direct-mid-session-e2e.json`,
|
||||
);
|
||||
|
||||
function createTestOAuthConfig(
|
||||
options: Parameters<typeof createOAuthClientConfig>[0],
|
||||
) {
|
||||
return {
|
||||
...createOAuthClientConfig(options),
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
};
|
||||
}
|
||||
|
||||
describe("InspectorClient direct mid-session OAuth", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
const testRedirectUrl = "http://localhost:3000/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (mcpServer) {
|
||||
await mcpServer.stop();
|
||||
mcpServer = null;
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(oauthTestStatePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers from invalidated access token after connect via silent refresh without disconnect", async () => {
|
||||
const staticClientId = "test-direct-mid-session";
|
||||
const staticClientSecret = "test-secret-direct-mid-session";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
expect(tokens?.refresh_token).toBeDefined();
|
||||
invalidateAccessToken(tokens!.access_token);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
expect(toolsResult.tools.length).toBeGreaterThan(0);
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => {
|
||||
const staticClientId = "test-direct-step-up";
|
||||
const staticClientSecret = "test-secret-direct-step-up";
|
||||
|
||||
const baseConfig = getDefaultServerConfig();
|
||||
const serverConfig = {
|
||||
...baseConfig,
|
||||
serverType: "streamable-http" as const,
|
||||
tools: baseConfig.tools!.map((tool) =>
|
||||
tool.name === "get_temp"
|
||||
? { ...tool, requiredScopes: ["weather:read"] }
|
||||
: tool,
|
||||
),
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp");
|
||||
expect(getTempTool).toBeDefined();
|
||||
|
||||
let recovery: AuthRecoveryRequiredError | undefined;
|
||||
try {
|
||||
await client.callTool(getTempTool!, { city: "NYC", units: "C" });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRecoveryRequiredError) {
|
||||
recovery = error;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
|
||||
expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain(
|
||||
"weather:read",
|
||||
);
|
||||
|
||||
const stepUpCode = await completeOAuthAuthorization(
|
||||
recovery!.authorizationUrl,
|
||||
);
|
||||
await client.completeOAuthFlow(stepUpCode);
|
||||
|
||||
const result = await client.callTool(getTempTool!, {
|
||||
city: "NYC",
|
||||
units: "C",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
});
|
||||
Loading…
Reference in a new issue