From 9f7c24860b7f488d6c7bac95eb146cf41af626bf Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 26 Oct 2026 10:43:09 +0000 Subject: [PATCH] fix(client): optionally keep the dropped grant's scope when a refused refresh leads to a new authorization When a refresh was refused (invalid_grant or invalid_dpop_proof), auth() dropped the stored tokens and started a new authorization for the selected scope only, so permissions granted earlier through step-up were lost and the user was sent back through step-up for them. Add OAuthClientProvider.includePreviouslyGrantedScopes (default false, so the request is unchanged unless a provider opts in). When true, auth() reads the token's scope before dropping it and adds it to that one authorization request (MCP 2026-07-28: re-authorization includes previously granted scopes). Once the tokens are gone nothing stored can supply it again, so a scope the server refuses is requested only once; it is carried only when no tokens remain and a scope is requested at all. offline_access and values the authorization server does not list in scopes_supported are left out. Code exchange, non-interactive token requests, the transports, step-up and withOAuth are unaffected. --- .../sep-2350-scope-accumulation-followup.md | 5 + docs/clients/oauth.md | 14 ++ examples/guides/clients/oauth.examples.ts | 6 + packages/client/src/client/auth.ts | 47 +++- packages/client/test/client/auth.test.ts | 222 ++++++++++++++++++ 5 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 .changeset/sep-2350-scope-accumulation-followup.md diff --git a/.changeset/sep-2350-scope-accumulation-followup.md b/.changeset/sep-2350-scope-accumulation-followup.md new file mode 100644 index 0000000000..9bb570326a --- /dev/null +++ b/.changeset/sep-2350-scope-accumulation-followup.md @@ -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). diff --git a/docs/clients/oauth.md b/docs/clients/oauth.md index f03a3e8af0..353c91f481 100644 --- a/docs/clients/oauth.md +++ b/docs/clients/oauth.md @@ -2,6 +2,7 @@ shape: how-to description: 'Sign an end user in from a client you build with the OAuth authorization-code flow.' --- + # Authenticate a user with OAuth Protecting a server you run → [Require authorization](../serving/authorization.md). Signing a user in from a client → this page. No user present → [Authenticate without a user](./machine-auth.md). @@ -183,6 +184,18 @@ The SDK sends the returned string on every refresh it makes for this provider, a Implement it only for an authorization server that needs it. Another server may reject a refresh that carries `scope`, or fail it once a granted scope is retired: a refresh that works without `scope` then ends in an `OAuthError` or a new sign-in. The `OAuthError` repeats on every request until you clear the stored tokens. ::: +## Keep earlier scopes when a refresh is refused + +When a refresh is refused (`invalid_grant` or `invalid_dpop_proof`), the SDK drops the stored tokens and sends the user to sign in again for the scope it would request on a first sign-in. Permissions granted earlier through step-up are not part of that, so the user steps up again for them. Set `includePreviouslyGrantedScopes` to request them in that sign-in too, as the MCP specification recommends. + +```ts source="../../examples/guides/clients/oauth.examples.ts#includePreviouslyGrantedScopes" +class KeepGrantedScopesProvider extends MyOAuthProvider { + includePreviouslyGrantedScopes = true; +} +``` + +The SDK reads the scope saved with the dropped tokens and leaves out `offline_access` and any value the authorization server does not list in `scopes_supported`. It requests that scope once: if the server refuses it, the next sign-in requests the usual scope alone. Nothing is added when the sign-in response had no `scope`, when there is no refresh token, or when no scope would be requested at all. + ## Recap - This page signs in an end user; machine-to-machine flows live on [Authenticate without a user](./machine-auth.md). @@ -192,3 +205,4 @@ Implement it only for an authorization server that needs it. Another server may - `IssuerMismatchError` is the mix-up defense — never render the callback's `error_description`. - `validateResourceURL` overrides the RFC 8707 `resource` parameter the SDK sends. - `scopeForRefresh` adds a `scope` to refresh requests, for authorization servers that reject a refresh without one. +- `includePreviouslyGrantedScopes` keeps scopes granted earlier when a refused refresh leads to a new sign-in. diff --git a/examples/guides/clients/oauth.examples.ts b/examples/guides/clients/oauth.examples.ts index 5de08a765e..3ca8c6be15 100644 --- a/examples/guides/clients/oauth.examples.ts +++ b/examples/guides/clients/oauth.examples.ts @@ -191,3 +191,9 @@ class ScopeOnRefreshProvider extends MyOAuthProvider { } } //#endregion scopeForRefresh_granted + +//#region includePreviouslyGrantedScopes +class KeepGrantedScopesProvider extends MyOAuthProvider { + includePreviouslyGrantedScopes = true; +} +//#endregion includePreviouslyGrantedScopes diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 69366521da..8a964be66f 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -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. + * + * @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 { // 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 }); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index b3cd42bd78..a914167c89 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5694,3 +5694,225 @@ describe('OAuth Authorization', () => { }); }); }); + +describe('re-authorization after a refused refresh, with includePreviouslyGrantedScopes', () => { + const ORIGIN = 'https://mcp.example.com'; + const MCP_URL = `${ORIGIN}/mcp`; + + /** + * An OAuth-protected MCP server reached through `fetchFn`. The token endpoint refuses refresh + * tokens (`refresh`), which is what drops the stored grant. + */ + type RefreshError = 'invalid_grant' | 'server_error'; + const createServer = (options: { refresh?: RefreshError | RefreshError[]; asScopes?: string[]; prmScopes?: string[] } = {}) => { + const tokenRequests: URLSearchParams[] = []; + const refreshErrors = [options.refresh ?? 'invalid_grant'].flat(); + const fetchFn = async (input: string | URL, init?: RequestInit): Promise => { + const url = new URL(input); + if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) { + return Response.json({ + resource: MCP_URL, + authorization_servers: [ORIGIN], + ...(options.prmScopes ? { scopes_supported: options.prmScopes } : {}) + }); + } + if (url.pathname.startsWith('/.well-known/oauth-authorization-server')) { + return Response.json({ + issuer: ORIGIN, + authorization_endpoint: `${ORIGIN}/authorize`, + token_endpoint: `${ORIGIN}/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + ...(options.asScopes ? { scopes_supported: options.asScopes } : {}) + }); + } + if (url.pathname === '/token') { + const params = new URLSearchParams(init?.body as string | URLSearchParams); + tokenRequests.push(params); + if (params.get('grant_type') === 'refresh_token') { + const error = refreshErrors.length > 1 ? refreshErrors.shift()! : refreshErrors[0]!; + return Response.json({ error }, { status: error === 'server_error' ? 500 : 400 }); + } + return Response.json({ access_token: 'new-token', token_type: 'Bearer' }); + } + throw new Error(`Unexpected request: ${url}`); + }; + return { fetchFn, tokenRequests }; + }; + + /** An interactive provider that stores tokens like a real one. */ + const createProvider = ( + initial: OAuthTokens | undefined, + options: { canInvalidate?: boolean; keepsTokensOnInvalidate?: boolean; interactive?: boolean; optIn?: boolean } = {} + ) => { + let stored: OAuthTokens | undefined = initial; + const interactive = options.interactive ?? true; + const provider: OAuthClientProvider & { authorizationScopes: Array } = { + authorizationScopes: [], + includePreviouslyGrantedScopes: options.optIn ?? true, + get redirectUrl() { + return interactive ? 'http://localhost:3000/callback' : undefined; + }, + get clientMetadata() { + return { redirect_uris: interactive ? ['http://localhost:3000/callback'] : [], client_name: 'Test Client' }; + }, + clientInformation: () => ({ client_id: 'client-1' }), + tokens: () => stored, + saveTokens: (tokens: OAuthTokens) => { + stored = tokens; + }, + saveCodeVerifier: () => {}, + codeVerifier: () => 'verifier', + redirectToAuthorization(url: URL) { + provider.authorizationScopes.push(url.searchParams.get('scope')); + }, + ...(options.canInvalidate === false + ? {} + : { + invalidateCredentials: (scope: string) => { + if (!options.keepsTokensOnInvalidate && (scope === 'tokens' || scope === 'all')) stored = undefined; + } + }), + ...(interactive + ? {} + : { + prepareTokenRequest: (scope?: string) => + new URLSearchParams({ grant_type: 'client_credentials', ...(scope ? { scope } : {}) }) + }) + }; + return provider; + }; + const asSet = (scope: string | null | undefined) => new Set(scope?.split(' ')); + + it('asks again for the scope the dropped grant held', async () => { + // A user granted `write` through step-up; the refresh is later refused. + const server = createServer(); + const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }); + + expect(await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).toBe('REDIRECT'); + + expect(asSet(provider.authorizationScopes[0])).toEqual(new Set(['read', 'write'])); + }); + + it('leaves the request as it was unless the provider opts in', async () => { + const server = createServer(); + const provider = createProvider( + { access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }, + { optIn: false } + ); + + await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn }); + + expect(provider.authorizationScopes).toEqual(['read']); + }); + + it('asks for the challenged scope alone once that request was refused', async () => { + // An admin removed `write`: the server answers that redirect with invalid_scope, so no token is saved. + const server = createServer(); + const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }); + await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn }); + + // The next 401 starts over. The dropped grant is gone, so nothing re-supplies `write`. + expect(await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).toBe('REDIRECT'); + + expect(provider.authorizationScopes.map(asSet)).toEqual([new Set(['read', 'write']), new Set(['read'])]); + }); + + it('asks for the challenged scope alone when the refresh fails without dropping the grant', async () => { + const server = createServer({ refresh: 'server_error' }); + const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn }); + + expect(provider.authorizationScopes).toEqual(['read']); + }); + + it('asks for the challenged scope alone when there is no refresh token', async () => { + const server = createServer(); + const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write' }); + + await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn }); + + expect(provider.authorizationScopes).toEqual(['read']); + }); + + it('does not redirect when the provider cannot drop the grant', async () => { + const server = createServer(); + const provider = createProvider( + { access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }, + { canInvalidate: false } + ); + + await expect(auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).rejects.toThrow(OAuthError); + + expect(provider.authorizationScopes).toEqual([]); + }); + + it('does not carry the scope while a stored grant remains', async () => { + // The provider ignores the invalidation and the second refresh fails softly, so the grant is still stored. + const server = createServer({ refresh: ['invalid_grant', 'server_error'] }); + const provider = createProvider( + { access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }, + { keepsTokensOnInvalidate: true } + ); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn }); + + expect(provider.authorizationScopes).toEqual(['read']); + }); + + it('keeps only values the authorization server advertises', async () => { + // The token reports expanded scopes the server does not accept back in a request. + const server = createServer({ asScopes: ['openid', 'offline_access'], prmScopes: ['api://app/.default'] }); + const provider = createProvider({ + access_token: 'old', + token_type: 'Bearer', + scope: 'openid api://app/Read', + refresh_token: 'r' + }); + + await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn }); + + expect(asSet(provider.authorizationScopes[0])).toEqual(new Set(['api://app/.default', 'openid'])); + }); + + it('leaves scope out when nothing selects one, so the server applies its default grant', async () => { + // No challenge scope, no PRM scopes_supported, no clientMetadata.scope; the server advertises only part of its scopes. + const server = createServer({ asScopes: ['openid', 'offline_access'] }); + const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'openid mcp:tools', refresh_token: 'r' }); + + await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn }); + + expect(provider.authorizationScopes).toEqual([null]); + }); + + it('leaves offline_access to the scope selection', async () => { + // The client does not declare the refresh_token grant, so the selection does not add offline_access (SEP-2207). + const server = createServer({ asScopes: ['read', 'write', 'offline_access'] }); + const provider = createProvider({ + access_token: 'old', + token_type: 'Bearer', + scope: 'read write offline_access', + refresh_token: 'r' + }); + + await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn }); + + expect(asSet(provider.authorizationScopes[0])).toEqual(new Set(['read', 'write'])); + }); + + it('leaves non-interactive token requests alone', async () => { + const server = createServer(); + const provider = createProvider( + { access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' }, + { interactive: false } + ); + + expect(await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).toBe('AUTHORIZED'); + + const tokenRequest = server.tokenRequests.find(params => params.get('grant_type') === 'client_credentials'); + expect(tokenRequest?.get('scope')).toBe('read'); + }); +});