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(); 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 { this.values.delete(account); } } class MemoryDiscovery { values = new Map(); async get(account: string): Promise { return this.values.get(account) as undefined; } async set(account: string, state: unknown): Promise { this.values.set(account, state); } async delete(account: string): Promise { 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((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((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 => { 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 => { 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 { const server = createServer(); await new Promise((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((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); return address.port; }