Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 100 additions & 20 deletions packages/coding-agent/src/step/mcp-oauth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import lockfile from "proper-lockfile";
import { openBrowser } from "../utils/open-browser.ts";
import { resolveStepConfigRoot } from "./environment.ts";

Expand Down Expand Up @@ -38,7 +40,7 @@ export function createStoredMcpOAuthProvider(
clientInformation(): OAuthClientInformationMixed | undefined;
tokens(): OAuthTokens | undefined;
saveClientInformation(value: OAuthClientInformationMixed): void;
saveTokens(value: OAuthTokens): void;
saveTokens(value: OAuthTokens): Promise<void>;
redirectToAuthorization(): never;
saveCodeVerifier(value: string): void;
codeVerifier(): string;
Expand All @@ -60,10 +62,8 @@ export function createStoredMcpOAuthProvider(
saveClientInformation: (value) => {
clientInformation = value;
},
saveTokens: (tokens) => {
const store = loadStore(env);
store[key(name, serverUrl)] = { serverName: name, serverUrl, clientInformation, tokens };
saveStore(env, store);
saveTokens: async (tokens) => {
await saveCredentialTokens(env, name, serverUrl, clientInformation, tokens);
},
redirectToAuthorization: () => {
throw new Error(`MCP server '${name}' requires login; run step mcp login ${name}`);
Expand Down Expand Up @@ -94,6 +94,77 @@ function key(name: string, url: string): string {
return `${name}|${url}`;
}

function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}

function acquireCredentialStoreLock(path: string): () => void {
const maxAttempts = 100;
const delayMs = 20;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return lockfile.lockSync(path, { realpath: false });
} catch (error) {
if (errorCode(error) !== "ELOCKED" || attempt === maxAttempts) throw error;
const started = Date.now();
while (Date.now() - started < delayMs) {
// Keep the synchronous logout API while a peer writer finishes.
}
}
}
throw new Error("Failed to acquire MCP OAuth credential store lock");
}

function withCredentialStoreLock<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
mkdirSync(resolveStepConfigRoot(env), { recursive: true, mode: 0o700 });
const release = acquireCredentialStoreLock(credentialsPath(env));
try {
return fn();
} finally {
release();
}
}

async function acquireCredentialStoreLockAsync(path: string): Promise<() => Promise<void>> {
const timeoutMs = 30_000;
const delayMs = 20;
const deadline = Date.now() + timeoutMs;
while (true) {
try {
return await lockfile.lock(path, { realpath: false, retries: 0, stale: 60_000 });
} catch (error) {
if (errorCode(error) !== "ELOCKED" || Date.now() >= deadline) throw error;
await sleep(delayMs);
}
}
}

async function withCredentialStoreLockAsync<T>(env: NodeJS.ProcessEnv, fn: () => T | Promise<T>): Promise<T> {
mkdirSync(resolveStepConfigRoot(env), { recursive: true, mode: 0o700 });
const release = await acquireCredentialStoreLockAsync(credentialsPath(env));
try {
return await fn();
} finally {
await release();
}
}

async function saveCredentialTokens(
env: NodeJS.ProcessEnv,
name: string,
serverUrl: string,
clientInformation: OAuthClientInformationMixed | undefined,
tokens: OAuthTokens,
): Promise<void> {
await withCredentialStoreLockAsync(env, () => {
const store = loadStore(env);
store[key(name, serverUrl)] = { serverName: name, serverUrl, clientInformation, tokens };
saveStore(env, store);
});
}

function loadStore(env: NodeJS.ProcessEnv): CredentialStore {
const path = credentialsPath(env);
if (!existsSync(path)) return {};
Expand All @@ -104,9 +175,18 @@ function loadStore(env: NodeJS.ProcessEnv): CredentialStore {
function saveStore(env: NodeJS.ProcessEnv, store: CredentialStore): void {
const path = credentialsPath(env);
mkdirSync(resolveStepConfigRoot(env), { recursive: true, mode: 0o700 });
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
renameSync(temporary, path);
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
try {
writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
renameSync(temporary, path);
} catch (error) {
try {
unlinkSync(temporary);
} catch {
// Cleanup is best effort and must not mask the original write or rename failure.
}
throw error;
}
}

export async function loginMcpServer(
Expand Down Expand Up @@ -181,10 +261,8 @@ export async function loginMcpServer(
},
state: () => state,
tokens: () => loadStore(env)[key(name, serverUrl)]?.tokens,
saveTokens: (tokens: OAuthTokens) => {
const store = loadStore(env);
store[key(name, serverUrl)] = { serverName: name, serverUrl, clientInformation, tokens };
saveStore(env, store);
saveTokens: async (tokens: OAuthTokens) => {
await saveCredentialTokens(env, name, serverUrl, clientInformation, tokens);
},
redirectToAuthorization: (url: URL) => {
process.stderr.write(`Open this URL to authorize ${name}:\n${url}\n`);
Expand Down Expand Up @@ -278,10 +356,12 @@ function matchesState(received: string | null, expected: string): boolean {
}

export function logoutMcpServer(name: string, serverUrl: string, env: NodeJS.ProcessEnv = process.env): boolean {
const store = loadStore(env);
const entry = key(name, serverUrl);
if (!store[entry]) return false;
delete store[entry];
saveStore(env, store);
return true;
return withCredentialStoreLock(env, () => {
const store = loadStore(env);
const entry = key(name, serverUrl);
if (!store[entry]) return false;
delete store[entry];
saveStore(env, store);
return true;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFileSync, renameSync, writeFileSync } from "node:fs";
import process from "node:process";
import lockfile from "proper-lockfile";

const path = process.env.MCP_OAUTH_CREDENTIALS_PATH;
const serverName = process.env.MCP_OAUTH_SERVER_NAME;
const serverUrl = process.env.MCP_OAUTH_SERVER_URL;
if (!path || !serverName || !serverUrl) throw new Error("Missing MCP OAuth lock holder environment");

const release = await lockfile.lock(path, { realpath: false, stale: 60_000 });
process.send?.({ type: "ready", pid: process.pid });

process.on("message", async (message: { type?: string; holdMs?: number }) => {
if (message.type !== "write" || typeof message.holdMs !== "number") return;
try {
const started = Date.now();
while (Date.now() - started < message.holdMs) {
// Simulate a slow synchronous read-modify-write that blocks lock heartbeat.
}
const store = JSON.parse(readFileSync(path, "utf8"));
store[`${serverName}|${serverUrl}`] = {
serverName,
serverUrl,
tokens: { access_token: `token-${serverName}`, token_type: "Bearer" },
};
const temporary = `${path}.${process.pid}.holder.tmp`;
writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
renameSync(temporary, path);
process.send?.({ type: "released" });
} catch (error) {
process.send?.({ type: "save-error", error: error instanceof Error ? error.stack : String(error) });
} finally {
await release();
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs, { existsSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { join } from "node:path";
import process from "node:process";
import lockfile from "proper-lockfile";

const moduleUrl = process.env.MCP_OAUTH_MODULE;
const credentialsPath = process.env.MCP_OAUTH_CREDENTIALS_PATH;
const barrierDir = process.env.MCP_OAUTH_BARRIER_DIR;
const serverName = process.env.MCP_OAUTH_SERVER_NAME;
const serverUrl = process.env.MCP_OAUTH_SERVER_URL;
if (!moduleUrl || !credentialsPath || !barrierDir || !serverName || !serverUrl) {
throw new Error("Missing MCP OAuth child fixture environment");
}

let armedRound: number | undefined;
const originalReadFileSync = fs.readFileSync;
fs.readFileSync = ((...args: unknown[]) => {
const result: unknown = Reflect.apply(originalReadFileSync, fs, args);
if (armedRound !== undefined && String(args[0]) === credentialsPath) {
writeFileSync(join(barrierDir, `read-${serverName}-${armedRound}`), "");
const releasePath = join(barrierDir, `release-${serverName}-${armedRound}`);
while (!existsSync(releasePath)) {
// The parent releases this read only after the competing writer is gated too.
}
}
return result;
}) as typeof fs.readFileSync;
syncBuiltinESMExports();

const originalLock = lockfile.lock;
lockfile.lock = (async (...args: unknown[]) => {
const release: unknown = await Reflect.apply(originalLock, lockfile, args);
if (armedRound !== undefined) {
writeFileSync(join(barrierDir, `lock-${serverName}-${armedRound}`), "");
}
return release;
}) as typeof lockfile.lock;

const { createStoredMcpOAuthProvider } = await import(moduleUrl);
const provider = createStoredMcpOAuthProvider(serverName, serverUrl, process.env);

process.on("message", async (message: { type?: string; round?: number; token?: string }) => {
if (message.type === "exit") {
process.exit(0);
}
if (message.type !== "save" || typeof message.round !== "number" || !message.token) return;
try {
armedRound = message.round;
await provider.saveTokens({ access_token: message.token, token_type: "Bearer" });
process.send?.({ type: "saved", round: message.round });
} catch (error) {
process.send?.({
type: "save-error",
round: message.round,
error: error instanceof Error ? error.stack : String(error),
});
} finally {
armedRound = undefined;
}
});

process.send?.({ type: "ready", pid: process.pid });
Loading
Loading