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
34 changes: 34 additions & 0 deletions src/api/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,40 @@ describe("registerCatalogServer", () => {
});
});

it("preserves OAuth credentials in the catalog registration body", async () => {
let body: unknown;
server.use(
http.post("*/api/v1/catalog/:catalogId/register", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true, server_id: "gateway-1", message: "Registered" });
}),
);

await registerCatalogServer("github", {
oauth_credentials: {
grant_type: "authorization_code",
issuer: "https://github.com",
client_id: "client-id",
client_secret: "client-secret", // pragma: allowlist secret
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
scopes: ["repo"],
},
});

expect(body).toEqual({
oauth_credentials: {
grant_type: "authorization_code",
issuer: "https://github.com",
client_id: "client-id",
client_secret: "client-secret", // pragma: allowlist secret
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
scopes: ["repo"],
},
});
});

it("DELETEs an encoded gateway ID and preserves async lifecycle metadata", async () => {
let requestPath = "";
server.use(
Expand Down
33 changes: 32 additions & 1 deletion src/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ import type {
GatewayTestResponse,
} from "@/generated/types";

/** Temporary handwritten contract until #6588 reaches generated OpenAPI types. */
export interface CatalogOAuthCredentials {
grant_type: "authorization_code";
issuer: string;
client_id: string;
client_secret: string; // pragma: allowlist secret
authorization_url: string;
token_url: string;
scopes: string[];
}

export type CatalogOAuthRegisterBody = CatalogServerRegisterBody & {
oauth_credentials: CatalogOAuthCredentials;
};

export interface OAuthUserTokenStatus {
status: "valid" | "near_expiry" | "expired" | "missing";
authorized: boolean;
scopes?: string[];
expires_at?: string | null;
updated_at?: string | null;
}

export interface OAuthGatewayStatus {
oauth_enabled: boolean;
grant_type?: string;
user_token_status?: OAuthUserTokenStatus;
}

export type OAuthGatewayStatusMap = Record<string, OAuthGatewayStatus>;

export interface GatewayImpactPreview {
gatewayId: string;
servers: Array<{ id: string; name: string }>;
Expand All @@ -17,7 +48,7 @@ export type CatalogGatewayDeleteResponse = GatewayRead | { status?: string; mess
/** Register a catalog entry through the authenticated BFF proxy. */
export async function registerCatalogServer(
catalogId: string,
body?: CatalogServerRegisterBody,
body?: CatalogServerRegisterBody | CatalogOAuthRegisterBody,
): Promise<CatalogServerRegisterResponse> {
return api.post<CatalogServerRegisterResponse>(
`/v1/catalog/${encodeURIComponent(catalogId)}/register`,
Expand Down
55 changes: 37 additions & 18 deletions src/api/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ function validateServerId(id: string): string {
return id;
}

function openOAuthAuthorizationPopup(): Window {
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const authWindow = window.open(
"",
"oauth_authorization",
`width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes`,
);

if (!authWindow) {
throw new Error(
"Failed to open OAuth authorization window. Please check your popup blocker settings.",
);
}

return authWindow;
}

export const serversApi = {
/**
* List all MCP servers with cursor-based pagination
Expand Down Expand Up @@ -170,6 +190,16 @@ export const serversApi = {
return api.post(`/oauth/fetch-tools/${validId}`);
},

/**
* Open blank OAuth popup during an active user gesture.
*
* Catalog setup must register credentials before it knows the gateway ID. It
* opens this blank window first, then passes it to triggerOAuthAuthorization
* once registration returns. Keeping window.open synchronous prevents popup
* blockers from rejecting first-time setup.
*/
openOAuthAuthorizationPopup,

/**
* Trigger OAuth authorization flow for a gateway via a popup window.
*
Expand All @@ -188,27 +218,16 @@ export const serversApi = {
*
* Returns a Promise that resolves on success or rejects on error / cancellation.
*/
triggerOAuthAuthorization: (id: string): Promise<OAuthCallbackResult> => {
triggerOAuthAuthorization: (
id: string,
existingAuthWindow?: Window,
): Promise<OAuthCallbackResult> => {
const validId = validateServerId(id);

return new Promise((resolve, reject) => {
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;

const authWindow = window.open(
"",
"oauth_authorization",
`width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes`,
);

if (!authWindow) {
reject(
new Error(
"Failed to open OAuth authorization window. Please check your popup blocker settings.",
),
);
const authWindow = existingAuthWindow ?? openOAuthAuthorizationPopup();
if (authWindow.closed) {
reject(new Error("OAuth authorization window was closed"));
return;
}

Expand Down
148 changes: 148 additions & 0 deletions src/components/server-catalog/CatalogOAuthDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";

import type { CatalogServer } from "@/generated/types";
import { useQuery } from "@/hooks/useQuery";
import { renderWithProviders } from "@/test/test-utils";
import { CatalogOAuthDialog } from "./CatalogOAuthDialog";

vi.mock("@/hooks/useQuery", () => ({ useQuery: vi.fn() }));
vi.mock("@/hooks/useTeams", () => ({
useTeamScope: () => ({ teams: [], onTeamChange: vi.fn() }),
}));

const server: CatalogServer = {
id: "github",
name: "GitHub",
auth_type: "OAuth2.1",
url: "https://github.com/mcp",
category: "Developer Tools",
provider: "GitHub",
description: "GitHub OAuth server",
is_registered: false,
};

const mockUseQuery = vi.mocked(useQuery);

function renderDialog(overrides: Partial<ComponentProps<typeof CatalogOAuthDialog>> = {}) {
const onOpenChange = vi.fn();
const onSubmit = vi.fn().mockResolvedValue(true);
mockUseQuery.mockReturnValue({ data: undefined } as ReturnType<typeof useQuery>);

renderWithProviders(
<CatalogOAuthDialog
server={server}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
isSubmitting={false}
{...overrides}
/>,
);

return { onOpenChange, onSubmit };
}

async function fillRequiredFields(user: ReturnType<typeof userEvent.setup>) {
const dialog = screen.getByRole("dialog", { name: "Add GitHub" });
await user.type(within(dialog).getByLabelText(/Issuer URL/i), "http://github.com");
await user.type(within(dialog).getByLabelText(/^Scopes/i), "repo, read:user");
await user.type(within(dialog).getByLabelText(/^Client ID/i), "github-client");
await user.type(within(dialog).getByLabelText(/^Client Secret/i), "github-secret");
await user.type(
within(dialog).getByLabelText(/^Authorization URL/i),
"https://github.com/login/oauth/authorize",
);
await user.type(
within(dialog).getByLabelText(/^Token URL/i),
"https://github.com/login/oauth/access_token",
);
}

describe("CatalogOAuthDialog", () => {
it("shows every required-field validation error without submitting", async () => {
const user = userEvent.setup();
const { onSubmit } = renderDialog();

await user.click(screen.getByRole("button", { name: "Configure and authorize" }));

expect(await screen.findByText("Enter a valid issuer URL.")).toBeInTheDocument();
expect(screen.getByText("Enter at least one scope.")).toBeInTheDocument();
expect(screen.getByText("Client ID is required.")).toBeInTheDocument();
expect(screen.getByText("Client secret is required.")).toBeInTheDocument();
expect(screen.getByText("Enter a valid authorization URL.")).toBeInTheDocument();
expect(screen.getByText("Enter a valid token URL.")).toBeInTheDocument();
expect(screen.getByLabelText(/Issuer URL/i)).toHaveAttribute("aria-required", "true");
expect(screen.getByLabelText(/Issuer URL/i)).toHaveAttribute(
"aria-describedby",
"catalog-oauth-issuer-error",
);
expect(screen.getByText("Enter a valid issuer URL.")).toHaveAttribute(
"id",
"catalog-oauth-issuer-error",
);
expect(onSubmit).not.toHaveBeenCalled();
});

it("shows callback URL and submits normalized authorization-code credentials", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
const onSubmit = vi.fn().mockResolvedValue(true);
mockUseQuery.mockReturnValue({
data: { redirectUri: "https://gateway.example/oauth/callback" },
} as ReturnType<typeof useQuery>);

renderWithProviders(
<CatalogOAuthDialog
server={server}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
isSubmitting={false}
/>,
);

expect(screen.getByText("Redirect URI")).toBeInTheDocument();
await user.type(screen.getByLabelText("Custom name (optional)"), " My GitHub ");
await fillRequiredFields(user);
await user.click(screen.getByRole("button", { name: "Configure and authorize" }));

await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
name: "My GitHub",
visibility: "private",
team_id: null,
oauth_credentials: {
grant_type: "authorization_code",
issuer: "http://github.com",
client_id: "github-client",
client_secret: "github-secret",
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
scopes: ["repo", "read:user"],
},
}),
);
expect(onOpenChange).toHaveBeenCalledWith(false);
});

it("resets values and notifies parent when cancelled", async () => {
const user = userEvent.setup();
const { onOpenChange } = renderDialog();

await user.type(screen.getByLabelText("Custom name (optional)"), "Temporary name");
await user.click(screen.getByRole("button", { name: "Cancel" }));

expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.getByLabelText("Custom name (optional)")).toHaveValue("");
});

it("does not close while authorization is submitting", async () => {
const user = userEvent.setup();
const { onOpenChange } = renderDialog({ isSubmitting: true });

await user.click(screen.getByRole("button", { name: "Close" }));

expect(onOpenChange).not.toHaveBeenCalled();
});
});
Loading
Loading