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
- Run
gbrain serve --http --enable-dcr v0.31.x against Postgres
- Register an MCP client with auth-code grant (
Claude Desktop does this via DCR automatically; the registration succeeds)
- Attempt the OAuth code-exchange step: SDK rejects every request with
Invalid client_secret
- 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
Summary
OAuth
authorization_codeflow 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'sclientAuthmiddleware). 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/sdkv1.29.0,dist/cjs/server/auth/middleware/clientAuth.js:49):The SDK does a plaintext
!==compare against whatever the provider'sgetClient()returns asclient_secret. No hashing.gbrain side (
src/core/oauth-provider.ts:134-156):getClient()returns the hash as theclient_secretfield. So the SDK compares plaintext-from-request against hash-from-DB. Never matches. Every auth-code flow fails withInvalidClientError: Invalid client_secret.Repro
gbrain serve --http --enable-dcrv0.31.x against PostgresClaude Desktopdoes this via DCR automatically; the registration succeeds)Invalid client_secret[clientAuth] Invalid client_secretfor the registered clientgbrain auth listconfirms the client exists; the plaintext secret is the one the SDK is sending; the stored hash is whatgetClient()returns. Fail.Why Claude Code works anyway
Claude Code's static-bearer path (
/tmp/gbrain-bearer) bypassesclientAuthentirely — it goes throughbearerAuth.js, which validates against access-token records inoauth_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/sdkto do hash-aware compare inclientAuth.js. The clientsStore could expose averifyClientSecret(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/sdkand waiting for a release that gbrain can pin.Option 2 — Override
clientAuthin gbrainReplace the SDK's
clientAuthmiddleware in gbrain'sserve-http.tswith a gbrain-implemented version that doesif (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 doconst secretHash = hashToken(clientSecret). Change toconst secretHash = clientSecret. The DB column nameclient_secret_hashbecomes 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 withclient_secret: undefined, then put gbrain's own auth logic upstream of the SDK middleware in serve-http.ts. The SDK'sif (client.client_secret)branch is skipped on undefined and the request proceeds toreq.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-onlysetup. 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
eec2d2b(v0.31.2)@modelcontextprotocol/sdk: v1.29.0🤖 Filed via Claude Code