a7f73ee645
When an MCP server uses OAuth Dynamic Client Registration (RFC 7591) and
no client_id is pre-configured, MCPOAuthFlow registers a fresh public
PKCE client on each authorize, captures the issued client_id into a
private field, then discards it once the flow object goes out of scope.
At refresh time, MCPManager#resolveAuthConfig calls refreshMCPOAuthToken
with auth.clientId from mcp.json — which is empty for these servers —
so providers that require client_id on the refresh grant (e.g. Linear at
mcp.linear.app/token) reject with HTTP 401 invalid_client. The user is
forced to /mcp reauth manually every time the access token expires.
This change threads the resolved/registered client credentials back out
of the OAuth flow and persists them into mcp.json so refresh has what
it needs indefinitely:
- MCPOAuthFlow exposes resolvedClientId / registeredClientSecret getters.
- MCPCommandController#handleOAuthFlow returns OAuthFlowResult with
credentialId + clientId + clientSecret, populated from the flow's
post-login state.
- The initial-connect non-wizard path and /mcp reauth path persist the
returned client credentials into both auth.{clientId,clientSecret}
(used at refresh) and oauth.{clientId,clientSecret} (used by future
/mcp reauth to skip re-registration).
- The wizard's onOAuth callback signature now returns the same shape;
#launchOAuthFlow folds the registered credentials into wizard state so
the final mcp.json entry built by #buildServerConfigWithAuth includes
them under auth.{clientId,clientSecret}.
Servers that configure a static oauth.clientId in mcp.json (Notion,
Slack, Datadog) are unaffected: #tryRegisterClient short-circuits, the
returned clientId equals the configured one, and the write-back is a
no-op.
Adds two MCPOAuthFlow unit tests covering both paths.
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import { hookFetch } from "../../utils/src/hook-fetch";
|
|
import { MCPOAuthFlow } from "../src/mcp/oauth-flow";
|
|
|
|
const originalFetch = global.fetch;
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
function mockProviderTokenEndpoint(onBody: (body: string) => void) {
|
|
return hookFetch((input, init) => {
|
|
const url = String(input);
|
|
if (url === "https://provider.example/token") {
|
|
onBody(String(init?.body ?? ""));
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: "access-token",
|
|
refresh_token: "refresh-token",
|
|
expires_in: 3600,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
}
|
|
|
|
describe("mcp oauth flow", () => {
|
|
it("uses Codex client name for dynamic client registration", async () => {
|
|
let registrationPayload: Record<string, unknown> | null = null;
|
|
|
|
using _hook = hookFetch((input, init) => {
|
|
const url = String(input);
|
|
if (url === "https://www.figma.com/.well-known/oauth-authorization-server") {
|
|
return new Response(
|
|
JSON.stringify({ registration_endpoint: "https://api.figma.com/v1/oauth/mcp/register" }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
if (url === "https://api.figma.com/v1/oauth/mcp/register") {
|
|
registrationPayload = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
|
return new Response(
|
|
JSON.stringify({
|
|
client_id: "registered-client-id",
|
|
client_secret: "registered-client-secret",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://www.figma.com/oauth/mcp",
|
|
tokenUrl: "https://api.figma.com/v1/oauth/token",
|
|
},
|
|
{},
|
|
);
|
|
|
|
const { url } = await flow.generateAuthUrl("test-state", "http://127.0.0.1:53172/callback");
|
|
const authUrl = new URL(url);
|
|
|
|
expect(registrationPayload).not.toBeNull();
|
|
expect((registrationPayload as { client_name?: string } | null)?.client_name).toBe("Codex");
|
|
expect(authUrl.searchParams.get("client_id")).toBe("registered-client-id");
|
|
expect(authUrl.searchParams.get("state")).toBe("test-state");
|
|
});
|
|
|
|
it("uses configured callbackPath for the local redirect URI", async () => {
|
|
let observedRedirectUri = "";
|
|
let tokenRequestBody = "";
|
|
|
|
using _hook = mockProviderTokenEndpoint(body => {
|
|
tokenRequestBody = body;
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
clientId: "client-id",
|
|
callbackPort: 14567,
|
|
callbackPath: "slack/oauth_redirect",
|
|
},
|
|
{
|
|
onAuth: info => {
|
|
const authUrl = new URL(info.url);
|
|
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
|
|
const state = authUrl.searchParams.get("state") ?? "";
|
|
queueMicrotask(() => {
|
|
void originalFetch(`${observedRedirectUri}?code=test-code&state=${state}`);
|
|
});
|
|
},
|
|
signal: AbortSignal.timeout(1_000),
|
|
},
|
|
);
|
|
|
|
const credentials = await flow.login();
|
|
const redirectUrl = new URL(observedRedirectUri);
|
|
const tokenParams = new URLSearchParams(tokenRequestBody);
|
|
|
|
expect(redirectUrl.pathname).toBe("/slack/oauth_redirect");
|
|
expect(tokenParams.get("redirect_uri")).toBe(observedRedirectUri);
|
|
expect(credentials).toMatchObject({
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
});
|
|
});
|
|
|
|
it("uses exact redirectUri and clientSecret for provider requests", async () => {
|
|
let observedRedirectUri = "";
|
|
let tokenRequestBody = "";
|
|
|
|
using _hook = mockProviderTokenEndpoint(body => {
|
|
tokenRequestBody = body;
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
clientId: "client-id",
|
|
clientSecret: "client-secret",
|
|
redirectUri: "https://public.example/slack/oauth_redirect",
|
|
callbackPort: 14568,
|
|
callbackPath: "slack/oauth_redirect",
|
|
},
|
|
{
|
|
onAuth: info => {
|
|
const authUrl = new URL(info.url);
|
|
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
|
|
const state = authUrl.searchParams.get("state") ?? "";
|
|
queueMicrotask(() => {
|
|
void originalFetch(`http://localhost:14568/slack/oauth_redirect?code=test-code&state=${state}`);
|
|
});
|
|
},
|
|
signal: AbortSignal.timeout(1_000),
|
|
},
|
|
);
|
|
|
|
const credentials = await flow.login();
|
|
const tokenParams = new URLSearchParams(tokenRequestBody);
|
|
|
|
expect(observedRedirectUri).toBe("https://public.example/slack/oauth_redirect");
|
|
expect(tokenParams.get("redirect_uri")).toBe("https://public.example/slack/oauth_redirect");
|
|
expect(tokenParams.get("client_secret")).toBe("client-secret");
|
|
expect(credentials).toMatchObject({
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
});
|
|
});
|
|
|
|
it("preserves root redirectUri values without adding a trailing slash", async () => {
|
|
let observedRedirectUri = "";
|
|
let tokenRequestBody = "";
|
|
|
|
using _hook = mockProviderTokenEndpoint(body => {
|
|
tokenRequestBody = body;
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
clientId: "client-id",
|
|
redirectUri: "https://public.example",
|
|
callbackPort: 14571,
|
|
},
|
|
{
|
|
onAuth: info => {
|
|
const authUrl = new URL(info.url);
|
|
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
|
|
const state = authUrl.searchParams.get("state") ?? "";
|
|
queueMicrotask(() => {
|
|
void originalFetch(`http://localhost:14571/?code=test-code&state=${state}`);
|
|
});
|
|
},
|
|
signal: AbortSignal.timeout(1_000),
|
|
},
|
|
);
|
|
|
|
const credentials = await flow.login();
|
|
const tokenParams = new URLSearchParams(tokenRequestBody);
|
|
|
|
expect(observedRedirectUri).toBe("https://public.example");
|
|
expect(tokenParams.get("redirect_uri")).toBe("https://public.example");
|
|
expect(credentials).toMatchObject({
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
});
|
|
});
|
|
|
|
it("supports https loopback redirectUri values behind a separate local callback port", async () => {
|
|
let observedRedirectUri = "";
|
|
let tokenRequestBody = "";
|
|
|
|
using _hook = mockProviderTokenEndpoint(body => {
|
|
tokenRequestBody = body;
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
redirectUri: "https://localhost:3443/slack/oauth_redirect",
|
|
callbackPort: 14570,
|
|
},
|
|
{
|
|
onAuth: info => {
|
|
const authUrl = new URL(info.url);
|
|
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
|
|
const state = authUrl.searchParams.get("state") ?? "";
|
|
queueMicrotask(() => {
|
|
void originalFetch(`http://localhost:14570/slack/oauth_redirect?code=test-code&state=${state}`);
|
|
});
|
|
},
|
|
signal: AbortSignal.timeout(1_000),
|
|
},
|
|
);
|
|
|
|
const credentials = await flow.login();
|
|
const tokenParams = new URLSearchParams(tokenRequestBody);
|
|
|
|
expect(observedRedirectUri).toBe("https://localhost:3443/slack/oauth_redirect");
|
|
expect(tokenParams.get("redirect_uri")).toBe("https://localhost:3443/slack/oauth_redirect");
|
|
expect(credentials).toMatchObject({
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
});
|
|
});
|
|
|
|
it("rejects https loopback redirectUri values without a separate callback port", () => {
|
|
expect(
|
|
() =>
|
|
new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
redirectUri: "https://localhost:3000/slack/oauth_redirect",
|
|
},
|
|
{},
|
|
),
|
|
).toThrow("HTTPS loopback redirect URIs require oauth.callbackPort");
|
|
});
|
|
|
|
it("listens on the implied port for exact HTTP loopback redirectUri values", async () => {
|
|
const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(options => {
|
|
expect(options.port).toBe(80);
|
|
throw new Error("EADDRINUSE");
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
redirectUri: "http://localhost/callback",
|
|
},
|
|
{ signal: AbortSignal.timeout(1_000) },
|
|
);
|
|
|
|
await expect(flow.login()).rejects.toThrow(
|
|
"OAuth callback port 80 unavailable; cannot fall back to a random port when oauth.redirectUri is set",
|
|
);
|
|
expect(serveSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("listens on the explicit port for exact HTTP loopback redirectUri values", async () => {
|
|
const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(options => {
|
|
expect(options.port).toBe(3000);
|
|
throw new Error("EADDRINUSE");
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
redirectUri: "http://localhost:3000/callback",
|
|
},
|
|
{ signal: AbortSignal.timeout(1_000) },
|
|
);
|
|
|
|
await expect(flow.login()).rejects.toThrow(
|
|
"OAuth callback port 3000 unavailable; cannot fall back to a random port when oauth.redirectUri is set",
|
|
);
|
|
expect(serveSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fails instead of falling back to a random port when redirectUri is exact", async () => {
|
|
vi.spyOn(Bun, "serve").mockImplementation(() => {
|
|
throw new Error("EADDRINUSE");
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
redirectUri: "https://public.example/slack/oauth_redirect",
|
|
callbackPort: 14569,
|
|
callbackPath: "/slack/oauth_redirect",
|
|
},
|
|
{ signal: AbortSignal.timeout(1_000) },
|
|
);
|
|
|
|
await expect(flow.login()).rejects.toThrow("cannot fall back to a random port when oauth.redirectUri is set");
|
|
});
|
|
|
|
it("exposes the dynamically registered client_id and client_secret after generateAuthUrl", async () => {
|
|
using _hook = hookFetch(input => {
|
|
const url = String(input);
|
|
if (url === "https://www.figma.com/.well-known/oauth-authorization-server") {
|
|
return new Response(
|
|
JSON.stringify({ registration_endpoint: "https://api.figma.com/v1/oauth/mcp/register" }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
if (url === "https://api.figma.com/v1/oauth/mcp/register") {
|
|
return new Response(
|
|
JSON.stringify({
|
|
client_id: "registered-client-id",
|
|
client_secret: "registered-client-secret",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://www.figma.com/oauth/mcp",
|
|
tokenUrl: "https://api.figma.com/v1/oauth/token",
|
|
},
|
|
{},
|
|
);
|
|
|
|
expect(flow.resolvedClientId).toBeUndefined();
|
|
expect(flow.registeredClientSecret).toBeUndefined();
|
|
|
|
await flow.generateAuthUrl("test-state", "http://127.0.0.1:53173/callback");
|
|
|
|
expect(flow.resolvedClientId).toBe("registered-client-id");
|
|
expect(flow.registeredClientSecret).toBe("registered-client-secret");
|
|
});
|
|
|
|
it("returns the configured client_id from resolvedClientId without triggering registration", async () => {
|
|
let registrationCalled = false;
|
|
using _hook = hookFetch(input => {
|
|
const url = String(input);
|
|
if (url.includes("/.well-known/")) {
|
|
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.endsWith("/register")) {
|
|
registrationCalled = true;
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
const flow = new MCPOAuthFlow(
|
|
{
|
|
authorizationUrl: "https://provider.example/authorize",
|
|
tokenUrl: "https://provider.example/token",
|
|
clientId: "configured-client-id",
|
|
},
|
|
{},
|
|
);
|
|
|
|
expect(flow.resolvedClientId).toBe("configured-client-id");
|
|
expect(flow.registeredClientSecret).toBeUndefined();
|
|
|
|
await flow.generateAuthUrl("test-state", "http://127.0.0.1:53174/callback");
|
|
|
|
expect(flow.resolvedClientId).toBe("configured-client-id");
|
|
expect(flow.registeredClientSecret).toBeUndefined();
|
|
expect(registrationCalled).toBe(false);
|
|
});
|
|
});
|