diff --git a/src/server/mcp/oauth-provider.ts b/src/server/mcp/oauth-provider.ts index e32897d..0ed76ec 100644 --- a/src/server/mcp/oauth-provider.ts +++ b/src/server/mcp/oauth-provider.ts @@ -17,6 +17,7 @@ import { MCP_ROUTE, withWorkersOAuthMcpScopes, } from "@/server/mcp/context"; +import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; import { getPublicOrigin } from "@/server/mcp/public-origin"; import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; @@ -407,5 +408,21 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) { onError: oauthErrorResponse, }; - return new OAuthProvider(options); + const provider = new OAuthProvider(options); + + return { + async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) { + const url = new URL(request.url); + + if (url.pathname === OAUTH_REGISTER_PATH) { + // Cloudflare's provider can reject public DCR clients, but Perplexity + // does not appear to retry as confidential and instead expects a + // client_secret. Normalize before handing the request to Cloudflare so + // it still owns client creation, secret hashing, and token storage. + request = await normalizeClientRegistrationRequest(request); + } + + return provider.fetch(request, env, ctx); + }, + }; } diff --git a/src/server/mcp/oauth-registration.test.ts b/src/server/mcp/oauth-registration.test.ts new file mode 100644 index 0000000..97b5426 --- /dev/null +++ b/src/server/mcp/oauth-registration.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; + +describe("normalizeClientRegistrationRequest", () => { + it("converts public dynamic registration requests to confidential clients", async () => { + const request = new Request( + "https://app.openseo.so/api/auth/oauth2/register", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"], + client_name: "Perplexity", + token_endpoint_auth_method: "none", + }), + }, + ); + + const normalized = await normalizeClientRegistrationRequest(request); + + await expect(normalized.json()).resolves.toMatchObject({ + token_endpoint_auth_method: "client_secret_post", + }); + }); + + it("defaults omitted token auth methods to confidential clients", async () => { + const request = new Request( + "https://app.openseo.so/api/auth/oauth2/register", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"], + client_name: "Perplexity", + }), + }, + ); + + const normalized = await normalizeClientRegistrationRequest(request); + + await expect(normalized.json()).resolves.toMatchObject({ + token_endpoint_auth_method: "client_secret_post", + }); + }); + + it("keeps explicit confidential registration methods", async () => { + const request = new Request( + "https://app.openseo.so/api/auth/oauth2/register", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"], + token_endpoint_auth_method: "client_secret_post", + }), + }, + ); + + const normalized = await normalizeClientRegistrationRequest(request); + + await expect(normalized.json()).resolves.toMatchObject({ + token_endpoint_auth_method: "client_secret_post", + }); + }); + + it("leaves oversized registration payloads for the provider to reject", async () => { + const body = JSON.stringify({ + redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"], + client_name: "a".repeat(1024 * 1024), + token_endpoint_auth_method: "none", + }); + const request = new Request( + "https://app.openseo.so/api/auth/oauth2/register", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": String(body.length), + }, + body, + }, + ); + + await expect(normalizeClientRegistrationRequest(request)).resolves.toBe( + request, + ); + }); +}); diff --git a/src/server/mcp/oauth-registration.ts b/src/server/mcp/oauth-registration.ts new file mode 100644 index 0000000..427dfe8 --- /dev/null +++ b/src/server/mcp/oauth-registration.ts @@ -0,0 +1,60 @@ +const CONFIDENTIAL_CLIENT_AUTH_METHOD = "client_secret_post"; +const MAX_CLIENT_REGISTRATION_BODY_BYTES = 1024 * 1024; + +export async function normalizeClientRegistrationRequest(request: Request) { + if (request.method !== "POST") { + return request; + } + + const contentLength = request.headers.get("Content-Length"); + if ( + contentLength && + Number.parseInt(contentLength, 10) > MAX_CLIENT_REGISTRATION_BODY_BYTES + ) { + // Keep Cloudflare's registration endpoint responsible for its own payload + // limit errors; this shim only handles small, valid metadata requests. + return request; + } + + let clientMetadata: unknown; + try { + const text = await request.clone().text(); + if (text.length > MAX_CLIENT_REGISTRATION_BODY_BYTES) { + // Match the provider's 1 MiB guard before parsing so the compatibility + // shim cannot consume unusually large DCR payloads first. + return request; + } + + clientMetadata = JSON.parse(text); + } catch { + return request; + } + + if (!clientMetadata || typeof clientMetadata !== "object") { + return request; + } + + const metadata = { + ...clientMetadata, + } as Record; + + if ( + metadata.token_endpoint_auth_method === undefined || + metadata.token_endpoint_auth_method === "none" + ) { + // Perplexity registers as a public client but then rejects DCR responses + // without a client_secret. Use client_secret_post because its validator + // accepts that method but rejects client_secret_basic. + metadata.token_endpoint_auth_method = CONFIDENTIAL_CLIENT_AUTH_METHOD; + } + + const headers = new Headers(request.headers); + headers.set("Content-Type", "application/json"); + headers.delete("Content-Length"); + + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(metadata), + }); +}