From d6381d1c622fd92d2a71032bd10f66d12aca164e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:25:57 +0000 Subject: [PATCH 1/2] feat(client): add an opt-in for sending scope on token refresh Some authorization servers reject a refresh-token request that carries no `scope` (#2718: Microsoft Entra ID answers AADSTS90009 when the client and the resource are the same app registration). Omitting it is allowed by RFC 6749 section 6 and other servers reject or narrow a refresh that does carry one, so the default request does not change. - `OAuthClientProvider.scopeForRefresh?(tokens)` returns the scope `auth()` sends on a refresh. Every caller of `auth()` takes the provider (both HTTP transports, their step-up path, `withOAuth`), so one member reaches them all. - `refreshAuthorization()` takes an optional `scope` for hosts that call it directly. An empty string sends nothing. - When a scope was sent and the response has none, the sent scope stays on the returned tokens (RFC 6749 section 5.1: an omitted scope equals the requested one). Without this, `tokens => tokens.scope` has nothing to return on the second refresh. It never applies when no scope is sent. A rejected refresh is handled as before: `invalid_scope` is thrown to the caller. An error thrown by the hook rejects `auth()`. Alternative to #2720, which sends the stored scope by default. --- .changeset/refresh-scope-opt-in.md | 5 ++ docs/clients/oauth.md | 19 ++++ examples/guides/clients/oauth.examples.ts | 12 +++ packages/client/src/client/auth.ts | 28 +++++- packages/client/test/client/auth.test.ts | 100 ++++++++++++++++++++++ test/e2e/requirements.ts | 14 +++ test/e2e/scenarios/client-auth.test.ts | 89 +++++++++++++++++++ 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 .changeset/refresh-scope-opt-in.md diff --git a/.changeset/refresh-scope-opt-in.md b/.changeset/refresh-scope-opt-in.md new file mode 100644 index 0000000000..545f6ea06e --- /dev/null +++ b/.changeset/refresh-scope-opt-in.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add an opt-in for sending `scope` on refresh-token requests, for authorization servers that reject a refresh without it (Microsoft Entra ID answers `AADSTS90009` when the client and the resource are the same app registration, #2718). `OAuthClientProvider` gains an optional `scopeForRefresh(tokens)`: `auth()`, and so the transports and `withOAuth`, sends the string it returns. `refreshAuthorization()` gains an optional `scope`. When a scope is sent and the response has none, the sent scope is kept on the returned tokens. Without either option the refresh request and the saved tokens are unchanged. diff --git a/docs/clients/oauth.md b/docs/clients/oauth.md index f713c2e04d..dfc51cf3d8 100644 --- a/docs/clients/oauth.md +++ b/docs/clients/oauth.md @@ -161,6 +161,24 @@ class PinnedResourceProvider extends MyOAuthProvider { `PinnedResourceProvider` sends the server's own URL as `resource` on every leg of the flow and refuses metadata that names a different one. `checkResourceAllowed` and `resourceUrlFromServerUrl` are exported for exactly this override. +## Send scope on refresh + +A refresh request carries no `scope` by default, which asks for the scope already granted (RFC 6749 §6). Some authorization servers reject that — Microsoft Entra ID answers `AADSTS90009` when the client and the resource are the same app registration. Implement `scopeForRefresh` to send one. + +```ts source="../../examples/guides/clients/oauth.examples.ts#scopeForRefresh_granted" +class ScopeOnRefreshProvider extends MyOAuthProvider { + scopeForRefresh(tokens: OAuthTokens) { + return tokens.scope; // the scope saved with these tokens + } +} +``` + +The SDK sends the returned string on every refresh it makes for this provider, and saves it with the new tokens when the refresh response has no `scope`; returning `undefined` sends none. If the sign-in response had no `scope`, `tokens.scope` is `undefined`: return the scope you requested instead. `refreshAuthorization()` takes the same value as its `scope` option. + +::: warning +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. +::: + ## Recap - This page signs in an end user; machine-to-machine flows live on [Authenticate without a user](./machine-auth.md). @@ -169,3 +187,4 @@ class PinnedResourceProvider extends MyOAuthProvider { - Reconnect on a fresh transport; OAuth state lives on the provider, not the transport. - `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. diff --git a/examples/guides/clients/oauth.examples.ts b/examples/guides/clients/oauth.examples.ts index e740c45d7d..5de08a765e 100644 --- a/examples/guides/clients/oauth.examples.ts +++ b/examples/guides/clients/oauth.examples.ts @@ -179,3 +179,15 @@ class PinnedResourceProvider extends MyOAuthProvider { } } //#endregion validateResourceURL_pin + +// --------------------------------------------------------------------------- +// "Send scope on refresh" +// --------------------------------------------------------------------------- + +//#region scopeForRefresh_granted +class ScopeOnRefreshProvider extends MyOAuthProvider { + scopeForRefresh(tokens: OAuthTokens) { + return tokens.scope; // the scope saved with these tokens + } +} +//#endregion scopeForRefresh_granted diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a95aca0a03..e7d9e1303f 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -386,6 +386,19 @@ export interface OAuthClientProvider { */ validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; + /** + * Returns the `scope` that {@linkcode auth} sends on a refresh-token request. Left undefined + * (the default), or when it returns `undefined` or `''`, the request carries no `scope`, which + * asks for the scope already granted (RFC 6749 §6). + * + * Implement it only for an authorization server that rejects a refresh without `scope`: + * other servers may reject or narrow a refresh that carries one. The returned value must + * not include a scope that was not originally granted. + * + * @param tokens - The stored tokens being refreshed; `tokens.scope` is the scope last saved with them. + */ + scopeForRefresh?(tokens: StoredOAuthTokens): string | undefined | Promise; + /** * If implemented, provides a way for the client to invalidate (e.g. delete) the specified * credentials, in the case where the server has indicated that they are no longer valid. @@ -1385,6 +1398,7 @@ async function authInternal( // current token's granted scope — refreshing would not widen it (RFC 6749 // §6), so skip straight to a fresh authorization request. if (tokens?.refresh_token && !forceReauthorization) { + const refreshScope = await provider.scopeForRefresh?.(tokens); let newTokens: OAuthTokens | undefined; try { // Attempt to refresh the token @@ -1392,6 +1406,7 @@ async function authInternal( metadata, clientInformation, refreshToken: tokens.refresh_token, + scope: refreshScope, resource, addClientAuthentication: provider.addClientAuthentication, dpop: await provider.dpop?.(), @@ -2353,6 +2368,7 @@ export async function refreshAuthorization( metadata, clientInformation, refreshToken, + scope, resource, addClientAuthentication, dpop, @@ -2361,6 +2377,11 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; + /** + * `scope` to send on the request; omitted or `''` sends none, which asks for the scope + * already granted (RFC 6749 §6). When sent, it is also the result's `scope` if the response has none. + */ + scope?: string; resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; /** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */ @@ -2372,6 +2393,9 @@ export async function refreshAuthorization( grant_type: 'refresh_token', refresh_token: refreshToken }); + if (scope) { + tokenRequestParams.set('scope', scope); + } const tokens = await executeTokenRequest(authorizationServerUrl, { metadata, @@ -2383,8 +2407,8 @@ export async function refreshAuthorization( fetchFn }); - // Preserve original refresh token if server didn't return a new one - return { refresh_token: refreshToken, ...tokens }; + // Preserve the original refresh token, and the scope sent (RFC 6749 §5.1), if the server didn't return new ones + return { refresh_token: refreshToken, ...(scope && { scope }), ...tokens }; } /** diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a87cbd7d92..e9389e04e0 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2278,6 +2278,47 @@ describe('OAuth Authorization', () => { expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); }); + it.each([undefined, ''])('sends no scope parameter when scope is %j', async scope => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: { client_id: 'client123' }, + refreshToken: 'refresh123', + scope, + resource: 'https://api.example.com/mcp-server' + }); + + expect(String(mockFetch.mock.calls[0]![1].body)).toBe( + 'grant_type=refresh_token&refresh_token=refresh123&resource=https%3A%2F%2Fapi.example.com%2Fmcp-server&client_id=client123' + ); + expect(tokens).toEqual({ refresh_token: 'refresh123', ...validTokens }); + }); + + it('sends the given scope, and returns it when the response has none', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: 'read write' + }); + + expect((mockFetch.mock.calls[0]![1].body as URLSearchParams).get('scope')).toBe('read write'); + expect(tokens.scope).toBe('read write'); + }); + + it('returns the scope from the response over the one sent', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ...validTokens, scope: 'read' }) }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: 'read write' + }); + + expect(tokens.scope).toBe('read'); + }); + it('validates token response schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -3103,6 +3144,65 @@ describe('OAuth Authorization', () => { const storedTokens = { access_token: 'old-access', refresh_token: 'refresh123', issuer: 'https://auth.example.com' }; + const refreshedWithoutScope = () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: 'new-access', token_type: 'Bearer' }) + }); + const refreshRequestBody = () => String(mockFetch.mock.calls.find(call => call[0].toString().includes('/token'))![1].body); + + it('sends no scope on refresh by default, even when the stored tokens record one', async () => { + mockDiscoveryWithTokenEndpoint(refreshedWithoutScope); + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' }); + (mockProvider.tokens as Mock).mockResolvedValue({ ...storedTokens, scope: 'read write' }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + await expect(auth(mockProvider, { serverUrl: 'https://api.example.com/mcp-server', scope: 'read write' })).resolves.toBe( + 'AUTHORIZED' + ); + + expect(refreshRequestBody()).toBe( + 'grant_type=refresh_token&refresh_token=refresh123&resource=https%3A%2F%2Fapi.example.com%2Fmcp-server&client_id=test-client' + ); + expect(mockProvider.saveTokens).toHaveBeenCalledWith( + { access_token: 'new-access', token_type: 'Bearer', refresh_token: 'refresh123', issuer: 'https://auth.example.com' }, + expect.anything() + ); + }); + + it('sends what scopeForRefresh() returns on refresh, and saves it when the response has no scope', async () => { + mockDiscoveryWithTokenEndpoint(refreshedWithoutScope); + const stored = { ...storedTokens, scope: 'read write' }; + const scopeForRefresh = vi.fn(async (tokens: StoredOAuthTokens) => tokens.scope); + const provider: OAuthClientProvider = { ...mockProvider, scopeForRefresh }; + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' }); + (mockProvider.tokens as Mock).mockResolvedValue(stored); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp-server' })).resolves.toBe('AUTHORIZED'); + + expect(scopeForRefresh).toHaveBeenCalledExactlyOnceWith(stored); + expect(new URLSearchParams(refreshRequestBody()).get('scope')).toBe('read write'); + expect(mockProvider.saveTokens).toHaveBeenCalledWith(expect.objectContaining({ scope: 'read write' }), expect.anything()); + }); + + it('rejects with the error scopeForRefresh() throws, and starts no new authorization', async () => { + mockDiscoveryWithTokenEndpoint(refreshedWithoutScope); + const provider: OAuthClientProvider = { + ...mockProvider, + scopeForRefresh: () => { + throw new Error('boom'); + } + }; + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' }); + (mockProvider.tokens as Mock).mockResolvedValue(storedTokens); + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp-server' })).rejects.toThrow('boom'); + + expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled(); + expect(mockProvider.saveTokens).not.toHaveBeenCalled(); + }); + it('propagates saveTokens errors after a successful refresh (#2034)', async () => { // Regression test: previously the catch block that wraps // refreshAuthorization() also wrapped saveTokens(), silently diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 3d9811809f..e9c5f34790 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2241,6 +2241,20 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + 'client-auth:refresh:no-scope-by-default': { + source: 'sdk', + behavior: + 'Unless the provider implements scopeForRefresh(), a refresh request carries grant_type, refresh_token, the resource indicator and client authentication only: no scope parameter, even when the stored tokens record a scope.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:refresh:scope-opt-in': { + source: 'sdk', + behavior: + 'A provider that implements scopeForRefresh(tokens) has the returned scope sent on every refresh request, and that scope is saved with the new tokens when the refresh response has none, so a client stays signed in across expiries at an authorization server that rejects a refresh without scope.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, 'client-auth:as-metadata-discovery:issuer-validation': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery', behavior: diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 134edd4dd4..9356ee5abe 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -1485,6 +1485,95 @@ verifies('client-auth:refresh:transparent', async (_args: TestArgs) => { } }); +/** An authorization server that rejects a refresh without `scope`, and an MCP host whose access tokens can be expired. */ +function createScopeRequiringWorld() { + const as = createMockAuthorizationServer(); + const mcpHost = hostPerSession(() => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('probe', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'ok' }] })); + return s; + }); + const refreshBodies: string[] = []; + const liveTokens = new Set(['access-0']); + const fetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = new URL(url); + if (urlObj.href === `${ISSUER}/token`) { + const body = String(init?.body); + refreshBodies.push(body); + if (!new URLSearchParams(body).has('scope')) { + return Response.json({ error: 'invalid_request', error_description: 'scope is required' }, { status: 400 }); + } + const accessToken = `access-${refreshBodies.length}`; + liveTokens.add(accessToken); + // RFC 6749 §5.1 and §6: neither `scope` nor `refresh_token` has to be repeated. + return Response.json({ access_token: accessToken, token_type: 'Bearer' }); + } + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + if (!liveTokens.has(new Headers(init?.headers).get('authorization')?.replace('Bearer ', '') ?? '')) { + return new Response(null, { status: 401 }); + } + return mcpHost.handleRequest(new Request(url, init)); + }; + const signedIn = { + tokens: { access_token: 'access-0', token_type: 'Bearer', refresh_token: 'rt', scope: 'mcp:read mcp:write' }, + clientInformation: { client_id: 'refresh-client' } + }; + return { fetch, mcpHost, refreshBodies, signedIn, expireAccessTokens: () => liveTokens.clear() }; +} + +verifies('client-auth:refresh:no-scope-by-default', async (_args: TestArgs) => { + const world = createScopeRequiringWorld(); + const provider = new RecordingOAuthClientProvider(world.signedIn); + const client = new Client({ name: 'c', version: '0' }); + + try { + await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: world.fetch })); + world.expireAccessTokens(); + + await expect(client.callTool({ name: 'probe', arguments: {} })).rejects.toMatchObject({ code: OAuthErrorCode.InvalidRequest }); + + // A Set: the standalone GET stream may meet the same 401 and refresh too. + expect(new Set(world.refreshBodies)).toEqual( + new Set(['grant_type=refresh_token&refresh_token=rt&resource=http%3A%2F%2Fin-process%2Fmcp&client_id=refresh-client']) + ); + } finally { + await client.close(); + await world.mcpHost.close(); + } +}); + +verifies('client-auth:refresh:scope-opt-in', async (_args: TestArgs) => { + class ScopeOnRefreshProvider extends RecordingOAuthClientProvider { + scopeForRefresh(tokens: StoredOAuthTokens) { + return tokens.scope; + } + } + const world = createScopeRequiringWorld(); + const provider = new ScopeOnRefreshProvider(world.signedIn); + const client = new Client({ name: 'c', version: '0' }); + + try { + await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: world.fetch })); + + // Two expiries: the second refresh depends on the scope kept from the first, whose response had none. + for (let expiry = 1; expiry <= 2; expiry++) { + world.expireAccessTokens(); + const result = await client.callTool({ name: 'probe', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(provider.saved.tokens).toMatchObject({ refresh_token: 'rt', scope: 'mcp:read mcp:write' }); + expect(world.refreshBodies.length).toBeGreaterThanOrEqual(expiry); + } + + expect(new Set(world.refreshBodies.map(body => new URLSearchParams(body).get('scope')))).toEqual(new Set(['mcp:read mcp:write'])); + expect(provider.redirectedTo).toHaveLength(0); + } finally { + await client.close(); + await world.mcpHost.close(); + } +}); + verifies('client-auth:resource-parameter', async (_args: TestArgs) => { const as = createMockAuthorizationServer({ tokenResponses: [{ access_token: 'resource-param-token', token_type: 'Bearer' }] }); const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'resource-test-client' } }); From 0f4f96b1b311c0e0d20049172141e08344f25954 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:38:33 +0000 Subject: [PATCH 2/2] fix(client): keep the scope sent when a refresh response has an empty scope --- docs/clients/oauth.md | 2 +- packages/client/src/client/auth.ts | 2 +- packages/client/test/client/auth.test.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/clients/oauth.md b/docs/clients/oauth.md index dfc51cf3d8..c53c3ad755 100644 --- a/docs/clients/oauth.md +++ b/docs/clients/oauth.md @@ -173,7 +173,7 @@ class ScopeOnRefreshProvider extends MyOAuthProvider { } ``` -The SDK sends the returned string on every refresh it makes for this provider, and saves it with the new tokens when the refresh response has no `scope`; returning `undefined` sends none. If the sign-in response had no `scope`, `tokens.scope` is `undefined`: return the scope you requested instead. `refreshAuthorization()` takes the same value as its `scope` option. +The SDK sends the returned string on every refresh it makes for this provider, and saves it with the new tokens when the refresh response has no `scope`; returning `undefined` sends none. If the sign-in response had no `scope`, `tokens.scope` is `undefined`: return the scope that was requested instead, which is the `scope` parameter of the URL passed to `redirectToAuthorization`. `refreshAuthorization()` takes the same value as its `scope` option. ::: warning 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. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index e7d9e1303f..caf4fa84e4 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -2408,7 +2408,7 @@ export async function refreshAuthorization( }); // Preserve the original refresh token, and the scope sent (RFC 6749 §5.1), if the server didn't return new ones - return { refresh_token: refreshToken, ...(scope && { scope }), ...tokens }; + return { refresh_token: refreshToken, ...tokens, ...(scope && !tokens.scope && { scope }) }; } /** diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index e9389e04e0..bb12d23aa6 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2319,6 +2319,18 @@ describe('OAuth Authorization', () => { expect(tokens.scope).toBe('read'); }); + it('returns the scope sent when the response scope is empty', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ...validTokens, scope: '' }) }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: 'read write' + }); + + expect(tokens.scope).toBe('read write'); + }); + it('validates token response schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true,