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.
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"}evenwith correct credentials. Same for /revoke.
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. viagbrain auth create-client) exhibits identical behavior on /token + /revoke.Root cause:
src/core/oauth-provider.ts:144returns the stored SHA256 hash as theclient_secretfield:The MCP SDK's
authenticateClientmiddleware at@modelcontextprotocol/sdk/dist/cjs/server/auth/middleware/clientAuth.jsuses thisclient_secretfield for plaintext string equality comparison: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()returnsOAuthClientInformationFullwithclient_secrettyped as plaintext string, compared via!==downstream). gbrain'sgetClient()violates this contract by populating that field with hashed data. The fix belongs in gbrain unless the SDK adds averifyClientSecret(clientId, submittedSecret)hook (see Option B below).Affected handlers (per
@modelcontextprotocol/sdkmiddleware usage):NOT affected:
src/commands/serve-http.ts:261that bypasses the SDKSuggested 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,timingSafeEqualagainst stored hash. If match, rewritereq.body.client_secretto 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/sdkaverifyClientSecret(clientId, submittedSecret): Promise<boolean>hook onOAuthServerProvider. Implement gbrain's hook to do hash-and-compare. Update SDK'sauthenticateClientmiddleware to use the hook when available, falling back to the current!==comparison only when the provider doesn't implement the hook.Severity rationale:
invalid_clientwhich 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.