Files
crafttable-mcp-client/test/oauthIntegration.test.ts

166 lines
7.8 KiB
TypeScript

/**
* 通过本地 OAuth 与 MCP 模拟服务验证完整 PKCE 登录、令牌刷新和秘密隔离流程。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";
import express from "express";
import type { AddressInfo } from "node:net";
import test from "node:test";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { credentialAccount, resolveClientOptions } from "../src/config.js";
import type { TokenStore } from "../src/credentials.js";
import { loginRemote } from "../src/remote.js";
class MemoryTokens implements TokenStore {
readonly values = new Map<string, OAuthTokens>();
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
async set(account: string, tokens: OAuthTokens): Promise<void> { this.values.set(account, tokens); }
async delete(account: string): Promise<boolean> { return this.values.delete(account); }
}
class MemoryDiscovery {
value: unknown;
async get(): Promise<undefined> { return this.value as undefined; }
async set(_account: string, value: unknown): Promise<void> { this.value = value; }
async delete(): Promise<void> { this.value = undefined; }
}
test("browser PKCE login exchanges, refreshes rotated tokens, and initializes MCP without leaking secrets", async () => {
const callbackPort = await availablePort();
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address() as AddressInfo;
const origin = `http://127.0.0.1:${address.port}`;
const resource = `${origin}/mcp`;
const authorizationCodes = new Map<string, { challenge: string; resource: string }>();
let authorizationRequest: URL | undefined;
let refreshCount = 0;
let mcpServer: Server | undefined;
let transport: StreamableHTTPServerTransport | undefined;
app.get("/.well-known/oauth-protected-resource/mcp", (_request, response) => response.json({
resource,
authorization_servers: [origin],
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
}));
const authorizationMetadata = {
issuer: origin,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
revocation_endpoint: `${origin}/revoke`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
};
app.get("/.well-known/oauth-authorization-server", (_request, response) => response.json(authorizationMetadata));
app.get("/.well-known/openid-configuration", (_request, response) => response.json({
...authorizationMetadata,
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
}));
app.get("/authorize", (request, response) => {
authorizationRequest = new URL(request.originalUrl, origin);
const redirectUri = String(request.query.redirect_uri ?? "");
const state = String(request.query.state ?? "");
const challenge = String(request.query.code_challenge ?? "");
const requestedResource = String(request.query.resource ?? "");
const code = "authorization-code";
authorizationCodes.set(code, { challenge, resource: requestedResource });
const callback = new URL(redirectUri);
callback.searchParams.set("code", code);
callback.searchParams.set("state", state);
response.redirect(callback.toString());
});
app.post("/token", (request, response) => {
const grantType = String(request.body.grant_type ?? "");
if (grantType === "authorization_code") {
const code = String(request.body.code ?? "");
const record = authorizationCodes.get(code);
const verifier = String(request.body.code_verifier ?? "");
const challenge = createHash("sha256").update(verifier).digest("base64url");
if (!record || challenge !== record.challenge || record.resource !== resource || request.body.resource !== resource || request.body.client_id !== "test-client") {
response.status(400).json({ error: "invalid_grant" });
return;
}
response.json({ access_token: "access-one", refresh_token: "refresh-one", token_type: "Bearer", expires_in: 3600 });
return;
}
if (grantType === "refresh_token" && request.body.refresh_token === "refresh-one" && request.body.resource === resource) {
refreshCount += 1;
response.json({ access_token: "access-two", refresh_token: "refresh-two", token_type: "Bearer", expires_in: 3600 });
return;
}
response.status(400).json({ error: "invalid_grant" });
});
app.post("/mcp", async (request, response) => {
if (request.headers.authorization !== "Bearer access-two") {
response.setHeader("WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`);
response.status(401).json({ error: "authentication required" });
return;
}
if (!transport && isInitializeRequest(request.body)) {
mcpServer = new Server({ name: "mock-gamecraft", version: "1.0.0" }, { capabilities: { tools: {} } });
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: "list_spaces", description: "List spaces", inputSchema: { type: "object" } }],
}));
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true });
await mcpServer.connect(transport);
}
if (!transport) {
response.status(400).json({ error: "MCP session is missing" });
return;
}
await transport.handleRequest(request, response, request.body);
});
const options = resolveClientOptions({ url: resource, clientId: "test-client", callbackPort, env: {} });
const tokens = new MemoryTokens();
const discovery = new MemoryDiscovery();
const output: string[] = [];
let browserFailure: unknown;
try {
const result = await loginRemote(options, tokens, discovery as never, {
browser: false,
timeoutMs: 3000,
writeLine: (line) => {
output.push(line);
const match = line.match(/https?:\/\/[^\s]+/);
if (match) void fetch(match[0], { redirect: "follow" }).catch((error) => { browserFailure = error; });
},
});
assert.equal(browserFailure, undefined);
assert.deepEqual(result, { alreadyAuthenticated: false, toolCount: 1 });
assert.equal(authorizationRequest?.searchParams.get("code_challenge_method"), "S256");
assert.equal(authorizationRequest?.searchParams.get("resource"), resource);
assert.match(authorizationRequest?.searchParams.get("scope") ?? "", /offline_access/);
assert.equal(refreshCount, 1);
assert.equal(tokens.values.get(credentialAccount(options))?.refresh_token, "refresh-two");
const renderedOutput = output.join("\n");
assert.doesNotMatch(renderedOutput, /authorization-code|access-one|access-two|refresh-one|refresh-two|Authorization:/);
} finally {
await mcpServer?.close();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
async function availablePort(): Promise<number> {
const app = express();
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address() as AddressInfo;
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
return address.port;
}