Skip to content

oauth: clientsStore.getClient() returns client_secret_hash where SDK expects plaintext client_secret; breaks /token and /revoke for all authorization_code clients #1166

@dcarolan1

Description

@dcarolan1

Affected version: gbrain 0.33.1.1 (HEAD cb8d6d8, 2026-05-13); likely older.

Discovery context: discovered while wiring Anthropic's claude.ai consumer connector to a gbrain MCP server. A working local patch is in production at the affected deployment (~75 LoC additive in src/commands/serve-http.ts; reversible by deletion). Happy to attach as PR or diff.

Reproduction: any authorization_code /token exchange fails with HTTP 400
{"error":"invalid_client","error_description":"Invalid client_secret"} even
with correct credentials. Same for /revoke.

# 1. Register a fresh DCR client
curl -X POST https://my-gbrain.example.com/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"test","redirect_uris":["https://example.com/cb"],"grant_types":["authorization_code"],"token_endpoint_auth_method":"client_secret_post"}'
# Returns: {client_id: "gbrain_cl_X", client_secret: "gbrain_cs_Y", ...}

# 2. Authorize (get a code)
curl -i "https://my-gbrain.example.com/authorize?client_id=gbrain_cl_X&...&code_challenge=Z&code_challenge_method=S256"
# 302 → Location includes ?code=gbrain_code_W

# 3. Exchange code at /token using the secret from step 1
curl -X POST https://my-gbrain.example.com/token \
  -d "grant_type=authorization_code&code=gbrain_code_W&client_id=gbrain_cl_X&client_secret=gbrain_cs_Y&code_verifier=V&redirect_uri=https://example.com/cb"
# 400 {"error":"invalid_client","error_description":"Invalid client_secret"}
#   ← BUG: server rejects the secret it just issued in step 1

Scope: this affects every authorization_code client, not just DCR-registered ones. The bug is in the universal getClient() path, not in DCR-specific code. An admin-created client (e.g. via gbrain auth create-client) exhibits identical behavior on /token + /revoke.

Root cause: src/core/oauth-provider.ts:144 returns the stored SHA256 hash as the client_secret field:

async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
  // ...
  return {
    // ...
    client_secret: r.client_secret_hash as string | undefined,  // ← BUG
    // ...
  };
}

The MCP SDK's authenticateClient middleware at @modelcontextprotocol/sdk/dist/cjs/server/auth/middleware/clientAuth.js uses this client_secret field for plaintext string equality comparison:

const client = await clientsStore.getClient(client_id);
if (client.client_secret) {
  if (client.client_secret !== client_secret) {  // ← submitted plaintext vs stored hash
    throw new InvalidClientError('Invalid client_secret');
  }
}

Since gbrain stores hashed secrets (correct security practice) but returns the hash as if it were plaintext, the comparison always fails. Every authorization_code /token exchange and every /revoke call fails.

Ownership: the SDK is doing what its interface contract documents (OAuthRegisteredClientsStore.getClient() returns OAuthClientInformationFull with client_secret typed as plaintext string, compared via !== downstream). gbrain's getClient() violates this contract by populating that field with hashed data. The fix belongs in gbrain unless the SDK adds a verifyClientSecret(clientId, submittedSecret) hook (see Option B below).

Affected handlers (per @modelcontextprotocol/sdk middleware usage):

  • /token (authorization_code grant; refresh_token grant)
  • /revoke

NOT affected:

  • /token (client_credentials grant) — gbrain has its own custom handler at src/commands/serve-http.ts:261 that bypasses the SDK
  • /authorize — no client_secret in that flow
  • /register — no client_secret in that flow

Suggested fix: implement secret verification in the gbrain provider layer rather than relying on the SDK's plaintext comparison. Two options:

Option A (minimal): intercept /token and /revoke before the SDK auth router, hash the submitted secret with the same hashToken() function /register uses, timingSafeEqual against stored hash. If match, rewrite req.body.client_secret to the stored hash so the SDK's downstream comparison passes. If mismatch, return 400 invalid_client. Working local patch in production at the affected deployment (~75 LoC additive in serve-http.ts, no SDK or provider modifications); happy to attach as PR or diff.

Option B (proper): propose to upstream @modelcontextprotocol/sdk a verifyClientSecret(clientId, submittedSecret): Promise<boolean> hook on OAuthServerProvider. Implement gbrain's hook to do hash-and-compare. Update SDK's authenticateClient middleware to use the hook when available, falling back to the current !== comparison only when the provider doesn't implement the hook.

Severity rationale:

  • Universal: affects every authorization_code OAuth flow on gbrain. Every Claude.ai connector. Every Claude Desktop MCP server config. Every ChatGPT MCP. Every Perplexity MCP. Etc.
  • Silent at first glance: error is invalid_client which most operators interpret as "I configured the wrong secret" rather than "the server is broken." Days lost to credential rotation attempts before root cause emerges.
  • Affects production deployment paths: client_credentials grant has its own custom gbrain handler so machine-to-machine usage works, but human-facing browser OAuth flows are dead end-to-end.
  • High blast radius for the gbrain user base: anyone wiring up an OAuth-protected gbrain MCP server hits this on the first /token attempt.

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