From 44cebc9fd8b347ddfb54908614c3d3e77a9f5454 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Thu, 28 May 2026 21:21:40 +0200 Subject: [PATCH] fix(oauth): accept application/x-www-form-urlencoded on /oauth/token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sovereign-audit traced "Authorization with the MCP server failed" past discovery, DCR, /authorize → redirect → code, and into POST /oauth/token, which Fastify rejected with 415 before our handler ever ran. RFC 6749 §3.2 makes form-urlencoded the mandatory wire format for the token endpoint, and every DCR-emitting client (Claude Desktop, Cursor, OpenAI Codex, …) posts it that way. Fastify ships no built-in parser for that media type so the route 415'd from the framework's content- type layer — invisible to a code review of the route handler. Adds a small URLSearchParams-based parser next to the existing JSON one, parses the form body into a plain object so the route's zod schema picks it up unchanged. No new dependency. --- apps/api/src/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 799bdb6..ad4afd2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -53,6 +53,29 @@ app.addContentTypeParser( }, ); +// RFC 6749 §3.2 makes application/x-www-form-urlencoded the mandatory wire +// format for the OAuth token endpoint, and most DCR-emitting clients +// (Claude Desktop included) post it that way without negotiating. Fastify +// has no built-in parser for it, so without this every POST /oauth/token +// hit 415 before reaching our handler. Parsed into a plain object so the +// existing zod schemas don't need to change. +app.addContentTypeParser( + 'application/x-www-form-urlencoded', + { parseAs: 'string' }, + (_req, body, done) => { + const text = body as string; + if (!text) return done(null, {}); + try { + const params = new URLSearchParams(text); + const out: Record = {}; + for (const [k, v] of params) out[k] = v; + done(null, out); + } catch (err) { + done(err as Error, undefined); + } + }, +); + await app.register(cors, { origin: [config.NEXT_PUBLIC_APP_URL], credentials: true,