Skip to content

fix(oauth): clamp authorize() requested scopes against client.scope (RFC 6749 §3.3)#727

Closed
garagon wants to merge 1 commit intogarrytan:masterfrom
garagon:garagon/oauth-authorize-scope-clamp
Closed

fix(oauth): clamp authorize() requested scopes against client.scope (RFC 6749 §3.3)#727
garagon wants to merge 1 commit intogarrytan:masterfrom
garagon:garagon/oauth-authorize-scope-clamp

Conversation

@garagon
Copy link
Copy Markdown
Contributor

@garagon garagon commented May 8, 2026

Summary

GBrainOAuthProvider.authorize() (src/core/oauth-provider.ts:235-259) inserted params.scopes || [] raw into oauth_codes. The MCP SDK's authorize handler (@modelcontextprotocol/sdk/.../auth/handlers/authorize.js) splits ?scope=... verbatim and forwards the parsed list to the provider, so the provider has to clamp against the client's registered grant. v0.28.11 didn't, which means a read-registered client requesting ?scope=admin had ['admin'] stored on the auth code and exchangeAuthorizationCode issued a fully-admin access token at /token exchange.

The asymmetry is the bug: the other two grant entry points already clamp.

  • exchangeClientCredentials (line 513-515) filters requested scopes through hasScope(allowedScopes, s).
  • exchangeRefreshToken's F3 (line 372-380, landed in v0.26.9) enforces RFC 6749 §6 subset against the original grant.

authorize() lined up with neither. This patch mirrors the client_credentials filter shape so all three grant entry points clamp consistently:

const allowedScopes = parseScopeString(client.scope);
const grantedScopes = (params.scopes || []).filter(s => hasScope(allowedScopes, s));

Empty/omitted requested scope keeps storing [] (existing shape, not a security boundary). The clamped subset is what the client sees in the scope field of the token response, which is the spec-compliant signal that the grant was reduced.

Tests

Added two pinning tests in test/oauth.test.ts:

  • authorize clamps requested scopes against client.scope (RFC 6749 §3.3) — read-only client requests ['read','write','admin'], the issued token carries only ['read'].
  • authorize subset request returns subsetread write client requesting ['read'] gets ['read'] (regression guard against over-clamping).

The existing v0.26.9 oauth.test.ts pins F3 (refresh clamp) but had no authorize-side coverage, which is why the regression survived.

$ bun test test/oauth.test.ts
 64 pass
 0 fail
 228 expect() calls

bun run typecheck clean.

Test plan

  • bun test test/oauth.test.ts green (64/64)
  • bun run typecheck clean
  • Pre-fix R12 PoC reported admin scope present? YES (still vulnerable); post-fix the same PoC reports admin scope present? no (FIXED).

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

  • Let Codesmith autofix CI failures and bot reviews

…RFC 6749 §3.3)

The MCP SDK's authorize handler (`@modelcontextprotocol/sdk/.../auth/handlers/authorize.js`)
splits `?scope=...` verbatim and forwards the parsed list to the provider, so the
provider has to clamp against the client's registered grant. v0.28.11
`authorize()` (src/core/oauth-provider.ts:235-259) inserted `params.scopes || []`
raw into `oauth_codes`, so a `read`-registered client requesting
`?scope=admin` had `['admin']` stored and `exchangeAuthorizationCode` issued
a fully-admin access token at /token exchange.

The asymmetry is the bug: the other two grant entry points already clamp.
`exchangeClientCredentials` (line 513-515) filters requested scopes through
`hasScope(allowedScopes, s)`, and `exchangeRefreshToken`'s F3 (line 372-380)
enforces RFC 6749 §6 subset against the original grant. authorize() lined up
with neither.

Fix mirrors the client_credentials filter shape so all three grant entry
points clamp consistently:

    const allowedScopes = parseScopeString(client.scope);
    const grantedScopes = (params.scopes || []).filter(s => hasScope(allowedScopes, s));

Empty/omitted requested scope keeps storing `[]` (existing shape, not a
security boundary). The clamped subset is what the client sees in the
`scope` field of the token response, which is the spec-compliant signal
that the grant was reduced.

Test coverage:
- New: authorize clamps requested scopes against client.scope (RFC 6749 §3.3)
  — read-only client requests ['read','write','admin'] and the issued token
  carries only ['read'].
- New: authorize subset request returns subset — 'read write' client
  requesting ['read'] gets ['read'] (regression guard against over-clamping).

The existing v0.26.9 oauth.test.ts pins F3 (refresh clamp) but had no
authorize-side coverage, which is why the regression survived.
@Supphaset
Copy link
Copy Markdown

Independent reproduction confirms — applying this patch fixes the issue end-to-end.

Repro before fix (gbrain v0.30.0, vanilla):

OAuth client registered via SQL with scope='read', grant_types=['authorization_code','refresh_token'], public (PKCE-only, client_secret_hash=NULL). PKCE-public sidesteps a separate plaintext-vs-hash bug in confidential-client auth (#720, addressed by #746) — this lets the auth_code path actually exchange tokens.

Hit /authorize?...&scope=admin+read+write+sources_admin+users_admin&..., exchange code at /token:

{
  "scope": "admin read write sources_admin users_admin"
}

whoami confirms all 5 scopes. put_page (write) succeeds — probe page lands. get_stats (admin) returns full stats. Read-only client became fully admin-equivalent.

After cherry-picking this PR:

Same probe with the same client + same scope=admin+read+write+sources_admin+users_admin URL → token comes back with "scope":"read" only. put_page rejected with insufficient_scope. Contract holds.

bun test test/oauth.test.ts clean (64/64 pass), bun run typecheck clean.

Context: I was wiring gbrain into Claude Code as a read-only MCP transport for an Obsidian-vault sovereignty contract — the auth_code grant is the path Claude Code's MCP HTTP transport defaults to, so this clamp is what makes the read-only registration actually mean "read-only." The asymmetry the PR description calls out (exchangeClientCredentials already clamps; authorize() didn't) was visible in the wild — client_credentials tokens narrowed correctly while auth_code tokens didn't, against the same client.

Happy to land this so we can drop the local vendor branch. Thanks for catching it, @garagon.

@panda850819
Copy link
Copy Markdown

Overall LGTM — ships a real bug, comment + tests make the intent durable. Two notes before garrytan picks it up, one substantive, one a cheap test addition.

1. Filter mirrors exchangeClientCredentials, but the omit-scope branch doesn't

The PR frames this as "all three grant entry points clamp consistently." For the filter step, yes. But the omit-scope branch leaves a residual asymmetry:

// exchangeClientCredentials, line 517-519
const requestedScopes = requestedScope ? parseScopeString(requestedScope) : allowedScopes;
//                                                                          ^^^^^^^^^^^^^

// this PR, authorize()
const grantedScopes = (params.scopes || []).filter(s => hasScope(allowedScopes, s));
//                    ^^^^^^^^^^^^^^^^^^^^

When the client omits ?scope= entirely:

  • client_credentials → grants client.scope as default
  • authorize() after this PR → grants []

Not a security boundary (erring restrictive is the safe side), and the PR description acknowledges it as existing shape. But RFC 6749 §3.3 says the AS MUST "process using a pre-defined default value or fail" — granting [] lands at "succeed and issue a token that fails every op with insufficient_scope," which is the worst UX of the three options. Concretely surfaces in clients that don't always include scope= (ChatGPT MCP connector flow). Suggest closing this in the same PR for true symmetry:

const requestedScopes = (params.scopes && params.scopes.length > 0)
  ? params.scopes
  : allowedScopes;
const grantedScopes = requestedScopes.filter(s => hasScope(allowedScopes, s));

2. Worth pinning hierarchy through this filter

The two new tests cover clamp + subset. They don't pin that `hasScope`'s `admin → {admin, sources_admin, users_admin, write, read}` hierarchy still fires through this filter — without a pin, a future refactor that swaps `hasScope` for `Set.has` would silently break admin-client-requests-read flows. Suggest:

```ts
test('authorize honors hierarchy — admin client can mint read-only token', async () => {
// client.scope = 'admin', params.scopes = ['read']
// expect auth.scopes === ['read']
});
```

Thanks for catching this — happy to land both for the cleanup.

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.

3 participants