Skip to content

fix(oauth): honor token_endpoint_auth_method: "none" for DCR public clients (PKCE-only)#909

Open
ding-modding wants to merge 1 commit into
garrytan:masterfrom
ding-modding:ding/oauth-public-client-fix
Open

fix(oauth): honor token_endpoint_auth_method: "none" for DCR public clients (PKCE-only)#909
ding-modding wants to merge 1 commit into
garrytan:masterfrom
ding-modding:ding/oauth-public-client-fix

Conversation

@ding-modding
Copy link
Copy Markdown

@ding-modding ding-modding commented May 12, 2026

Summary

registerClient (DCR) unconditionally generates a client_secret even when the client requests token_endpoint_auth_method: "none". The MCP SDK's clientAuth middleware then requires a secret on every /token exchange because client.client_secret is truthy from the stored row — which rejects every valid PKCE-only public-client flow (Claude Code, Cursor, MCP Inspector, …).

This PR makes registerClient honor "none" per RFC 7591 §2: no secret generated, NULL stored, no secret in the DCR response.

Repro

gbrain serve --http --enable-dcr --public-url https://<your-domain>

From a public-client MCP host (Claude Code shown — same behavior on Cursor):

claude mcp add --scope user --transport http gbrain "https://<your-domain>/mcp"
# trigger auth → browser opens → user clicks Allow → callback returns code

Before this PR: the /token exchange fails. The MCP SDK then surfaces:

Existing OAuth client information is required when exchanging an authorization code

After this PR: the same flow lands on a green ✓ Connected.

Root cause

src/core/oauth-provider.ts:172-194 always emits a fresh client_secret:

const clientSecret = generateToken('gbrain_cs_');
const secretHash = hashToken(clientSecret);
// ...
INSERT INTO oauth_clients (..., client_secret_hash, ...) VALUES (..., ${secretHash}, ...)

getClient reads client_secret_hash back as client.client_secret. The MCP SDK's middleware (@modelcontextprotocol/sdk/server/auth/middleware/clientAuth.js:19) then gates:

if (client.client_secret) {
  if (!client_secret) throw new InvalidClientError('Client secret is required');
  // ...
}

Public clients (per RFC 7591) cannot safely store a secret, so they send client_id + code_verifier only — and get rejected.

Fix

Gate secret generation on token_endpoint_auth_method:

const isPublicClient = client.token_endpoint_auth_method === 'none';
const clientSecret = isPublicClient ? undefined : generateToken('gbrain_cs_');
const secretHash = clientSecret ? hashToken(clientSecret) : null;

And omit client_secret from the DCR response for public clients. The oauth_clients.client_secret_hash column was already nullable — no schema migration needed. Confidential-client behavior is unchanged.

Tests

Two new cases in test/oauth.test.ts under client registration:

  • DCR with token_endpoint_auth_method "none" issues no client_secret — response has no secret, stored row's client_secret is falsy
  • DCR without token_endpoint_auth_method defaults to confidential client — regression guard so the default path still mints + stores a secret
$ bun test test/oauth.test.ts
 66 pass
 0 fail
 257 expect() calls

Spec references

  • RFC 7591 §2 — Client Metadata, token_endpoint_auth_method values including "none"
  • RFC 6749 §2.1, §10.1 — public vs confidential client classification
  • RFC 7636 — PKCE (the auth method public clients use instead of a secret)

Affected clients

Any MCP host that uses DCR with PKCE-only auth — i.e., any public OAuth 2.1 client. Confirmed in the wild for Claude Code and Cursor; the same break-mode applies to MCP Inspector and the reference clients in @modelcontextprotocol/sdk.


View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

…ients

RFC 7591 §2 specifies that clients registering via Dynamic Client
Registration with token_endpoint_auth_method "none" are public clients
and MUST NOT receive a client_secret. PKCE alone authenticates the
authorization code exchange.

Before this fix, registerClient unconditionally generated a client_secret
and stored its hash in oauth_clients.client_secret_hash. The MCP SDK's
clientAuth middleware (server/auth/middleware/clientAuth.js) inspects
the stored client and, when client.client_secret is truthy, requires
client_secret_post auth at /token. PKCE-only clients like Claude Code
and Cursor (both register with token_endpoint_auth_method: "none") sent
PKCE code_verifier with no secret, hit "Client secret is required", and
the SDK's client-side OAuth flow then surfaced "Existing OAuth client
information is required when exchanging an authorization code".

Fix: skip client_secret generation when token_endpoint_auth_method ===
"none", and omit client_secret from the DCR response in that case. The
oauth_clients.client_secret_hash column was already nullable (schema
unchanged). Confidential clients (default, or any non-"none" auth
method) continue to receive a secret exactly as before.

Tests: two new cases in client registration —
  - DCR with "none" issues no client_secret; stored secret is falsy
  - DCR without explicit auth method defaults to confidential (secret
    issued and stored)

Repro of the original bug:
  1. Start: gbrain serve --http --enable-dcr --public-url https://...
  2. From Claude Code: claude mcp add --transport http gbrain <url>
  3. Trigger auth → browser flow completes → token exchange fails with
     "Existing OAuth client information is required"
After fix, the same flow lands on a healthy token + green Connected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant