diff --git a/.changeset/spotty-cats-tickle.md b/.changeset/spotty-cats-tickle.md new file mode 100644 index 000000000..502130dc5 --- /dev/null +++ b/.changeset/spotty-cats-tickle.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +The client credentials providers now support scopes being added to the token request. diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index d5f63bd66..e2486f5f7 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -105,6 +105,11 @@ export interface ClientCredentialsProviderOptions { * Optional client name for metadata. */ clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -137,7 +142,8 @@ export class ClientCredentialsProvider implements OAuthClientProvider { client_name: options.clientName ?? 'client-credentials-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'client_secret_basic' + token_endpoint_auth_method: 'client_secret_basic', + scope: options.scope }; } @@ -213,6 +219,11 @@ export interface PrivateKeyJwtProviderOptions { * Optional JWT lifetime in seconds (default: 300). */ jwtLifetimeSeconds?: number; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -246,7 +257,8 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { client_name: options.clientName ?? 'private-key-jwt-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope }; this.addClientAuthentication = createPrivateKeyJwtAuth({ issuer: options.clientId, @@ -321,6 +333,11 @@ export interface StaticPrivateKeyJwtProviderOptions { * Optional client name for metadata. */ clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -344,7 +361,8 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { client_name: options.clientName ?? 'static-private-key-jwt-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope }; const assertion = options.jwtBearerAssertion; diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 36b797ca1..7e55102fe 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -50,6 +50,35 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(tokens?.access_token).toBe('test-access-token'); }); + it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret', + clientName: 'test-client', + scope: 'read write' + }); + + expect(provider.clientMetadata.scope).toBe('read write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('read write'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', @@ -93,6 +122,38 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(assertionFromRequest).toBeTruthy(); }); + it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + clientName: 'private-key-jwt-client', + scope: 'openid profile' + }); + + expect(provider.clientMetadata.scope).toBe('openid profile'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('openid profile'); + expect(params.get('client_assertion')).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', @@ -150,6 +211,39 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(tokens).toBeTruthy(); expect(tokens?.access_token).toBe('test-access-token'); }); + + it('sends scope in token request when StaticPrivateKeyJwtProvider is configured with scope', async () => { + const staticAssertion = 'header.payload.signature'; + + const provider = new StaticPrivateKeyJwtProvider({ + clientId: 'static-client', + jwtBearerAssertion: staticAssertion, + clientName: 'static-private-key-jwt-client', + scope: 'api:read api:write' + }); + + expect(provider.clientMetadata.scope).toBe('api:read api:write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('api:read api:write'); + expect(params.get('client_assertion')).toBe(staticAssertion); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); }); describe('createPrivateKeyJwtAuth', () => {