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
146 changes: 146 additions & 0 deletions e2e/mcp-oauth-outcomes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { test, expect } from "./fixtures/auth";
import { APP } from "./utils/paths";

test("MCP server OAuth failure → dismissal → retry → fresh components", async ({
page,
context,
apiMock,
}) => {
await apiMock.mockPermissions();
const server = {
id: "oauth-outcomes",
name: "OAuth outcomes server",
url: "http://localhost:9000/mcp",
transport: "STREAMABLEHTTP",
enabled: true,
reachable: true,
visibility: "public",
authType: "oauth",
oauthConfig: { grant_type: "authorization_code" },
createdAt: "2026-10-21T00:00:00Z",
updatedAt: "2026-10-21T00:00:00Z",
};
let authorized = false;
let discovered = false;
await page.route("**/v1/mcp-servers?*", (route) =>
route.fulfill({ json: { gateways: [server], nextCursor: null } }),
);
await page.route(`**/v1/mcp-servers/${server.id}`, (route) => route.fulfill({ json: server }));
await page.route("**/oauth/status?*", (route) =>
route.fulfill({
json: {
[server.id]: {
oauth_enabled: true,
grant_type: "authorization_code",
user_token_status: {
status: authorized ? "valid" : "missing",
authorized,
},
},
},
}),
);
await page.route("**/oauth/authorize-nonce", (route) =>
route.fulfill({ json: { nonce: "test-nonce" } }),
);
await context.route("**/oauth/authorize/**", (route) =>
route.fulfill({
contentType: "text/html",
body: "<!DOCTYPE html><html><body>Provider</body></html>",
}),
);
await page.route(`**/oauth/fetch-tools/${server.id}`, (route) => {
discovered = true;
return route.fulfill({ json: { success: true, message: "Fetched components" } });
});
await page.route("**/tools?*", (route) =>
route.fulfill({
json: {
tools: discovered
? [
{
id: "fresh-tool",
name: "fresh_tool",
originalName: "fresh_tool",
title: "Fresh tool",
},
]
: [],
},
}),
);
await page.route("**/resources?*", (route) =>
route.fulfill({
json: {
resources: discovered
? [
{
id: "fresh-resource",
name: "fresh_resource",
uri: "file:///fresh",
title: "Fresh resource",
},
]
: [],
},
}),
);
await page.route("**/prompts?*", (route) =>
route.fulfill({
json: {
prompts: discovered
? [
{
id: "fresh-prompt",
name: "fresh_prompt",
originalName: "fresh_prompt",
title: "Fresh prompt",
},
]
: [],
},
}),
);

await page.goto(APP.SERVERS);
const firstPopupPromise = context.waitForEvent("page");
await page.getByRole("button", { name: "Authorize OAuth outcomes server" }).click();
const firstPopup = await firstPopupPromise;
await firstPopup.waitForURL(/\/oauth\/authorize\//);
await firstPopup.evaluate(() => {
window.opener.postMessage(
{
type: "oauth_callback",
status: "error",
error: "access_denied",
errorDescription: "Provider denied the requested scope",
},
"*",
);
window.close();
});
const dialog = page.getByRole("dialog", {
name: "OAuth authorization failed for OAuth outcomes server",
});
await expect(dialog.getByText("Provider denied the requested scope")).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
const panel = page.getByRole("region", { name: /MCP Server details: OAuth outcomes server/ });
await expect(panel.getByText("Authorization required")).toBeVisible();
await expect(page.locator(`#oauth-authorize-${server.id}`)).toBeFocused();

const retryPopupPromise = context.waitForEvent("page");
await panel.getByRole("button", { name: "Authorize", exact: true }).last().click();
const retryPopup = await retryPopupPromise;
await retryPopup.waitForURL(/\/oauth\/authorize\//);
authorized = true;
await retryPopup.evaluate(() => {
window.opener.postMessage({ type: "oauth_callback", status: "success" }, "*");
window.close();
});
await expect(panel.getByText("OAuth authorization successful.")).toBeVisible();
await expect(panel.getByText("Fresh tool")).toBeVisible();
await expect(panel.getByText("Fresh resource")).toBeVisible();
await expect(panel.getByText("Fresh prompt")).toBeVisible();
await expect(panel.getByText("Authorization required")).not.toBeVisible();
});
6 changes: 6 additions & 0 deletions src/api/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,9 @@ export async function getOAuthStatuses(

return result;
}

/** Transient UI outcome, separate from the caller's authoritative token status. */
export type OAuthAttempt =
| { state: "pending" }
| { state: "succeeded" }
| { state: "failed"; reason: string; dialogOpen: boolean };
37 changes: 36 additions & 1 deletion src/api/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,11 @@ describe("serversApi", () => {
Object.defineProperty(event, "source", { value: mockAuthWindow, writable: false });
window.dispatchEvent(event);

await expect(promise).rejects.toThrow("User denied access");
await expect(promise).rejects.toMatchObject({
name: "OAuthAuthorizationError",
code: "access_denied",
description: "User denied access",
});
});

it("falls back to the error code when errorDescription is absent", async () => {
Expand Down Expand Up @@ -284,6 +288,37 @@ describe("serversApi", () => {
await expect(promise).rejects.toThrow("OAuth authorization failed");
});

it.each([
{
error: "access_denied",
errorDescription: "x".repeat(2000),
expected: `${"x".repeat(999)}…`,
},
{
error: { unsafe: "object" },
errorDescription: ["not text"],
expected: "OAuth authorization failed",
},
{ error: "access_denied", errorDescription: " ", expected: "access_denied" },
])(
"normalizes and bounds provider error fields: $expected",
async ({ error, errorDescription, expected }) => {
const popup = mockAuthPopup();
vi.spyOn(window, "open").mockReturnValue(popup);
mockFetch.mockResolvedValueOnce(nonceResponse());
const promise = serversApi.triggerOAuthAuthorization("server-123");
const event = new MessageEvent("message", {
data: { type: "oauth_callback", status: "error", error, errorDescription },
});
Object.defineProperty(event, "source", { value: popup });
window.dispatchEvent(event);
await expect(promise).rejects.toMatchObject({
name: "OAuthAuthorizationError",
message: expected,
});
},
);

it("ignores postMessages from other sources", async () => {
vi.useFakeTimers();
const mockAuthWindow = mockAuthPopup();
Expand Down
28 changes: 27 additions & 1 deletion src/api/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,32 @@ import { validateServerId } from "@/utils/serverId";

const serverByIdRequestCache = new Map<string, Promise<MCPServer>>();

export const MAX_OAUTH_ERROR_LENGTH = 1000;

function providerErrorText(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const text = value.trim();
if (!text) return undefined;
return text.length > MAX_OAUTH_ERROR_LENGTH
? `${text.slice(0, MAX_OAUTH_ERROR_LENGTH - 1)}…`
: text;
}

/** Provider callback failure; render only bounded plain-text fields. */
export class OAuthAuthorizationError extends Error {
readonly code?: string;
readonly description?: string;

constructor(code?: unknown, description?: unknown) {
const safeCode = providerErrorText(code);
const safeDescription = providerErrorText(description);
super(safeDescription || safeCode || "OAuth authorization failed");
this.name = "OAuthAuthorizationError";
this.code = safeCode;
this.description = safeDescription;
}
}

/** The user closed the OAuth popup. Typed so callers can stay quiet about it. */
export class OAuthCancelledError extends Error {
constructor() {
Expand Down Expand Up @@ -265,7 +291,7 @@ export const serversApi = {
if (data.status === "success") {
resolve(data);
} else {
reject(new Error(data.errorDescription ?? data.error ?? "OAuth authorization failed"));
reject(new OAuthAuthorizationError(data.error, data.errorDescription));
}
};

Expand Down
Loading
Loading