Repository navigation
fix(client): optionally keep previously granted scopes when a refused refresh leads to a new authorization #2448
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Add `OAuthClientProvider.includePreviouslyGrantedScopes`: when `true`, a new authorization started because a refresh was refused also requests the scope the dropped tokens held, so permissions granted earlier through step-up survive. See [Keep earlier scopes when a refresh is refused](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/clients/oauth.md#keep-earlier-scopes-when-a-refresh-is-refused). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -271,6 +271,20 @@ export interface OAuthClientProvider { | |
| */ | ||
| skipOfflineAccessConsentPrompt?: boolean; | ||
|
|
||
| /** | ||
| * When `true`, a new authorization that {@linkcode auth} starts because a refresh was refused | ||
| * (`invalid_grant` or `invalid_dpop_proof`) also requests the scope the dropped tokens held, so | ||
| * permissions granted earlier (for example through step-up) are not lost. The MCP specification | ||
| * recommends this ("include these scopes alongside any previously granted scopes"). | ||
| * | ||
| * Values the authorization server does not list in `scopes_supported` and `offline_access` are | ||
| * left out. The scope is requested only once: if the server refuses it, the next authorization | ||
| * requests the selected scope alone. | ||
|
Comment on lines
+280
to
+282
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): readers whose authorization server publishes no Why this was flaggedNo runtime failure; this is a check of public wording the diff adds. It applies when the authorization server metadata has no Verification: packages/client/src/client/auth.ts:1134 filters with Finding ID: |
||
| * | ||
| * @default false | ||
| */ | ||
| includePreviouslyGrantedScopes?: boolean; | ||
|
|
||
| /** | ||
| * Metadata about this OAuth client. | ||
| */ | ||
|
|
@@ -1105,9 +1119,13 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions): | |
| // (RFC 9449 §5) to a DPoP key this process no longer holds — e.g. a non-extractable | ||
| // key regenerated across a restart. Like invalid_grant, the token set is unusable; | ||
| // drop it and fall through to a fresh authorization. | ||
| // With `includePreviouslyGrantedScopes`, that authorization asks again for the scope the | ||
| // dropped grant held. Read it first: once the tokens are gone nothing stored can supply | ||
| // it again, so a refused scope is requested only once. | ||
| const discarded = provider.includePreviouslyGrantedScopes ? await provider.tokens() : undefined; | ||
| warnCredentialInvalidation(provider, error, 'tokens'); | ||
| await provider.invalidateCredentials?.('tokens'); | ||
| return await authInternal(provider, options); | ||
| return await authInternal(provider, options, discarded?.scope); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1116,6 +1134,20 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions): | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * The permissions in a dropped grant's `scope` worth asking for again: values the authorization | ||
| * server advertises in `scopes_supported` (all of them when it advertises none), since a token's | ||
| * `scope` can hold values the server does not accept back in a request, such as expanded forms. | ||
| * `offline_access` is left to {@linkcode determineScope}: it is not a resource permission, and | ||
| * requesting it adds `prompt=consent` (SEP-2207). | ||
| */ | ||
| function filterToSupportedScopes(scope: string | undefined, metadata: AuthorizationServerMetadata | undefined): string | undefined { | ||
| const supported = metadata?.scopes_supported; | ||
| return computeScopeUnion( | ||
| ...(scope?.split(/\s+/) ?? []).filter(value => value !== 'offline_access' && (!supported?.length || supported.includes(value))) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Selects scopes per the MCP spec and augment for refresh token support. | ||
| */ | ||
|
|
@@ -1161,7 +1193,8 @@ async function authInternal( | |
| fetchFn, | ||
| skipIssuerMetadataValidation, | ||
| forceReauthorization | ||
| }: AuthOptions | ||
| }: AuthOptions, | ||
| discardedScope?: string | ||
| ): Promise<AuthResult> { | ||
| // SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope() | ||
| // intentionally reads the raw provider.clientMetadata instead. | ||
|
|
@@ -1461,13 +1494,19 @@ async function authInternal( | |
|
|
||
| const state = provider.state ? await provider.state() : undefined; | ||
|
|
||
| // Start new authorization flow | ||
| // Start new authorization flow. After a refused refresh dropped the tokens, also ask for the | ||
| // scope that grant held (MCP 2026-07-28: re-authorization includes previously granted scopes). | ||
| // Only when no tokens remain, so a stored grant can never re-supply a scope the server refused, | ||
| // and only when a scope is requested at all: an omitted `scope` gets the server's default grant, | ||
| // which an explicit partial request would narrow (RFC 6749 §3.3). | ||
| const authorizationScope = | ||
| tokens || !resolvedScope ? resolvedScope : computeScopeUnion(resolvedScope, filterToSupportedScopes(discardedScope, metadata)); | ||
| const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { | ||
| metadata, | ||
| clientInformation, | ||
| state, | ||
| redirectUrl: provider.redirectUrl, | ||
| scope: resolvedScope, | ||
| scope: authorizationScope, | ||
| resource, | ||
| skipOfflineAccessConsentPrompt: provider.skipOfflineAccessConsentPrompt | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 (optional) Developers who copy the new example get an option that never takes effect: a refused refresh throws
OAuthErrorinstead of a sign-in that keeps earlier scopes.KeepGrantedScopesProviderextendsMyOAuthProvider, which has noinvalidateCredentials, so the tokens are never dropped and auth.ts:1490 adds nothing. Fix: make the documented path work and say what it needs, by adding aninvalidateCredentialsthat clears tokens to the example and naming that requirement in this section and theincludePreviouslyGrantedScopesJSDoc (auth.ts:261-273).Why this was flagged
A provider built from the page's own example sets
includePreviouslyGrantedScopes = true(docs/clients/oauth.md:188-190) on top ofMyOAuthProvider, which defines noinvalidateCredentials(examples/guides/clients/oauth.examples.ts:43-98). Oninvalid_grant, packages/client/src/client/auth.ts:1114 callsprovider.invalidateCredentials?.('tokens'), a no-op, so the retry at auth.ts:1115 finds the same tokens at auth.ts:1421, refreshes again at auth.ts:1433 and rethrows at auth.ts:1468. No redirect happens, and thetokens ||guard at auth.ts:1490 would drop the carried scope anyway. The new test 'does not redirect when the provider cannot drop the grant' (packages/client/test/client/auth.test.ts:5780) pins exactly this. The base throws here too, but had no text claiming otherwise: line 185 says the SDK "drops the stored tokens and sends the user to sign in again", and the "Nothing is added when" list at line 193 omits this case.Verification: examples/guides/clients/oauth.examples.ts:43-98 —
MyOAuthProviderdefinestokens,saveTokens,state, discovery and verifier methods, but noinvalidateCredentials. auth.ts:1421 — the retry reads the same stored tokens; auth.ts:1433 seestokens?.refresh_tokenand refreshes again at 1438; the same refusal is rethrown at 1468. Nothing regresses against the base.Finding ID:
f-65e95c33