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
5 changes: 5 additions & 0 deletions .changeset/spotty-cats-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': minor
---

The client credentials providers now support scopes being added to the token request.
24 changes: 21 additions & 3 deletions packages/client/src/client/authExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export interface ClientCredentialsProviderOptions {
* Optional client name for metadata.
*/
clientName?: string;

/**
* Space-separated scopes values requested by the client.
*/
scope?: string;
}

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

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

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -321,6 +333,11 @@ export interface StaticPrivateKeyJwtProviderOptions {
* Optional client name for metadata.
*/
clientName?: string;

/**
* Space-separated scopes values requested by the client.
*/
scope?: string;
}

/**
Expand All @@ -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;
Expand Down
94 changes: 94 additions & 0 deletions packages/client/test/client/authExtensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading