Skip to content
Merged
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
20 changes: 20 additions & 0 deletions server/test/oauth-callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ beforeAll(async () => {
return;
}

if (req.url?.startsWith("/oauth/callback?structured-error")) {
res.writeHead(400, { "content-type": "text/html" });
res.end(
'<script>window.opener.postMessage({"type":"oauth_callback","status":"error","error":"oauth_error","errorCode":"token_url_unreachable","failedStage":"token_exchange","reauthorizationRequired":false},"*");window.close();</script>',
);
return;
}

// Mirrors mcpgateway's oauth_callback popup branch: an HTML page whose
// inline script posts the result to window.opener and closes itself.
res.writeHead(200, {
Expand All @@ -65,6 +73,18 @@ async function buildApp() {
}

describe("GET /oauth/callback", () => {
it("forwards structured backend callback error fields", async () => {
const app = await buildApp();
const response = await app.fastify.inject({
method: "GET",
url: "/oauth/callback?structured-error=1&state=popup.xyz",
});
expect(response.statusCode).toBe(400);
expect(response.body).toContain('"errorCode":"token_url_unreachable"');
expect(response.body).toContain('"failedStage":"token_exchange"');
expect(response.body).toContain('"reauthorizationRequired":false');
});

it("proxies with no session cookie required", async () => {
const app = await buildApp();

Expand Down
9 changes: 8 additions & 1 deletion src/api/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,11 @@ export async function getOAuthStatuses(
export type OAuthAttempt =
| { state: "pending" }
| { state: "succeeded" }
| { state: "failed"; reason: string; dialogOpen: boolean };
| {
state: "failed";
reason: string;
dialogOpen: boolean;
errorCode?: string;
failedStage?: string;
reauthorizationRequired?: boolean;
};
64 changes: 63 additions & 1 deletion src/api/servers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { serversApi } from "./servers";
import { OAuthAuthorizationError, serversApi } from "./servers";
import { setCsrfToken } from "./client";
import type { GatewayTestRequest, GatewayHandshakeRequest } from "@/generated/types";

Expand Down Expand Up @@ -33,6 +33,68 @@ describe("serversApi", () => {
vi.restoreAllMocks();
});

describe("post-authorization connection validation", () => {
it("sends only the saved gateway identity, URL and strict mode", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ success: false, latencyMs: 1, errorCode: "insufficient_scope" }),
{ status: 200 },
),
);
const controller = new AbortController();
const result = await serversApi.testOAuthConnection(
{ gatewayId: "gw-1", baseUrl: "https://saved.example/mcp" },
controller.signal,
);
expect(result?.errorCode).toBe("insufficient_scope");
expect(mockFetch).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining("/v1/mcp-servers/test-handshake"),
expect.objectContaining({
method: "POST",
signal: controller.signal,
body: JSON.stringify({
baseUrl: "https://saved.example/mcp",
gatewayId: "gw-1",
validationMode: "post_authorization",
}),
}),
);
});

it("retains structured callback fields and remains compatible with legacy errors", async () => {
const authWindow = mockAuthPopup();
vi.spyOn(window, "open").mockReturnValue(authWindow);
mockFetch.mockResolvedValueOnce(nonceResponse());
const promise = serversApi.triggerOAuthAuthorization("gw-1");
const failure = expect(promise).rejects.toMatchObject({
code: "oauth_error",
errorCode: "token_url_unreachable",
failedStage: "token_exchange",
reauthorizationRequired: false,
});
window.dispatchEvent(
new MessageEvent("message", {
source: authWindow,
data: {
type: "oauth_callback",
status: "error",
error: "oauth_error",
errorCode: "token_url_unreachable",
failedStage: "token_exchange",
reauthorizationRequired: false,
},
}),
);
await failure;
expect(new OAuthAuthorizationError("access_denied").errorCode).toBe("access_denied");
expect(
new OAuthAuthorizationError("unknown", "text", {
reauthorizationRequired: "yes" as unknown as boolean,
}).reauthorizationRequired,
).toBe(false);
});
});

describe("toggleEnabled", () => {
it("should activate a server with CSRF token", async () => {
const mockResponse = new Response(
Expand Down
32 changes: 30 additions & 2 deletions src/api/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import type {
GatewayTestRequest,
GatewayTestResponse,
} from "@/generated/types";
import type {
OAuthConnectionValidationRequest,
OAuthConnectionValidationResponse,
} from "./contracts/oauthConnectionValidation";
import { validateServerId } from "@/utils/serverId";

const serverByIdRequestCache = new Map<string, Promise<MCPServer>>();
Expand All @@ -33,14 +37,20 @@ function providerErrorText(value: unknown): string | undefined {
export class OAuthAuthorizationError extends Error {
readonly code?: string;
readonly description?: string;
readonly errorCode?: string;
readonly failedStage?: string;
readonly reauthorizationRequired: boolean;

constructor(code?: unknown, description?: unknown) {
constructor(code?: unknown, description?: unknown, details?: Partial<OAuthCallbackResult>) {
const safeCode = providerErrorText(code);
const safeDescription = providerErrorText(description);
super(safeDescription || safeCode || "OAuth authorization failed");
this.name = "OAuthAuthorizationError";
this.code = safeCode;
this.description = safeDescription;
this.errorCode = providerErrorText(details?.errorCode) ?? safeCode;
this.failedStage = providerErrorText(details?.failedStage);
this.reauthorizationRequired = details?.reauthorizationRequired === true;
}
}

Expand Down Expand Up @@ -204,6 +214,21 @@ export const serversApi = {
return api.post("/v1/mcp-servers/test-handshake", request, { signal });
},

testOAuthConnection: (
{ gatewayId, baseUrl }: { gatewayId: string; baseUrl: string },
signal?: AbortSignal,
): Promise<OAuthConnectionValidationResponse> => {
return api.post(
"/v1/mcp-servers/test-handshake",
{
baseUrl,
gatewayId: validateServerId(gatewayId),
validationMode: "post_authorization",
} satisfies OAuthConnectionValidationRequest,
{ signal },
);
},

/**
* Toggle the enabled state of an MCP server (activate/deactivate)
*/
Expand Down Expand Up @@ -297,7 +322,7 @@ export const serversApi = {
if (data.status === "success") {
resolve(data);
} else {
reject(new OAuthAuthorizationError(data.error, data.errorDescription));
reject(new OAuthAuthorizationError(data.error, data.errorDescription, data));
}
};

Expand Down Expand Up @@ -343,4 +368,7 @@ export interface OAuthCallbackResult {
gatewayName?: string;
error?: string;
errorDescription?: string;
errorCode?: string;
failedStage?: string;
reauthorizationRequired?: boolean;
}
144 changes: 144 additions & 0 deletions src/components/servers/OAuthConnectionValidationPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "@/test/test-utils";
import { OAuthConnectionValidationPanel } from "./OAuthConnectionValidationPanel";
import type { OAuthConnectionValidationState } from "@/hooks/useOAuthConnectionValidation";

const props = { name: "Example", onTest: vi.fn(), onCancel: vi.fn(), onAuthorize: vi.fn() };
const success: OAuthConnectionValidationState = {
state: "success",
testedAt: Date.now(),
result: {
success: true,
latencyMs: 2,
stages: { token: "ok", negotiation: "ok", tools_list: "ok" },
componentCounts: { tools: 0 },
},
};

describe("OAuthConnectionValidationPanel", () => {
it("waits for a click before testing", async () => {
render(<OAuthConnectionValidationPanel {...props} />);
expect(screen.queryByRole("status", { busy: true })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Test connection" }));
expect(props.onTest).toHaveBeenCalledOnce();
});

it("keeps Test focused while running and returns focus from Cancel on completion", async () => {
const { rerender } = render(<OAuthConnectionValidationPanel {...props} />);
const button = screen.getByRole("button", { name: "Test connection" });
button.focus();
rerender(<OAuthConnectionValidationPanel {...props} entry={{ state: "running" }} />);
expect(button).toHaveFocus();
expect(button).toHaveAttribute("aria-disabled", "true");
const before = props.onTest.mock.calls.length;
await userEvent.click(button);
expect(props.onTest).toHaveBeenCalledTimes(before);
screen.getByRole("button", { name: "Cancel" }).focus();
rerender(<OAuthConnectionValidationPanel {...props} entry={success} />);
expect(screen.getByRole("button", { name: "Retest" })).toHaveFocus();
});

it("returns focus from Cancel when validation is reset", () => {
const { rerender } = render(
<OAuthConnectionValidationPanel {...props} entry={{ state: "running" }} />,
);
screen.getByRole("button", { name: "Cancel" }).focus();
rerender(<OAuthConnectionValidationPanel {...props} />);
expect(screen.getByRole("button", { name: "Test connection" })).toHaveFocus();
});

it("hides unknown stage values instead of constructing missing translation keys", () => {
render(
<OAuthConnectionValidationPanel
{...props}
entry={{
state: "failure",
errorCode: "incompatible",
result: {
success: false,
stages: { token: "unexpected", negotiation: "ok", tools_list: "ok" },
} as unknown as OAuthConnectionValidationState["result"],
}}
/>,
);
expect(screen.queryByRole("list")).not.toBeInTheDocument();
expect(screen.queryByText(/outcome.unexpected/)).not.toBeInTheDocument();
});

it("renders zero tools, stage text, test time and Retest", () => {
render(<OAuthConnectionValidationPanel {...props} entry={success} />);
expect(screen.getByText("0 tools")).toBeVisible();
expect(screen.getByText("Token: Passed")).toBeVisible();
expect(screen.getByText("MCP negotiation: Passed")).toBeVisible();
expect(screen.getByText("Tools access: Passed")).toBeVisible();
expect(screen.getByText(/Tested at/)).toBeVisible();
expect(screen.getByRole("button", { name: "Retest" })).toBeEnabled();
});

it("preserves first-page lower bounds", () => {
render(
<OAuthConnectionValidationPanel
{...props}
entry={{
...success,
result: { ...success.result!, countsPartial: true, componentCounts: { tools: 2 } },
}}
/>,
);
expect(screen.getByText("2+ tools")).toBeVisible();
});

it("announces a cancellable running test", async () => {
render(<OAuthConnectionValidationPanel {...props} entry={{ state: "running" }} />);
expect(screen.getByRole("status", { busy: true })).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(props.onCancel).toHaveBeenCalled();
});

it.each(["token", "negotiation", "tools_list"] as const)(
"shows a failed %s stage and reauthorization",
async (stage) => {
render(
<OAuthConnectionValidationPanel
{...props}
entry={{
state: "failure",
errorCode: "insufficient_scope",
result: {
success: false,
latencyMs: 1,
stages: {
token: "skipped",
negotiation: "skipped",
tools_list: "skipped",
[stage]: "failed",
},
reauthorizationRequired: true,
},
}}
/>,
);
expect(screen.getByText(/stored token lacks the required scope/)).toBeVisible();
expect(screen.getByText(/: Failed$/)).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Authorize again" }));
expect(props.onAuthorize).toHaveBeenCalled();
},
);

it("uses generic guidance for unknown errors without displaying upstream secrets", () => {
render(
<OAuthConnectionValidationPanel
{...props}
entry={{
state: "failure",
errorCode: "secret-provider-code",
result: { success: false, latencyMs: 1, error: "unsafe token" },
}}
/>,
);
expect(screen.getByText(/Check the configuration and try again/)).toBeVisible();
expect(screen.queryByText(/unsafe token|secret-provider-code/)).not.toBeInTheDocument();
});
});
Loading
Loading