Summary
apps/backend/src/routers/oauth/token.ts reads client_id from the request body only. RFC 6749 §2.3.1 places client credentials in the Authorization: Basic header for confidential clients, so for any client registered with token_endpoint_auth_method: "client_secret_basic" the value is undefined — and the authorization_code grant then fails 100% of the time.
Verified against ai-dev at the time of writing.
The code
Both grant handlers destructure it from the body:
// handleAuthorizationCodeGrant
const { code, redirect_uri, client_id, code_verifier } = req.body;
// handleRefreshTokenGrant
const { refresh_token, client_id } = req.body;
In handleAuthorizationCodeGrant the guard is unconditional, so it always fires for a Basic client:
if (codeData.client_id !== client_id) {
return res.status(400).json({
error: "invalid_client",
error_description: "Client ID does not match",
});
}
The file does contain a client_secret_basic branch that parses the Authorization header — but it sits below this guard, so it is unreachable for exactly the clients it exists to serve:
if (clientData.token_endpoint_auth_method === "client_secret_basic") {
const authHeader = req.headers.authorization;
...
if (authClientId !== client_id || authClientSecret !== clientData.client_secret) {
Note it compares authClientId !== client_id, i.e. the header value against the (undefined) body value. That dead branch reads as evidence the support was intended rather than deliberately omitted.
handleRefreshTokenGrant differs and is worth calling out separately — its guard is conditional:
if (client_id && tokenData.client_id !== client_id) {
So a Basic client does not get rejected there; instead client validation is silently skipped, because client_id is falsy. That is a weaker check rather than an outright failure, but it is the same root cause.
Impact
Any MCP client registering with client_secret_basic can never complete an authorization code exchange. A concrete real-world split: Google Gemini's custom connected apps register client_secret_basic and fail every time, while Claude registers client_secret_post and works — same server, same endpoint, same user.
The user-visible symptom is uninformative. Gemini reports only "Account linking is required to use this custom app. Try again." Server-side it looks like an ordinary 400. It is diagnosable only from a browser HAR, where Google's AccountLinkingService/FinishOAuth returns 409 with the upstream reply embedded:
error=invalid_client&error_description=Client%20ID%20does%20not%20match
Reproduced deterministically against a clean instance, varying only the registered auth method:
token_endpoint_auth_method |
token exchange |
client_secret_basic |
400 invalid_client |
client_secret_post |
200 |
DCR accepts client_secret_basic and issues a secret for it, so the server advertises support for a method it cannot honour.
Fix
Resolve client_id from the body when present, else from the Basic header, and use it in both handlers. Body-first means client_secret_post and public (none) clients are unaffected. The existing client_secret_basic secret-validation branch needs no change — it starts working once client_id resolves.
I have this running in production and will open a PR against ai-dev shortly.
Summary
apps/backend/src/routers/oauth/token.tsreadsclient_idfrom the request body only. RFC 6749 §2.3.1 places client credentials in theAuthorization: Basicheader for confidential clients, so for any client registered withtoken_endpoint_auth_method: "client_secret_basic"the value isundefined— and theauthorization_codegrant then fails 100% of the time.Verified against
ai-devat the time of writing.The code
Both grant handlers destructure it from the body:
In
handleAuthorizationCodeGrantthe guard is unconditional, so it always fires for a Basic client:The file does contain a
client_secret_basicbranch that parses the Authorization header — but it sits below this guard, so it is unreachable for exactly the clients it exists to serve:Note it compares
authClientId !== client_id, i.e. the header value against the (undefined) body value. That dead branch reads as evidence the support was intended rather than deliberately omitted.handleRefreshTokenGrantdiffers and is worth calling out separately — its guard is conditional:So a Basic client does not get rejected there; instead client validation is silently skipped, because
client_idis falsy. That is a weaker check rather than an outright failure, but it is the same root cause.Impact
Any MCP client registering with
client_secret_basiccan never complete an authorization code exchange. A concrete real-world split: Google Gemini's custom connected apps registerclient_secret_basicand fail every time, while Claude registersclient_secret_postand works — same server, same endpoint, same user.The user-visible symptom is uninformative. Gemini reports only "Account linking is required to use this custom app. Try again." Server-side it looks like an ordinary 400. It is diagnosable only from a browser HAR, where Google's
AccountLinkingService/FinishOAuthreturns 409 with the upstream reply embedded:Reproduced deterministically against a clean instance, varying only the registered auth method:
token_endpoint_auth_methodclient_secret_basicinvalid_clientclient_secret_postDCR accepts
client_secret_basicand issues a secret for it, so the server advertises support for a method it cannot honour.Fix
Resolve
client_idfrom the body when present, else from the Basic header, and use it in both handlers. Body-first meansclient_secret_postand public (none) clients are unaffected. The existingclient_secret_basicsecret-validation branch needs no change — it starts working onceclient_idresolves.I have this running in production and will open a PR against
ai-devshortly.