Skip to content

OAuth token endpoint rejects all client_secret_basic clients (client_id read from request body only) #342

Description

@rafaelborja

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions