Files
crafttable-mcp-client/test/configAndOAuth.test.ts
T

129 lines
7.2 KiB
TypeScript

import assert from "node:assert/strict";
import { createServer } from "node:net";
import test from "node:test";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { OAuthCallbackServer } from "../src/callbackServer.js";
import { credentialAccount, resolveClientOptions, validateMcpUrl } from "../src/config.js";
import type { TokenStore } from "../src/credentials.js";
import { GameCraftOAuthProvider } from "../src/oauthProvider.js";
import { logoutRemote } 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<void> { this.values.delete(account); }
}
class MemoryDiscovery {
values = new Map<string, unknown>();
async get(account: string): Promise<undefined> { return this.values.get(account) as undefined; }
async set(account: string, state: unknown): Promise<void> { this.values.set(account, state); }
async delete(account: string): Promise<void> { this.values.delete(account); }
}
test("connection options enforce HTTPS and isolate credentials by URL and client ID", () => {
assert.equal(validateMcpUrl("https://example.test/mcp/").toString(), "https://example.test/mcp");
assert.equal(validateMcpUrl("http://127.0.0.1:8787/mcp").protocol, "http:");
assert.throws(() => validateMcpUrl("http://example.test/mcp"), /HTTPS/);
assert.throws(() => validateMcpUrl("https://example.test/mcp?token=secret"), /query/);
const first = resolveClientOptions({ url: "https://one.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const second = resolveClientOptions({ url: "https://two.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const third = resolveClientOptions({ url: "https://one.test/mcp", clientId: "other", callbackPort: 48321, env: {} });
assert.notEqual(credentialAccount(first), credentialAccount(second));
assert.notEqual(credentialAccount(first), credentialAccount(third));
});
test("OAuth provider validates state and replaces rotated refresh tokens", async () => {
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const tokens = new MemoryTokens();
const discovery = new MemoryDiscovery();
const provider = new GameCraftOAuthProvider(options, tokens, discovery as never, () => undefined, "expected-state");
assert.equal(provider.validateState("expected-state"), true);
assert.equal(provider.validateState("wrong-state"), false);
provider.saveCodeVerifier("verifier");
assert.equal(provider.codeVerifier(), "verifier");
await provider.saveTokens({ access_token: "access-one", refresh_token: "refresh-one", token_type: "Bearer" });
await provider.saveTokens({ access_token: "access-two", refresh_token: "refresh-two", token_type: "Bearer" });
assert.equal((await provider.tokens())?.refresh_token, "refresh-two");
});
test("OAuth callback validates state without exposing the authorization code", async () => {
const port = await availablePort();
const callback = new OAuthCallbackServer(port, (state) => state === "expected");
await callback.listen();
try {
const response = await fetch(`http://127.0.0.1:${port}/oauth/callback?code=sensitive-code&state=expected`);
assert.equal(response.status, 200);
assert.doesNotMatch(await response.text(), /sensitive-code/);
assert.deepEqual(await callback.wait(1000), { code: "sensitive-code" });
} finally {
await callback.close();
}
});
test("OAuth callback reports timeout and occupied ports", async () => {
const timeoutPort = await availablePort();
const timeoutServer = new OAuthCallbackServer(timeoutPort, () => true);
await timeoutServer.listen();
try {
await assert.rejects(timeoutServer.wait(10), /timed out/);
} finally {
await timeoutServer.close();
}
const occupiedPort = await availablePort();
const blocker = createServer();
await new Promise<void>((resolve) => blocker.listen(occupiedPort, "127.0.0.1", resolve));
try {
await assert.rejects(new OAuthCallbackServer(occupiedPort, () => true).listen(), /Could not listen/);
} finally {
await new Promise<void>((resolve, reject) => blocker.close((error) => error ? reject(error) : resolve()));
}
});
test("logout revokes refresh and access tokens before deleting local credentials", async () => {
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const account = credentialAccount(options);
const tokens = new MemoryTokens();
tokens.values.set(account, { access_token: "access", refresh_token: "refresh", token_type: "Bearer" });
const discovery = new MemoryDiscovery();
const requests: Array<{ url: string; body?: string }> = [];
const fetchFn = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = String(input);
requests.push({ url, body: init?.body?.toString() });
if (url.includes("oauth-protected-resource")) return Response.json({ authorization_servers: ["https://id.example/oidc"] });
if (url.includes("openid-configuration")) return Response.json({ revocation_endpoint: "https://id.example/revoke" });
return new Response(null, { status: 200 });
};
const result = await logoutRemote(options, tokens, discovery as never, false, fetchFn as typeof fetch);
assert.deepEqual(result, { hadCredential: true, revoked: true });
assert.equal(tokens.values.has(account), false);
assert.equal(requests.filter((request) => request.url.endsWith("/revoke")).length, 1);
assert.match(requests.find((request) => request.body?.includes("refresh_token"))?.body ?? "", /token=refresh/);
});
test("failed remote revocation retains local credentials", async () => {
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const account = credentialAccount(options);
const tokens = new MemoryTokens();
tokens.values.set(account, { access_token: "access", token_type: "Bearer" });
const fetchFn = async (input: string | URL | Request): Promise<Response> => {
const url = String(input);
if (url.includes("oauth-protected-resource")) return Response.json({ authorization_servers: ["https://id.example/oidc"] });
if (url.includes("openid-configuration")) return Response.json({ revocation_endpoint: "https://id.example/revoke" });
return new Response(null, { status: 500 });
};
await assert.rejects(logoutRemote(options, tokens, new MemoryDiscovery() as never, false, fetchFn as typeof fetch), /retained/);
assert.equal(tokens.values.has(account), true);
});
async function availablePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("could not allocate test port");
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
return address.port;
}