974 lines
34 KiB
JavaScript
974 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
|
import http from "node:http";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
const host = process.env.HOST || "127.0.0.1";
|
|
const port = Number(process.env.PORT || 3978);
|
|
const issuer = process.env.ISSUER || `http://${host}:${port}`;
|
|
const extraToolCount = Number(process.env.MOCK_EXTRA_TOOL_COUNT || 0);
|
|
const autoApprove = process.env.AUTO_APPROVE !== "0";
|
|
const disableDcr = process.env.DISABLE_DCR === "1";
|
|
const strictOAuth = process.argv.includes("--strict") || process.env.STRICT_OAUTH === "1";
|
|
// Strict mode rejects refresh tokens this instance did not issue (and
|
|
// rotates on every refresh grant). Off by default: eval flows restart the
|
|
// mock mid-scenario and legitimately present pre-restart refresh tokens.
|
|
const strictRefreshTokens = process.env.STRICT_REFRESH_TOKENS === "1";
|
|
const mockClientId = process.env.MOCK_CLIENT_ID || "mock-preregistered-client";
|
|
const mockClientSecret = process.env.MOCK_CLIENT_SECRET || "mock-preregistered-secret";
|
|
const preregisteredRedirectUris = (process.env.MOCK_REDIRECT_URIS || "")
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
const advertisedScopes = ["mcp:read", "mcp:write"];
|
|
const extraToolName = (process.env.MOCK_EXTRA_TOOL_NAME || "").trim();
|
|
const extraToolTitle = (process.env.MOCK_EXTRA_TOOL_TITLE || extraToolName).trim();
|
|
const extraToolDescription = (process.env.MOCK_EXTRA_TOOL_DESCRIPTION || "Returns a fixed result from the mock OAuth MCP server.").trim();
|
|
const extraToolResult = process.env.MOCK_EXTRA_TOOL_RESULT || "mock oauth mcp ok";
|
|
const errorToolName = (process.env.MOCK_ERROR_TOOL_NAME || "").trim();
|
|
const errorToolTitle = (process.env.MOCK_ERROR_TOOL_TITLE || errorToolName).trim();
|
|
const errorToolDescription = (process.env.MOCK_ERROR_TOOL_DESCRIPTION || "Returns a provider policy error from the mock OAuth MCP server.").trim();
|
|
const errorToolStatus = Number(process.env.MOCK_ERROR_TOOL_STATUS || 403);
|
|
const errorToolMode = (process.env.MOCK_ERROR_TOOL_MODE || "result").trim();
|
|
const errorToolConnectUrl = (process.env.MOCK_ERROR_TOOL_CONNECT_URL || "https://connect.example.test/salesforce/start").trim();
|
|
const errorToolProvider = (process.env.MOCK_ERROR_TOOL_PROVIDER || "salesforce").trim();
|
|
const allowUnauthenticatedMcp = process.env.MOCK_ALLOW_UNAUTHENTICATED_MCP === "1";
|
|
const syntheticTools = Array.from({ length: extraToolCount }, (_, index) => {
|
|
const i = index + 1;
|
|
return {
|
|
name: `mock_tool_${i}`,
|
|
description: `Synthetic scale tool ${i} for capability search volume testing; keyword kw${i}.`,
|
|
inputSchema: { type: "object", properties: {} },
|
|
};
|
|
});
|
|
|
|
const clients = new Map();
|
|
const codes = new Map();
|
|
const tokens = new Set();
|
|
const refreshTokens = new Set();
|
|
const requests = [];
|
|
const drafts = [];
|
|
let agentWorkloads = [];
|
|
|
|
const gmailThreadId = "thread-q3-launch";
|
|
|
|
function gmailBodyData(value) {
|
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
}
|
|
|
|
const gmailThreadMessages = [
|
|
{
|
|
id: "msg-q3-kickoff",
|
|
threadId: gmailThreadId,
|
|
snippet: "Hi Sarah, Thursday still works for the Q3 launch prep.",
|
|
payload: {
|
|
headers: [
|
|
{ name: "From", value: "Jordan Demo <jordan.demo@acme.test>" },
|
|
{ name: "To", value: "Sarah Chen <sarah@acme.test>" },
|
|
{ name: "Subject", value: "Q3 launch" },
|
|
{ name: "Date", value: "Mon, 13 Jul 2026 16:30:00 -0700" },
|
|
{ name: "Message-ID", value: "<kickoff-1@acme.test>" },
|
|
],
|
|
mimeType: "text/plain",
|
|
body: {
|
|
data: gmailBodyData([
|
|
"Hi Sarah,",
|
|
"Thursday still works for the Q3 launch prep.",
|
|
"I am checking the final room details now.",
|
|
"Jordan",
|
|
].join("\n")),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "msg-q3-sarah-2",
|
|
threadId: gmailThreadId,
|
|
snippet: "Are we still on for Thursday? I need to confirm the room booking by Wednesday.",
|
|
payload: {
|
|
headers: [
|
|
{ name: "From", value: "Sarah Chen <sarah@acme.test>" },
|
|
{ name: "To", value: "Jordan Demo <jordan.demo@acme.test>" },
|
|
{ name: "Subject", value: "Re: Q3 launch" },
|
|
{ name: "Date", value: "Tue, 14 Jul 2026 09:15:00 -0700" },
|
|
{ name: "Message-ID", value: "<sarah-2@acme.test>" },
|
|
{ name: "References", value: "<kickoff-1@acme.test>" },
|
|
],
|
|
mimeType: "text/plain",
|
|
body: {
|
|
data: gmailBodyData([
|
|
"Are we still on for Thursday?",
|
|
"I need to confirm the room booking by Wednesday.",
|
|
"Also bringing the updated launch checklist.",
|
|
"Sarah",
|
|
].join("\n")),
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const gmailMessagesById = new Map(gmailThreadMessages.map((message) => [message.id, message]));
|
|
|
|
function gmailMessageShape(message, format) {
|
|
return {
|
|
id: message.id,
|
|
threadId: message.threadId,
|
|
snippet: message.snippet,
|
|
payload: format === "full" ? message.payload : { headers: message.payload.headers },
|
|
};
|
|
}
|
|
|
|
function json(res, status, body, headers = {}) {
|
|
res.writeHead(status, {
|
|
"access-control-allow-origin": "*",
|
|
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version",
|
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
"content-type": "application/json",
|
|
...headers,
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function text(res, status, body, headers = {}) {
|
|
res.writeHead(status, {
|
|
"access-control-allow-origin": "*",
|
|
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
"content-type": "text/html; charset=utf-8",
|
|
...headers,
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let raw = "";
|
|
req.on("data", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on("end", () => resolve(raw));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function agentContentText(value) {
|
|
if (typeof value === "string") return value;
|
|
if (Array.isArray(value)) return value.map(agentContentText).filter(Boolean).join("\n");
|
|
if (!value || typeof value !== "object") return "";
|
|
if (typeof value.text === "string") return value.text;
|
|
return agentContentText(value.content);
|
|
}
|
|
|
|
function validateAgentWorkloads(value) {
|
|
if (!Array.isArray(value)) throw new Error("agent workloads must be an array");
|
|
const markers = new Set();
|
|
return value.map((workload) => {
|
|
if (!workload || typeof workload !== "object") throw new Error("agent workload must be an object");
|
|
const promptMarker = typeof workload.promptMarker === "string" ? workload.promptMarker.trim() : "";
|
|
const finalReply = typeof workload.finalReply === "string" ? workload.finalReply : "";
|
|
if (!promptMarker || !finalReply || !Array.isArray(workload.steps) || workload.steps.length === 0) {
|
|
throw new Error("agent workload needs promptMarker, finalReply, and at least one step");
|
|
}
|
|
if (markers.has(promptMarker)) throw new Error(`duplicate agent workload marker: ${promptMarker}`);
|
|
markers.add(promptMarker);
|
|
const steps = workload.steps.map((step) => {
|
|
if (!step || typeof step !== "object" || typeof step.tool !== "string" || !step.tool.trim()) {
|
|
throw new Error(`agent workload ${promptMarker} has an invalid tool step`);
|
|
}
|
|
if (!step.arguments || typeof step.arguments !== "object" || Array.isArray(step.arguments)) {
|
|
throw new Error(`agent workload ${promptMarker} tool ${step.tool} needs object arguments`);
|
|
}
|
|
return { tool: step.tool.trim(), arguments: structuredClone(step.arguments) };
|
|
});
|
|
return { promptMarker, finalReply, steps };
|
|
});
|
|
}
|
|
|
|
function offeredAgentTool(body, wanted) {
|
|
if (!Array.isArray(body?.tools)) return null;
|
|
const names = body.tools.flatMap((tool) => (
|
|
tool && typeof tool === "object" && typeof tool.function?.name === "string"
|
|
? [tool.function.name]
|
|
: []
|
|
));
|
|
return names.find((name) => name === wanted)
|
|
?? names.find((name) => name.endsWith(`_${wanted}`))
|
|
?? null;
|
|
}
|
|
|
|
function agentStream(res, model, chunks) {
|
|
res.writeHead(200, {
|
|
"access-control-allow-origin": "*",
|
|
"content-type": "text/event-stream",
|
|
"cache-control": "no-cache",
|
|
connection: "keep-alive",
|
|
});
|
|
let delayMs = 150;
|
|
for (const chunk of chunks) {
|
|
setTimeout(() => {
|
|
if (!res.writableEnded) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
}, delayMs);
|
|
delayMs += 150;
|
|
}
|
|
setTimeout(() => {
|
|
if (!res.writableEnded) res.end("data: [DONE]\n\n");
|
|
}, delayMs);
|
|
}
|
|
|
|
function agentChunk(model, delta, finishReason = null) {
|
|
return {
|
|
id: `chatcmpl-mock-agent-${randomUUID()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
};
|
|
}
|
|
|
|
async function handleAgentCompletion(req, res, entry) {
|
|
const body = await readJson(req);
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
json(res, 400, { error: { message: "completion body must be an object" } });
|
|
return;
|
|
}
|
|
const model = typeof body.model === "string" ? body.model : "mock-agent-workload-model";
|
|
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
const conversationText = messages.map(agentContentText).join("\n");
|
|
const matchedMarkers = agentWorkloads
|
|
.filter((workload) => conversationText.includes(workload.promptMarker))
|
|
.map((workload) => workload.promptMarker);
|
|
const completedTools = messages.filter((message) => message && typeof message === "object" && message.role === "tool").length;
|
|
const baseRequest = { model, matchedMarkers, completedTools };
|
|
|
|
if (!Array.isArray(body.tools) || body.tools.length === 0) {
|
|
entry.agentCompletion = { ...baseRequest, kind: "utility", promptMarker: matchedMarkers[0] ?? null, toolName: null, arguments: {} };
|
|
agentStream(res, model, [
|
|
agentChunk(model, { role: "assistant" }),
|
|
agentChunk(model, { content: "Active session workload" }),
|
|
agentChunk(model, {}, "stop"),
|
|
]);
|
|
return;
|
|
}
|
|
if (matchedMarkers.length !== 1) {
|
|
entry.agentCompletion = { ...baseRequest, kind: "error", promptMarker: null, toolName: null, arguments: {} };
|
|
json(res, 400, { error: { message: `expected one workload marker, found ${matchedMarkers.length}` } });
|
|
return;
|
|
}
|
|
const workload = agentWorkloads.find((candidate) => candidate.promptMarker === matchedMarkers[0]);
|
|
if (!workload) throw new Error("matched agent workload disappeared");
|
|
if (completedTools >= workload.steps.length) {
|
|
entry.agentCompletion = { ...baseRequest, kind: "final", promptMarker: workload.promptMarker, toolName: null, arguments: {} };
|
|
agentStream(res, model, [
|
|
agentChunk(model, { role: "assistant" }),
|
|
agentChunk(model, { content: workload.finalReply }),
|
|
agentChunk(model, {}, "stop"),
|
|
]);
|
|
return;
|
|
}
|
|
const step = workload.steps[completedTools];
|
|
const toolName = offeredAgentTool(body, step.tool);
|
|
if (!toolName) {
|
|
entry.agentCompletion = { ...baseRequest, kind: "error", promptMarker: workload.promptMarker, toolName: step.tool, arguments: step.arguments };
|
|
json(res, 400, { error: { message: `tool ${step.tool} was not offered to the mock agent` } });
|
|
return;
|
|
}
|
|
const callId = `call_${workload.promptMarker.replace(/[^a-zA-Z0-9_-]/g, "_")}_${completedTools + 1}`;
|
|
entry.agentCompletion = {
|
|
...baseRequest,
|
|
kind: "tool",
|
|
promptMarker: workload.promptMarker,
|
|
toolName,
|
|
arguments: step.arguments,
|
|
};
|
|
agentStream(res, model, [
|
|
agentChunk(model, { role: "assistant" }),
|
|
agentChunk(model, {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: callId,
|
|
type: "function",
|
|
function: { name: toolName, arguments: JSON.stringify(step.arguments) },
|
|
}],
|
|
}),
|
|
agentChunk(model, {}, "tool_calls"),
|
|
]);
|
|
}
|
|
|
|
async function readJson(req) {
|
|
const raw = await readBody(req);
|
|
if (!raw.trim()) return {};
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
async function readForm(req) {
|
|
const raw = await readBody(req);
|
|
return Object.fromEntries(new URLSearchParams(raw));
|
|
}
|
|
|
|
function record(req, url) {
|
|
const entry = {
|
|
id: requests.length + 1,
|
|
method: req.method,
|
|
path: url.pathname,
|
|
url: `${url.pathname}${url.search}`,
|
|
at: new Date().toISOString(),
|
|
};
|
|
requests.push(entry);
|
|
console.log(`[mock-oauth-mcp] ${entry.method} ${entry.path}`);
|
|
return entry;
|
|
}
|
|
|
|
function protectedResourceMetadata() {
|
|
return {
|
|
resource: `${issuer}/mcp`,
|
|
authorization_servers: [issuer],
|
|
scopes_supported: advertisedScopes,
|
|
bearer_methods_supported: ["header"],
|
|
};
|
|
}
|
|
|
|
function authorizationServerMetadata() {
|
|
return {
|
|
issuer,
|
|
authorization_endpoint: `${issuer}/authorize`,
|
|
token_endpoint: `${issuer}/token`,
|
|
...(disableDcr ? {} : { registration_endpoint: `${issuer}/register` }),
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"],
|
|
code_challenge_methods_supported: ["S256", "plain"],
|
|
scopes_supported: advertisedScopes,
|
|
};
|
|
}
|
|
|
|
function basicClient(req) {
|
|
const header = req.headers.authorization || "";
|
|
if (header.length < 7 || header.slice(0, 6).toLowerCase() !== "basic ") return null;
|
|
let credentialStart = 6;
|
|
while (header[credentialStart] === " ") credentialStart += 1;
|
|
if (credentialStart === header.length) return null;
|
|
const decoded = Buffer.from(header.slice(credentialStart), "base64").toString("utf8");
|
|
const separator = decoded.indexOf(":");
|
|
if (separator === -1) return { clientId: decoded, clientSecret: "" };
|
|
return {
|
|
clientId: decoded.slice(0, separator),
|
|
clientSecret: decoded.slice(separator + 1),
|
|
};
|
|
}
|
|
|
|
function rejectInvalidPreregisteredClient(res) {
|
|
json(res, 400, { error: "invalid_client" });
|
|
}
|
|
|
|
function requirePreregisteredAuthorizeClient(res, params) {
|
|
if (!disableDcr) return true;
|
|
if (params.get("client_id") === mockClientId) return true;
|
|
rejectInvalidPreregisteredClient(res);
|
|
return false;
|
|
}
|
|
|
|
function requireStrictAuthorizeContract(res, params) {
|
|
if (!strictOAuth) return true;
|
|
const clientId = params.get("client_id") || "";
|
|
const redirectUri = params.get("redirect_uri") || "";
|
|
const registeredRedirects = clients.get(clientId)?.redirect_uris
|
|
?? (clientId === mockClientId ? preregisteredRedirectUris : []);
|
|
if (!redirectUri || !registeredRedirects.includes(redirectUri)) {
|
|
json(res, 400, {
|
|
error: "invalid_request",
|
|
error_description: "redirect_uri did not match any configured URIs",
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const scopes = (params.get("scope") || "").split(/\s+/).filter(Boolean);
|
|
if (scopes.length === 0 || scopes.some((scope) => !advertisedScopes.includes(scope))) {
|
|
json(res, 400, {
|
|
error: "invalid_scope",
|
|
error_description: "scope is required and must be advertised",
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function requirePreregisteredTokenClient(req, res, form, grant) {
|
|
if (!disableDcr) return true;
|
|
const basic = basicClient(req);
|
|
const clientId = basic?.clientId || form.client_id || grant?.clientId || "";
|
|
if (clientId !== mockClientId) {
|
|
rejectInvalidPreregisteredClient(res);
|
|
return false;
|
|
}
|
|
const suppliedSecret = basic?.clientSecret ?? form.client_secret;
|
|
if (suppliedSecret !== undefined && suppliedSecret !== mockClientSecret) {
|
|
rejectInvalidPreregisteredClient(res);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function redirectWithCode(res, params) {
|
|
const redirectUri = params.get("redirect_uri");
|
|
if (!redirectUri) {
|
|
json(res, 400, { error: "invalid_request", error_description: "redirect_uri is required" });
|
|
return;
|
|
}
|
|
|
|
const code = `mock-code-${randomUUID()}`;
|
|
codes.set(code, {
|
|
clientId: params.get("client_id") || "mock-client",
|
|
codeChallenge: params.get("code_challenge") || null,
|
|
codeChallengeMethod: params.get("code_challenge_method") || "plain",
|
|
scope: params.get("scope") || "mcp:read mcp:write",
|
|
});
|
|
|
|
const callback = new URL(redirectUri);
|
|
callback.searchParams.set("code", code);
|
|
const state = params.get("state");
|
|
if (state) callback.searchParams.set("state", state);
|
|
|
|
res.writeHead(302, { location: callback.toString() });
|
|
res.end();
|
|
}
|
|
|
|
function authorize(req, res, url) {
|
|
if (!requirePreregisteredAuthorizeClient(res, url.searchParams)) {
|
|
return;
|
|
}
|
|
if (!requireStrictAuthorizeContract(res, url.searchParams)) {
|
|
return;
|
|
}
|
|
if (autoApprove && url.searchParams.get("force_consent") !== "1") {
|
|
redirectWithCode(res, url.searchParams);
|
|
return;
|
|
}
|
|
|
|
const approveUrl = new URL(`${issuer}/approve`);
|
|
for (const [key, value] of url.searchParams) approveUrl.searchParams.set(key, value);
|
|
const requestedScopes = (url.searchParams.get("scope") || "").split(/\s+/).filter(Boolean);
|
|
const requestedScopesHtml = requestedScopes.length > 0
|
|
? `<h2>Requested scopes</h2><ul>${requestedScopes.map((scope) => `<li><code>${escapeHtml(scope)}</code></li>`).join("")}</ul>`
|
|
: "";
|
|
text(res, 200, `<!doctype html>
|
|
<html>
|
|
<head><title>Mock MCP OAuth</title></head>
|
|
<body style="font-family: system-ui, sans-serif; max-width: 560px; margin: 48px auto;">
|
|
<h1>Mock MCP OAuth</h1>
|
|
<p>This fake OAuth provider is for OpenWork MCP end-to-end tests.</p>
|
|
${requestedScopesHtml}
|
|
<form method="post" action="${escapeHtml(`${approveUrl.pathname}${approveUrl.search}`)}">
|
|
<button style="font: inherit; padding: 10px 14px;">Approve OpenWork</button>
|
|
</form>
|
|
</body>
|
|
</html>`);
|
|
}
|
|
|
|
async function registerClient(req, res, entry) {
|
|
if (disableDcr) {
|
|
json(res, 404, { error: "not_found" });
|
|
return;
|
|
}
|
|
const body = await readJson(req).catch(() => ({}));
|
|
if (entry) {
|
|
// Keep conformance evidence useful without recording credentials. These
|
|
// are the public RFC 7591 fields OpenWork is expected to send.
|
|
entry.registration = {
|
|
application_type: body.application_type ?? null,
|
|
redirect_uris: Array.isArray(body.redirect_uris) ? body.redirect_uris : [],
|
|
grant_types: Array.isArray(body.grant_types) ? body.grant_types : [],
|
|
response_types: Array.isArray(body.response_types) ? body.response_types : [],
|
|
scope: typeof body.scope === "string" ? body.scope : null,
|
|
token_endpoint_auth_method: body.token_endpoint_auth_method ?? null,
|
|
};
|
|
}
|
|
const clientId = `mock-client-${randomUUID()}`;
|
|
const client = {
|
|
client_id: clientId,
|
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
token_endpoint_auth_method: body.token_endpoint_auth_method || "none",
|
|
redirect_uris: Array.isArray(body.redirect_uris) ? body.redirect_uris : [],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
scope: "mcp:read mcp:write",
|
|
};
|
|
clients.set(clientId, client);
|
|
json(res, 201, client);
|
|
}
|
|
|
|
async function issueToken(req, res, entry) {
|
|
const form = await readForm(req);
|
|
const grantType = form.grant_type || "authorization_code";
|
|
if (entry) entry.grantType = grantType;
|
|
let grantedScope = "mcp:read mcp:write";
|
|
|
|
if (grantType === "authorization_code") {
|
|
const grant = codes.get(form.code);
|
|
if (!grant) {
|
|
json(res, 400, { error: "invalid_grant" });
|
|
return;
|
|
}
|
|
if (!requirePreregisteredTokenClient(req, res, form, grant)) {
|
|
return;
|
|
}
|
|
grantedScope = grant.scope;
|
|
if (grant.codeChallenge) {
|
|
const verifier = form.code_verifier || "";
|
|
const expected =
|
|
grant.codeChallengeMethod === "S256"
|
|
? createHash("sha256").update(verifier).digest("base64url")
|
|
: verifier;
|
|
if (expected !== grant.codeChallenge) {
|
|
json(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
|
|
return;
|
|
}
|
|
}
|
|
} else if (grantType === "refresh_token") {
|
|
if (!requirePreregisteredTokenClient(req, res, form, null)) {
|
|
return;
|
|
}
|
|
if (strictRefreshTokens) {
|
|
if (!form.refresh_token || !refreshTokens.has(form.refresh_token)) {
|
|
json(res, 400, { error: "invalid_grant", error_description: "unknown refresh token" });
|
|
return;
|
|
}
|
|
// Rotate, like real providers (and the Den) do: the old refresh token
|
|
// dies with this exchange, so the client must persist the replacement.
|
|
refreshTokens.delete(form.refresh_token);
|
|
}
|
|
} else if (!requirePreregisteredTokenClient(req, res, form, null)) {
|
|
return;
|
|
}
|
|
|
|
if (form.code) codes.delete(form.code);
|
|
const accessToken = `mock-access-${randomUUID()}`;
|
|
tokens.add(accessToken);
|
|
const refreshToken = `mock-refresh-${randomUUID()}`;
|
|
refreshTokens.add(refreshToken);
|
|
json(res, 200, {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
token_type: "Bearer",
|
|
expires_in: 3600,
|
|
scope: grantedScope,
|
|
});
|
|
}
|
|
|
|
function isAuthorized(req) {
|
|
if (allowUnauthenticatedMcp) return true;
|
|
const token = bearerToken(req);
|
|
return Boolean(token && tokens.has(token));
|
|
}
|
|
|
|
/** Linear-time bearer parse — `\s+(.+)` backtracks polynomially on header spam. */
|
|
function bearerToken(req) {
|
|
const header = req.headers.authorization || "";
|
|
if (!/^bearer /i.test(header)) return null;
|
|
return header.slice("bearer ".length).trim() || null;
|
|
}
|
|
|
|
/**
|
|
* A stable, non-secret fingerprint of the caller's bearer token.
|
|
*
|
|
* Per-member credential modes issue a DIFFERENT token per person, so distinct
|
|
* fingerprints are how a spec proves one member's credential was not reused for
|
|
* another. Never log the token itself.
|
|
*/
|
|
function tokenFingerprint(req) {
|
|
const token = bearerToken(req);
|
|
if (!token) return null;
|
|
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
}
|
|
|
|
function mcpResult(message) {
|
|
switch (message.method) {
|
|
case "initialize":
|
|
return {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "mock-oauth-mcp", version: "1.0.0" },
|
|
};
|
|
case "tools/list":
|
|
return {
|
|
tools: [
|
|
{
|
|
name: "mock_echo",
|
|
title: "Mock Echo",
|
|
description: "Echoes the provided text from the mock OAuth MCP server.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { text: { type: "string" } },
|
|
required: ["text"],
|
|
},
|
|
annotations: {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
},
|
|
},
|
|
{
|
|
name: "mock_batch",
|
|
description: "Echo a batch of items (nested schema for form-fallback testing).",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
items: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: { text: { type: "string" } },
|
|
},
|
|
},
|
|
},
|
|
required: ["items"],
|
|
},
|
|
},
|
|
...syntheticTools,
|
|
...(extraToolName ? [{
|
|
name: extraToolName,
|
|
title: extraToolTitle || extraToolName,
|
|
description: extraToolDescription,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
channel: { type: "string" },
|
|
unresolved: { type: "string" },
|
|
},
|
|
},
|
|
}] : []),
|
|
...(errorToolName ? [{
|
|
name: errorToolName,
|
|
title: errorToolTitle || errorToolName,
|
|
description: errorToolDescription,
|
|
inputSchema: { type: "object", properties: {} },
|
|
}] : []),
|
|
],
|
|
};
|
|
case "tools/call":
|
|
if (message.params?.name === "mock_batch") {
|
|
const items = message.params?.arguments?.items;
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Received ${Array.isArray(items) ? items.length : 0} items.`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (syntheticTools.some((tool) => tool.name === message.params?.name)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `${message.params.name} ok`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (errorToolName && message.params?.name === errorToolName) {
|
|
return {
|
|
isError: true,
|
|
structuredContent: {
|
|
providerStatus: Number.isFinite(errorToolStatus) ? errorToolStatus : 403,
|
|
category: "provider_policy",
|
|
providerCode: "access_denied",
|
|
},
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "The provider rejected this operation because administrator approval is required.",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (extraToolName && message.params?.name === extraToolName) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: extraToolResult,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: String(message.params?.arguments?.text ?? "mock oauth mcp ok"),
|
|
},
|
|
],
|
|
};
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function mcpResponse(message) {
|
|
if (
|
|
errorToolMode === "authorization_required"
|
|
&& errorToolName
|
|
&& message.method === "tools/call"
|
|
&& message.params?.name === errorToolName
|
|
) {
|
|
const connectLink = `[${errorToolConnectUrl}](${errorToolConnectUrl})`;
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
error: {
|
|
code: -32001,
|
|
message: `Authorization required — connect your ${errorToolProvider} account to use this connector. Open ${connectLink} in a browser, sign in, then retry this request.`,
|
|
data: {
|
|
connect_url: errorToolConnectUrl,
|
|
provider: errorToolProvider,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return { jsonrpc: "2.0", id: message.id, result: mcpResult(message) };
|
|
}
|
|
|
|
async function handleMcp(req, res, entry) {
|
|
const body = await readJson(req).catch(() => ({}));
|
|
const messages = Array.isArray(body) ? body : [body];
|
|
entry.rpcMethods = messages
|
|
.filter((message) => message && typeof message === "object" && typeof message.method === "string")
|
|
.map((message) => message.method);
|
|
|
|
const authorized = isAuthorized(req);
|
|
if (!authorized) {
|
|
json(res, 401, { error: "missing_mcp_token" }, {
|
|
"www-authenticate": `Bearer resource_metadata="${issuer}/.well-known/oauth-protected-resource"`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET") {
|
|
json(res, 405, { error: "method_not_allowed" });
|
|
return;
|
|
}
|
|
|
|
entry.authorized = authorized;
|
|
entry.toolNames = messages
|
|
.filter((message) => message && typeof message === "object" && message.method === "tools/call" && typeof message.params?.name === "string")
|
|
.map((message) => message.params.name);
|
|
// Arguments + a token fingerprint make the connector the AUTHORITY on who
|
|
// called it: a spec can prove two members each invoked a tool with their own
|
|
// credential, without trusting the app's own UI state.
|
|
entry.tokenId = tokenFingerprint(req);
|
|
entry.toolCalls = messages
|
|
.filter((message) => message && typeof message === "object" && message.method === "tools/call" && typeof message.params?.name === "string")
|
|
.map((message) => ({
|
|
name: message.params.name,
|
|
args: message.params.arguments ?? message.params.args ?? {},
|
|
tokenId: entry.tokenId,
|
|
}));
|
|
const responses = messages.flatMap((message) => {
|
|
if (!message || typeof message !== "object" || message.id === undefined) return [];
|
|
return [mcpResponse(message)];
|
|
});
|
|
|
|
if (responses.length === 0) {
|
|
res.writeHead(202, { "access-control-allow-origin": "*" });
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
json(res, 200, Array.isArray(body) ? responses : responses[0]);
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url || "/", issuer);
|
|
const entry = record(req, url);
|
|
|
|
if (req.method === "OPTIONS") {
|
|
json(res, 204, {});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/health") {
|
|
json(res, 200, { ok: true, issuer, autoApprove, disableDcr, requests: requests.length });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/requests") {
|
|
json(res, 200, { requests });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/admin/agent-workloads" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
agentWorkloads = validateAgentWorkloads(body?.workloads);
|
|
json(res, 200, { configured: agentWorkloads.length });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
json(res, 200, {
|
|
object: "list",
|
|
data: [{ id: "mock-agent-workload-model", object: "model", owned_by: "openwork-testkit" }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
req.method === "POST"
|
|
&& (url.pathname === "/v1/chat/completions" || url.pathname === "/chat/completions")
|
|
) {
|
|
await handleAgentCompletion(req, res, entry);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
url.pathname === "/.well-known/oauth-protected-resource" ||
|
|
url.pathname === "/.well-known/oauth-protected-resource/mcp" ||
|
|
url.pathname === "/mcp/.well-known/oauth-protected-resource"
|
|
) {
|
|
json(res, 200, protectedResourceMetadata());
|
|
return;
|
|
}
|
|
|
|
if (
|
|
url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
url.pathname === "/.well-known/oauth-authorization-server/mcp"
|
|
) {
|
|
json(res, 200, authorizationServerMetadata());
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/register" && req.method === "POST") {
|
|
await registerClient(req, res, entry);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/authorize" && req.method === "GET") {
|
|
authorize(req, res, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/approve" && req.method === "POST") {
|
|
redirectWithCode(res, url.searchParams);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/token" && req.method === "POST") {
|
|
await issueToken(req, res, entry);
|
|
return;
|
|
}
|
|
|
|
// Test hook: kill every live access token (refresh grants stay valid),
|
|
// so the next authenticated MCP call gets a 401 challenge — the same
|
|
// thing a client sees in production when its access token expires.
|
|
if (url.pathname === "/admin/expire-access-tokens" && req.method === "POST") {
|
|
const expired = tokens.size;
|
|
tokens.clear();
|
|
json(res, 200, { expired });
|
|
return;
|
|
}
|
|
|
|
// Test hook: invalidate both access and refresh credentials. With
|
|
// STRICT_REFRESH_TOKENS=1 the next authenticated MCP operation follows
|
|
// the production-shaped 401 -> refresh -> invalid_grant path.
|
|
if (url.pathname === "/admin/expire-oauth-tokens" && req.method === "POST") {
|
|
const expiredAccessTokens = tokens.size;
|
|
const expiredRefreshTokens = refreshTokens.size;
|
|
tokens.clear();
|
|
refreshTokens.clear();
|
|
json(res, 200, { expiredAccessTokens, expiredRefreshTokens });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/mcp") {
|
|
await handleMcp(req, res, entry);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/gmail/v1/users/me/messages" && req.method === "GET") {
|
|
if (!isAuthorized(req)) {
|
|
json(res, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
|
return;
|
|
}
|
|
const messages = [...gmailThreadMessages].reverse().map((message) => ({
|
|
id: message.id,
|
|
threadId: message.threadId,
|
|
}));
|
|
json(res, 200, { messages, resultSizeEstimate: messages.length });
|
|
return;
|
|
}
|
|
|
|
const gmailMessageMatch = url.pathname.match(/^\/gmail\/v1\/users\/me\/messages\/([^/]+)$/);
|
|
if (gmailMessageMatch && req.method === "GET") {
|
|
if (!isAuthorized(req)) {
|
|
json(res, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
|
return;
|
|
}
|
|
const message = gmailMessagesById.get(decodeURIComponent(gmailMessageMatch[1]));
|
|
if (!message) {
|
|
json(res, 404, { error: { code: 404, message: "Message not found" } });
|
|
return;
|
|
}
|
|
json(res, 200, gmailMessageShape(message, url.searchParams.get("format")));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === `/gmail/v1/users/me/threads/${gmailThreadId}` && req.method === "GET") {
|
|
if (!isAuthorized(req)) {
|
|
json(res, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
|
return;
|
|
}
|
|
json(res, 200, {
|
|
id: gmailThreadId,
|
|
messages: gmailThreadMessages.map((message) => ({
|
|
id: message.id,
|
|
threadId: message.threadId,
|
|
payload: message.payload,
|
|
})),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Minimal Gmail drafts.create stand-in so the org Google Workspace flow
|
|
// can be proven end-to-end: requires a token this mock issued, records
|
|
// the request (external witness), returns Gmail-shaped ids.
|
|
if (url.pathname === "/gmail/v1/users/me/drafts" && req.method === "POST") {
|
|
if (!isAuthorized(req)) {
|
|
json(res, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
|
return;
|
|
}
|
|
const body = await readJson(req).catch(() => ({}));
|
|
const raw = typeof body?.message?.raw === "string" ? body.message.raw : "";
|
|
const threadId = typeof body?.message?.threadId === "string" ? body.message.threadId : null;
|
|
drafts.push({ raw, threadId, at: new Date().toISOString() });
|
|
json(res, 200, {
|
|
id: `draft-${randomUUID()}`,
|
|
message: { id: `msg-${randomUUID()}`, threadId: threadId || `thread-${randomUUID()}` },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/gmail/drafts-log") {
|
|
json(res, 200, { drafts });
|
|
return;
|
|
}
|
|
|
|
json(res, 404, { error: "not_found" });
|
|
} catch (error) {
|
|
console.error("[mock-oauth-mcp] request failed", error);
|
|
json(res, 500, { error: "internal_server_error" });
|
|
}
|
|
});
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`[mock-oauth-mcp] listening on ${issuer}`);
|
|
console.log(`[mock-oauth-mcp] MCP URL: ${issuer}/mcp`);
|
|
console.log(`[mock-oauth-mcp] set AUTO_APPROVE=0 to require an approval click`);
|
|
});
|