fix(mcp): stop breaking public OAuth clients at token refresh (#420)

* fix(mcp): stop breaking public OAuth clients at token refresh

The DCR shim force-upgraded every public client (token_endpoint_auth_method
"none" or omitted) to client_secret_post so Perplexity would accept the
registration response. That made the stored client confidential, so the
token endpoint demanded client authentication on every grant — and MCP
clients that discard the secret (Codex) lost their session at first token
expiry with "invalid_client: missing client_secret".

Register those clients as true public clients instead (PKCE + the
provider's grant-to-client binding secure that flow), and satisfy
Perplexity by decorating only the registration response with a placeholder
client_secret and client_secret_post. The provider skips secret validation
for public clients, so clients that send the placeholder and clients that
never store it both keep working, including on refresh.

* refactor(mcp): only rebuild DCR requests that actually change

* fix(mcp): satisfy type-aware lint in DCR shims

oxlint --type-aware rejected the Record<string, unknown> assertions used to
read untrusted DCR payloads. Parse both with loose Zod schemas instead, per
the repo's trust-boundary convention, which also replaces the hand-rolled
object guards.
This commit is contained in:
Ben Senescu 2026-08-05 16:10:28 -04:00 committed by GitHub
parent 30948a2a1d
commit 8a728563d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 157 additions and 33 deletions

View File

@ -20,7 +20,10 @@ import {
MCP_ROUTE, MCP_ROUTE,
withWorkersOAuthMcpScopes, withWorkersOAuthMcpScopes,
} from "@/server/mcp/context"; } from "@/server/mcp/context";
import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; import {
normalizeClientRegistrationRequest,
withCompatibilityClientSecret,
} from "@/server/mcp/oauth-registration";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
@ -114,10 +117,11 @@ function oauthErrorResponse(error: {
}) { }) {
// 401s here are the standard OAuth discovery handshake, not failures: an // 401s here are the standard OAuth discovery handshake, not failures: an
// unauthenticated /mcp hit returns `invalid_token` (which triggers the // unauthenticated /mcp hit returns `invalid_token` (which triggers the
// client's .well-known discovery), and the DCR client_secret_post shim makes // client's .well-known discovery), and clients registered as confidential
// a client's first token attempt return `invalid_client` before it retries // before the public-client DCR fix still draw `invalid_client` until they
// with the secret. Log those at debug so they stop masquerading as errors; // retry with the secret or re-register. Log those at debug so they stop
// keep 5xx at error and everything else (bad client metadata, etc.) at warn. // masquerading as errors; keep 5xx at error and everything else (bad client
// metadata, etc.) at warn.
const line = `[oauth] ${error.status} ${error.code}: ${error.description}`; const line = `[oauth] ${error.status} ${error.code}: ${error.description}`;
if (error.status === 401) { if (error.status === 401) {
console.debug(line); console.debug(line);
@ -445,11 +449,17 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === OAUTH_REGISTER_PATH) { if (url.pathname === OAUTH_REGISTER_PATH) {
// Cloudflare's provider can reject public DCR clients, but Perplexity // Register secretless MCP clients as true public clients so refresh
// does not appear to retry as confidential and instead expects a // grants never require client authentication, then dress the DCR
// client_secret. Normalize before handing the request to Cloudflare so // response as confidential for clients (Perplexity) that reject
// it still owns client creation, secret hashing, and token storage. // responses without a client_secret. Cloudflare still owns client
request = await normalizeClientRegistrationRequest(request); // creation, secret hashing, and token storage.
const response = await provider.fetch(
await normalizeClientRegistrationRequest(request),
env,
ctx,
);
return withCompatibilityClientSecret(response);
} }
return provider.fetch(request, env, ctx); return provider.fetch(request, env, ctx);

View File

@ -1,16 +1,22 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; import { z } from "zod";
import {
normalizeClientRegistrationRequest,
withCompatibilityClientSecret,
} from "@/server/mcp/oauth-registration";
const registrationBodySchema = z.looseObject({ client_secret: z.string() });
describe("normalizeClientRegistrationRequest", () => { describe("normalizeClientRegistrationRequest", () => {
it("converts public dynamic registration requests to confidential clients", async () => { it("keeps explicit public registrations public", async () => {
const request = new Request( const request = new Request(
"https://app.openseo.so/api/auth/oauth2/register", "https://app.openseo.so/api/auth/oauth2/register",
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"], redirect_uris: ["http://localhost:1455/auth/callback"],
client_name: "Perplexity", client_name: "Codex",
token_endpoint_auth_method: "none", token_endpoint_auth_method: "none",
}), }),
}, },
@ -19,11 +25,11 @@ describe("normalizeClientRegistrationRequest", () => {
const normalized = await normalizeClientRegistrationRequest(request); const normalized = await normalizeClientRegistrationRequest(request);
await expect(normalized.json()).resolves.toMatchObject({ await expect(normalized.json()).resolves.toMatchObject({
token_endpoint_auth_method: "client_secret_post", token_endpoint_auth_method: "none",
}); });
}); });
it("defaults omitted token auth methods to confidential clients", async () => { it("defaults omitted token auth methods to public clients", async () => {
const request = new Request( const request = new Request(
"https://app.openseo.so/api/auth/oauth2/register", "https://app.openseo.so/api/auth/oauth2/register",
{ {
@ -39,7 +45,7 @@ describe("normalizeClientRegistrationRequest", () => {
const normalized = await normalizeClientRegistrationRequest(request); const normalized = await normalizeClientRegistrationRequest(request);
await expect(normalized.json()).resolves.toMatchObject({ await expect(normalized.json()).resolves.toMatchObject({
token_endpoint_auth_method: "client_secret_post", token_endpoint_auth_method: "none",
}); });
}); });
@ -86,3 +92,55 @@ describe("normalizeClientRegistrationRequest", () => {
); );
}); });
}); });
function registrationResponse(body: Record<string, unknown>, status = 201) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("withCompatibilityClientSecret", () => {
it("adds a placeholder secret to public client registrations", async () => {
const response = await withCompatibilityClientSecret(
registrationResponse({
client_id: "abc123",
token_endpoint_auth_method: "none",
client_id_issued_at: 1753228800,
}),
);
const body = registrationBodySchema.parse(await response.json());
expect(body).toMatchObject({
client_id: "abc123",
token_endpoint_auth_method: "client_secret_post",
client_secret_expires_at: 0,
client_secret_issued_at: 1753228800,
});
expect(body.client_secret).toMatch(/^[0-9a-f]{32}$/);
});
it("leaves confidential registrations untouched", async () => {
const original = registrationResponse({
client_id: "abc123",
token_endpoint_auth_method: "client_secret_post",
client_secret: "real-secret",
});
const response = await withCompatibilityClientSecret(original);
expect(response).toBe(original);
});
it("leaves registration errors untouched", async () => {
const original = registrationResponse(
{ error: "invalid_client_metadata" },
400,
);
const response = await withCompatibilityClientSecret(original);
expect(response).toBe(original);
});
});

View File

@ -1,6 +1,20 @@
const CONFIDENTIAL_CLIENT_AUTH_METHOD = "client_secret_post"; import { z } from "zod";
const PUBLIC_CLIENT_AUTH_METHOD = "none";
const COMPAT_CLIENT_AUTH_METHOD = "client_secret_post";
const MAX_CLIENT_REGISTRATION_BODY_BYTES = 1024 * 1024; const MAX_CLIENT_REGISTRATION_BODY_BYTES = 1024 * 1024;
// Loose so every field the provider cares about survives the round trip; these
// shims only read the auth method and secret.
const clientMetadataSchema = z.looseObject({
token_endpoint_auth_method: z.string().optional(),
});
const clientRegistrationSchema = z.looseObject({
token_endpoint_auth_method: z.string().optional(),
client_secret: z.string().optional(),
});
export async function normalizeClientRegistrationRequest(request: Request) { export async function normalizeClientRegistrationRequest(request: Request) {
if (request.method !== "POST") { if (request.method !== "POST") {
return request; return request;
@ -16,7 +30,7 @@ export async function normalizeClientRegistrationRequest(request: Request) {
return request; return request;
} }
let clientMetadata: unknown; let rawMetadata: unknown;
try { try {
const text = await request.clone().text(); const text = await request.clone().text();
if (text.length > MAX_CLIENT_REGISTRATION_BODY_BYTES) { if (text.length > MAX_CLIENT_REGISTRATION_BODY_BYTES) {
@ -25,28 +39,26 @@ export async function normalizeClientRegistrationRequest(request: Request) {
return request; return request;
} }
clientMetadata = JSON.parse(text); rawMetadata = JSON.parse(text);
} catch { } catch {
return request; return request;
} }
if (!clientMetadata || typeof clientMetadata !== "object") { const parsed = clientMetadataSchema.safeParse(rawMetadata);
if (!parsed.success || parsed.data.token_endpoint_auth_method !== undefined) {
return request; return request;
} }
// The provider defaults an omitted auth method to client_secret_basic,
// which requires client authentication on every grant — including refresh.
// MCP clients that discard the secret (Codex did) then lose their session
// at first token expiry with "invalid_client: missing client_secret".
// Register them as public clients instead; PKCE plus the provider's
// grant-to-client binding secure the public-client flow.
const metadata = { const metadata = {
...clientMetadata, ...parsed.data,
} as Record<string, unknown>; token_endpoint_auth_method: PUBLIC_CLIENT_AUTH_METHOD,
};
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); const headers = new Headers(request.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
@ -58,3 +70,47 @@ export async function normalizeClientRegistrationRequest(request: Request) {
body: JSON.stringify(metadata), body: JSON.stringify(metadata),
}); });
} }
export async function withCompatibilityClientSecret(response: Response) {
if (response.status !== 201) {
return response;
}
let rawBody: unknown;
try {
rawBody = await response.clone().json();
} catch {
return response;
}
const parsed = clientRegistrationSchema.safeParse(rawBody);
if (
!parsed.success ||
parsed.data.token_endpoint_auth_method !== PUBLIC_CLIENT_AUTH_METHOD ||
parsed.data.client_secret !== undefined
) {
return response;
}
// Perplexity registers as a public client but rejects DCR responses without
// a client_secret (its validator accepts client_secret_post but not
// client_secret_basic). Dress the response as confidential while the stored
// client stays public: the token endpoint skips secret validation for public
// clients, so clients that send this placeholder and clients that never
// store it both keep working — including refresh grants.
const compatBody = {
...parsed.data,
token_endpoint_auth_method: COMPAT_CLIENT_AUTH_METHOD,
client_secret: crypto.randomUUID().replaceAll("-", ""),
client_secret_expires_at: 0,
client_secret_issued_at: parsed.data.client_id_issued_at,
};
const headers = new Headers(response.headers);
headers.delete("Content-Length");
return new Response(JSON.stringify(compatBody), {
status: response.status,
headers,
});
}