Files
cokaremote/test/auth-security.integration.test.ts
T

168 lines
5.8 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
import { startHttpServer } from "../src/http-server.js";
import { createServices } from "../src/mcp-server.js";
import { RemoteDevOAuthProvider } from "../src/oauth.js";
async function reservePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
describe("OAuth endpoint security boundaries", () => {
it("does not trust spoofed forwarded IPs unless a proxy is explicitly configured", async () => {
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), "cokacremote-auth-boundary-test-"),
);
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const config = loadConfig(
{
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key",
MCP_PUBLIC_URL: baseUrl,
MCP_OAUTH_STATE_FILE: path.join(temporaryDirectory, "oauth-state.json"),
MCP_HOST: "127.0.0.1",
MCP_PORT: String(port),
MCP_DEFAULT_CWD: temporaryDirectory,
},
temporaryDirectory,
);
const running = await startHttpServer(config, createServices(config));
try {
const approvalKeyAsBearer = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
authorization: "Bearer oauth-approval-key",
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
});
expect(approvalKeyAsBearer.status).toBe(401);
const statuses: number[] = [];
for (let index = 1; index <= 21; index += 1) {
const response = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": `203.0.113.${index}`,
},
body: JSON.stringify({
redirect_uris: ["https://chatgpt.com/connector/oauth/security-test"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: `security-test-${index}`,
scope: "mcp:tools",
}),
});
statuses.push(response.status);
}
expect(statuses.slice(0, 20)).toEqual(Array(20).fill(201));
expect(statuses[20]).toBe(429);
} finally {
await running.close();
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("rolls back failed state writes and revokes an entire token grant", async () => {
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), "cokacremote-oauth-store-test-"),
);
const baseEnvironment = {
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key",
MCP_PUBLIC_URL: "http://127.0.0.1:34567",
};
const metadata = {
client_id: "security-store-client",
redirect_uris: ["https://chatgpt.com/connector/oauth/security-store-test"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "security store test",
scope: "mcp:tools",
};
try {
const blockedDirectory = path.join(temporaryDirectory, "blocked-state-directory");
await mkdir(blockedDirectory);
const failedProvider = new RemoteDevOAuthProvider(
loadConfig(
{
...baseEnvironment,
MCP_OAUTH_STATE_FILE: path.join(blockedDirectory, "state.json"),
},
temporaryDirectory,
),
);
await expect(
failedProvider.clientsStore.getClient(metadata.client_id),
).resolves.toBeUndefined();
await rm(blockedDirectory, { recursive: true, force: true });
await writeFile(blockedDirectory, "block state persistence");
await expect(
failedProvider.clientsStore.registerClient(
metadata as Parameters<typeof failedProvider.clientsStore.registerClient>[0],
),
).rejects.toThrow();
await expect(
failedProvider.clientsStore.getClient(metadata.client_id),
).resolves.toBeUndefined();
const stateFile = path.join(temporaryDirectory, "valid-state.json");
const provider = new RemoteDevOAuthProvider(
loadConfig(
{ ...baseEnvironment, MCP_OAUTH_STATE_FILE: stateFile },
temporaryDirectory,
),
);
const client = await provider.clientsStore.registerClient(
metadata as Parameters<typeof provider.clientsStore.registerClient>[0],
);
const resource = "http://127.0.0.1:34567/mcp";
const pair = await provider.clientsStore.issueTokenPair(
client.client_id,
["mcp:tools"],
resource,
);
expect(pair.refresh_token).toBeTypeOf("string");
await provider.clientsStore.revoke(pair.refresh_token!, client.client_id);
await expect(
provider.clientsStore.getAccessToken(pair.access_token),
).resolves.toBeUndefined();
await expect(
provider.clientsStore.rotateRefreshToken(
pair.refresh_token!,
client.client_id,
resource,
undefined,
),
).resolves.toMatchObject({ status: "invalid" });
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
});