From 98dd73a667ec89c48362c5a3e1c1a79319517db2 Mon Sep 17 00:00:00 2001 From: XinLei Date: Thu, 12 Mar 2026 00:11:39 -0700 Subject: [PATCH] fix: include scope in OAuth authorization code token exchange (#941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `prepareAuthorizationCodeRequest()` now accepts an optional `scope` parameter and appends it to the token request body when provided, keeping the call backwards compatible. - `fetchToken()` gains a `scope` option. For the `authorization_code` path it forwards *only* the explicitly supplied scope so that a server which narrowed the granted scope during authorization does not receive a broader re-assertion (RFC 6749 §4.1.3). For the custom `prepareTokenRequest()` path (client_credentials, jwt-bearer, …) it retains the previous behaviour of falling back to `provider.clientMetadata.scope`, because those flows initiate a fresh token request rather than redeeming a pre-authorized grant. - `auth()` computes `tokenExchangeScope` as the explicit scope from the WWW-Authenticate challenge only; it does not inject PRM scopes or clientMetadata.scope into the token exchange, preventing invalid_scope errors from providers (e.g. Azure AD) that narrow the granted scope at the authorization endpoint. Closes #941 Co-Authored-By: Claude Opus 4.6 --- packages/client/src/client/auth.ts | 33 ++++++-- packages/client/test/client/auth.test.ts | 95 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 7f7f44019..0409d025c 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -510,6 +510,12 @@ async function authInternal( // The resolved scope is used consistently for both DCR and the authorization request. const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; + // For the token exchange, only forward scope that was explicitly requested via + // WWW-Authenticate (the `scope` parameter). Do not inject PRM scopes_supported or + // clientMetadata.scope — either can exceed what the server granted, triggering + // invalid_scope errors per RFC 6749 §4.1.3. + const tokenExchangeScope = scope; + // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -562,6 +568,7 @@ async function authInternal( metadata, resource, authorizationCode, + scope: tokenExchangeScope, fetchFn }); @@ -1198,14 +1205,21 @@ export async function startAuthorization( export function prepareAuthorizationCodeRequest( authorizationCode: string, codeVerifier: string, - redirectUri: string | URL + redirectUri: string | URL, + scope?: string ): URLSearchParams { - return new URLSearchParams({ + const params = new URLSearchParams({ grant_type: 'authorization_code', code: authorizationCode, code_verifier: codeVerifier, redirect_uri: String(redirectUri) }); + + if (scope) { + params.set('scope', scope); + } + + return params; } /** @@ -1401,21 +1415,25 @@ export async function fetchToken( metadata, resource, authorizationCode, + scope, fetchFn }: { metadata?: AuthorizationServerMetadata; resource?: URL; /** Authorization code for the default `authorization_code` grant flow */ authorizationCode?: string; + scope?: string; fetchFn?: FetchLike; } = {} ): Promise { - const scope = provider.clientMetadata.scope; - // Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code let tokenRequestParams: URLSearchParams | undefined; if (provider.prepareTokenRequest) { - tokenRequestParams = await provider.prepareTokenRequest(scope); + // For non-interactive flows (client_credentials, jwt-bearer), include the client's + // configured scope as a default — the server has never narrowed it via an authorization + // grant, so clientMetadata.scope is the expected starting point. + const tokenRequestScope = scope ?? provider.clientMetadata.scope; + tokenRequestParams = await provider.prepareTokenRequest(tokenRequestScope); } // Default to authorization_code grant if no custom prepareTokenRequest @@ -1427,7 +1445,10 @@ export async function fetchToken( throw new Error('redirectUrl is required for authorization_code flow'); } const codeVerifier = await provider.codeVerifier(); - tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); + // Only include scope in the token request when explicitly provided — do not inject + // clientMetadata.scope because the provider may have narrowed the granted scope during + // authorization and re-sending a broader scope would cause an invalid_scope error. + tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl, scope); } const clientInformation = await provider.clientInformation(); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 9d8f5cf6b..6eac25e81 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -13,7 +13,9 @@ import { discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, + fetchToken, isHttpsUrl, + prepareAuthorizationCodeRequest, refreshAuthorization, registerClient, selectClientAuthMethod, @@ -1519,6 +1521,7 @@ describe('OAuth Authorization', () => { expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + expect(body.get('scope')).toBeNull(); }); it('allows for string "expires_in" values', async () => { @@ -1845,6 +1848,98 @@ describe('OAuth Authorization', () => { }); }); + describe('prepareAuthorizationCodeRequest', () => { + it('includes scope when provided', () => { + const params = prepareAuthorizationCodeRequest('code123', 'verifier123', 'http://localhost:3000/callback', 'read write'); + + expect(params.get('grant_type')).toBe('authorization_code'); + expect(params.get('code')).toBe('code123'); + expect(params.get('code_verifier')).toBe('verifier123'); + expect(params.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(params.get('scope')).toBe('read write'); + }); + + it('omits scope when not provided', () => { + const params = prepareAuthorizationCodeRequest('code123', 'verifier123', 'http://localhost:3000/callback'); + + expect(params.get('scope')).toBeNull(); + }); + }); + + describe('fetchToken', () => { + const validTokens: OAuthTokens = { + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600 + }; + + function createFetchTokenProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + scope: 'client:default' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client123', + client_secret: 'secret123' + }), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('verifier123'), + ...overrides + }; + } + + it('includes explicitly passed scope in authorization code token requests', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const provider = createFetchTokenProvider(); + + await fetchToken(provider, 'https://auth.example.com', { + authorizationCode: 'code123', + scope: 'read write' + }); + + const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('scope')).toBe('read write'); + }); + + it('does not inject scope from clientMetadata when no explicit scope is passed', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const provider = createFetchTokenProvider(); + + await fetchToken(provider, 'https://auth.example.com', { + authorizationCode: 'code123' + }); + + const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; + // Do NOT inject clientMetadata.scope into the token request — providers that narrowed + // scope during authorization would reject a broader scope with invalid_scope. + expect(body.get('scope')).toBeNull(); + }); + }); + describe('registerClient', () => { const validClientMetadata = { redirect_uris: ['http://localhost:3000/callback'],