Skip to content

OAuth auth-code flow broken: SDK clientAuth plaintext compare vs hashed client_secret in getClient() #803

@billy-armstrong

Description

@billy-armstrong

Summary

OAuth authorization_code flow is broken end-to-end on gbrain v0.31.x for any properly-spec'd MCP client (Claude Desktop, Claude iOS, anything that goes through @modelcontextprotocol/sdk's clientAuth middleware). Claude Code (legacy static-bearer path) is unaffected because it bypasses this code path.

Root cause is a plaintext-vs-hash mismatch between gbrain's storage and the SDK's compare logic.

Mechanism

SDK side (@modelcontextprotocol/sdk v1.29.0, dist/cjs/server/auth/middleware/clientAuth.js:49):

const { client_id, client_secret } = result.data;        // ← plaintext from request
const client = await clientsStore.getClient(client_id);  // ← whatever provider returns
...
if (client.client_secret !== client_secret) {            // ← strict ===, no hashing
    throw new errors_js_1.InvalidClientError('Invalid client_secret');
}

The SDK does a plaintext !== compare against whatever the provider's getClient() returns as client_secret. No hashing.

gbrain side (src/core/oauth-provider.ts:134-156):

async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
  const rows = await this.sql`
    SELECT client_id, client_secret_hash, client_name, ...
    FROM oauth_clients WHERE client_id = ${clientId}
  `;
  ...
  return {
    client_id: r.client_id as string,
    client_secret: r.client_secret_hash as string | undefined,  // ← line 145: hash returned AS client_secret
    ...
  };
}

getClient() returns the hash as the client_secret field. So the SDK compares plaintext-from-request against hash-from-DB. Never matches. Every auth-code flow fails with InvalidClientError: Invalid client_secret.

Repro

  1. Run gbrain serve --http --enable-dcr v0.31.x against Postgres
  2. Register an MCP client with auth-code grant (Claude Desktop does this via DCR automatically; the registration succeeds)
  3. Attempt the OAuth code-exchange step: SDK rejects every request with Invalid client_secret
  4. Server log shows [clientAuth] Invalid client_secret for the registered client

gbrain auth list confirms the client exists; the plaintext secret is the one the SDK is sending; the stored hash is what getClient() returns. Fail.

Why Claude Code works anyway

Claude Code's static-bearer path (/tmp/gbrain-bearer) bypasses clientAuth entirely — it goes through bearerAuth.js, which validates against access-token records in oauth_access_tokens, not the client_secret store. So this bug only bites DCR-registered, auth-code-grant clients.

Suggested fixes (in order of architectural cleanliness)

Option 1 — Patch the SDK (cleanest, slowest)

Get @modelcontextprotocol/sdk to do hash-aware compare in clientAuth.js. The clientsStore could expose a verifyClientSecret(clientId, secret) callback that lets the provider implement constant-time hash compare. This is the architecturally right answer but requires a PR upstream against @modelcontextprotocol/sdk and waiting for a release that gbrain can pin.

Option 2 — Override clientAuth in gbrain

Replace the SDK's clientAuth middleware in gbrain's serve-http.ts with a gbrain-implemented version that does if (hashToken(client_secret) === client.client_secret_hash). Keeps DB hashed, no SDK change needed. Larger surface to maintain in gbrain.

Option 3 — Store plaintext (functional fix, security regression)

Three sites in oauth-provider.ts (lines 174, 504, 569) currently do const secretHash = hashToken(clientSecret). Change to const secretHash = clientSecret. The DB column name client_secret_hash becomes a misnomer. If the DB ever leaks, OAuth secrets leak directly instead of leaking-as-SHA-256-hashes (a thin layer in practice — the secrets are 256-bit random tokens, so SHA-256 is computationally trivial to verify against a known plaintext, but not trivial to reverse).

Option 4 — Suppress the SDK's compare entirely

Have getClient() return the row with client_secret: undefined, then put gbrain's own auth logic upstream of the SDK middleware in serve-http.ts. The SDK's if (client.client_secret) branch is skipped on undefined and the request proceeds to req.client = client. Then gbrain's own middleware verifies the secret against the hash before handing off to the route. Surgical but spreads auth across two layers.

What I'm running

I shipped Option 3 as a local patch on my brain server (3 sites, one-line each, with an inline comment marker). The OAuth auth-code flow now works end-to-end for both Claude Desktop and a thin-client gbrain init --mcp-only setup. I'm comfortable filing the diff as a PR if you want it as a stop-gap, but I don't think Option 3 is the right architectural answer — Option 1 or 2 is. Filing this issue first to let you pick the path you want.

Happy to drive the PR for whichever option you prefer.

Environment

  • gbrain commit: eec2d2b (v0.31.2)
  • @modelcontextprotocol/sdk: v1.29.0
  • Postgres 16 on Linux, gbrain v0.31 storage schema
  • Affects: Claude Desktop, Claude iOS, any DCR-registered auth-code MCP client
  • Does not affect: Claude Code (legacy bearer path)

🤖 Filed via Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions