fix(oauth): clamp authorize() requested scopes against client.scope (RFC 6749 §3.3)#727
fix(oauth): clamp authorize() requested scopes against client.scope (RFC 6749 §3.3)#727garagon wants to merge 1 commit intogarrytan:masterfrom
Conversation
…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.
|
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 Hit {
"scope": "admin read write sources_admin users_admin"
}
After cherry-picking this PR: Same probe with the same client + same
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 ( Happy to land this so we can drop the local vendor branch. Thanks for catching it, @garagon. |
|
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
|
Summary
GBrainOAuthProvider.authorize()(src/core/oauth-provider.ts:235-259) insertedparams.scopes || []raw intooauth_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 aread-registered client requesting?scope=adminhad['admin']stored on the auth code andexchangeAuthorizationCodeissued 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 throughhasScope(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:Empty/omitted requested scope keeps storing
[](existing shape, not a security boundary). The clamped subset is what the client sees in thescopefield 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 subset—read writeclient requesting['read']gets['read'](regression guard against over-clamping).The existing v0.26.9
oauth.test.tspins F3 (refresh clamp) but had no authorize-side coverage, which is why the regression survived.bun run typecheckclean.Test plan
bun test test/oauth.test.tsgreen (64/64)bun run typecheckcleanadmin scope present? YES (still vulnerable); post-fix the same PoC reportsadmin scope present? no (FIXED).Need help on this PR? Tag
@codesmithwith what you need.