fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350)#2265
fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350)#2265mattzcarey wants to merge 2 commits into
Conversation
…enges (SEP-2350) Per the draft authorization spec's step-up authorization flow, scope accumulation is a client-side responsibility: when re-authorizing after a 403 insufficient_scope challenge, the client SHOULD request the union of previously requested/granted scopes and the newly challenged scopes. Previously the StreamableHTTP transport replaced the requested scope with the challenged scopes only, dropping previously granted permissions. - Add exported unionScopes() helper (opaque-string, order-preserving, deduped union of space-delimited scope strings) - Union stored token scope + previously requested scope + challenged scope in the 403 insufficient_scope retry path - Tests for the step-up union, dedup, and no-prior-scope cases plus unionScopes unit tests Closes #2200
🦋 Changeset detectedLatest commit: e1ddc2f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
There was a problem hiding this comment.
The union logic and unit tests look right, but the conformance suite doesn't exercise it yet. Since this is the SEP-2350 PR, could we wire that up here?
It will still warn because for the scenario to pass it also needs #2266, but given this PR is about step up auth, it would be create to wire the auth/scope-step-up as part of this PR as an expected failure in expected-failures.yml and then remove it with #2266
The fix in test/conformance/src/helpers/withOAuthRetry.ts:
-import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client';
+import { auth, extractWWWAuthenticateParams, unionScopes, UnauthorizedError } from '@modelcontextprotocol/client';
@@ handle401
- const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
+ const { resourceMetadataUrl, scope: challengedScope } = extractWWWAuthenticateParams(response);
+ const scope = unionScopes((await provider.tokens())?.scope, challengedScope);Checked locally: the SEP-2350 check passes and the full client suite still exits 0. Leave auth/scope-step-up in expected-failures.yaml for now since it still fails the SEP-837 check; #2266 drops it once that lands.
Implements SEP-2350 (tracking issue #2200): client-side scope accumulation in step-up authorization.
Spec
The draft authorization spec (
basic/authorization/index.mdx, "Scope Challenge Handling" note and "Step-Up Authorization Flow" step 2) makes scope accumulation a client-side responsibility:Servers stay stateless and emit only per-operation scopes in
WWW-Authenticate: Bearer error="insufficient_scope", scope="..."challenges.Before / after
Before: the
403 insufficient_scoperetry path inStreamableHTTPClientTransportdidthis._scope = scope— the challenged scopes replaced the requested scope, so the re-authorization dropped everything previously granted. A client holdingread writethat hit a challenge foradminwould re-authorize with onlyadmin.After: the client requests the union of (1) the scope on the stored tokens (previously granted), (2) the previously requested scope, and (3) the newly challenged scope. The same client now re-authorizes with
read write admin. The union is order-preserving and exact-string-deduped — scopes are treated as opaque strings per the spec, so no hierarchy-aware deduplication (repodoes not absorbrepo:read).The helper is exported as
unionScopes(...scopeStrings: Array<string | undefined>): string | undefinedfrom@modelcontextprotocol/client.Retry limiting is unchanged: the existing
_lastUpscopingHeaderguard still prevents infinite upscoping loops.Decision note for reviewers: 401 path left as-is
The
401path (handleOAuthUnauthorized/ thethis._scope = scopeassignments in the 401 branches ofstreamableHttp.tsandsse.ts) is intentionally unchanged. The spec's union language is specifically about re-authorization during step-up after aninsufficient_scopechallenge; a 401 carries initial-auth semantics (no valid grant to preserve), and applying the union there is debatable. Flagging explicitly in case reviewers want union-on-401 too — happy to follow up.Relatedly,
sse.tshas no403 insufficient_scopehandler at all (only 401 paths), so there was nothing to mirror there.Validation
pnpm build:all✅pnpm typecheck:all✅pnpm lint:all✅pnpm --filter @modelcontextprotocol/client test— 378/378 ✅ (new: 3 step-up integration tests instreamableHttp.test.tscovering union, dedup-on-repeated-scope, and no-prior-scope; 8unionScopesunit tests inauth.test.ts)@modelcontextprotocol/clientDownstream impact: cloudflare/agents needs no changes — its DurableObjectOAuthClientProvider persists token scope, so the union picks previously granted scopes up automatically.
A follow-up PR implementing SEP-2468 (iss validation) touches the same transport retry region and will be rebased over this.
Closes #2200