166 lines
7.8 KiB
TypeScript
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;
|
|
}
|