129 lines
7.2 KiB
TypeScript
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;
|
|
}
|