/** * 通过本地 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(); async get(account: string): Promise { return this.values.get(account); } async set(account: string, tokens: OAuthTokens): Promise { this.values.set(account, tokens); } async delete(account: string): Promise { return this.values.delete(account); } } class MemoryDiscovery { value: unknown; async get(): Promise { return this.value as undefined; } async set(_account: string, value: unknown): Promise { this.value = value; } async delete(): Promise { 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((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(); 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((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); async function availablePort(): Promise { const app = express(); const server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); const address = server.address() as AddressInfo; await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); return address.port; }