fix(oauth): accept application/x-www-form-urlencoded on /oauth/token
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s

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.
This commit is contained in:
Marco Sadjadi 2026-05-28 21:21:40 +02:00
parent 0c6d738a6b
commit 44cebc9fd8

View File

@ -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<string, string> = {};
for (const [k, v] of params) out[k] = v;
done(null, out);
} catch (err) {
done(err as Error, undefined);
}
},
);
await app.register(cors, { await app.register(cors, {
origin: [config.NEXT_PUBLIC_APP_URL], origin: [config.NEXT_PUBLIC_APP_URL],
credentials: true, credentials: true,