Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -562,6 +568,7 @@ async function authInternal(
metadata,
resource,
authorizationCode,
scope: tokenExchangeScope,
fetchFn
});

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<OAuthTokens> {
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
Expand All @@ -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();
Expand Down
95 changes: 95 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
fetchToken,
isHttpsUrl,
prepareAuthorizationCodeRequest,
refreshAuthorization,
registerClient,
selectClientAuthMethod,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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> = {}): 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'],
Expand Down
Loading