Skip to content

Commit 4aec5f7

Browse files
authored
Private key jwt scopes (#1443)
1 parent 9efecc2 commit 4aec5f7

File tree

3 files changed

+120
-3
lines changed

3 files changed

+120
-3
lines changed

.changeset/spotty-cats-tickle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
The client credentials providers now support scopes being added to the token request.

packages/client/src/client/authExtensions.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ export interface ClientCredentialsProviderOptions {
112112
* Optional client name for metadata.
113113
*/
114114
clientName?: string;
115+
116+
/**
117+
* Space-separated scopes values requested by the client.
118+
*/
119+
scope?: string;
115120
}
116121

117122
/**
@@ -146,7 +151,8 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
146151
client_name: options.clientName ?? 'client-credentials-client',
147152
redirect_uris: [],
148153
grant_types: ['client_credentials'],
149-
token_endpoint_auth_method: 'client_secret_basic'
154+
token_endpoint_auth_method: 'client_secret_basic',
155+
scope: options.scope
150156
};
151157
}
152158

@@ -222,6 +228,11 @@ export interface PrivateKeyJwtProviderOptions {
222228
* Optional JWT lifetime in seconds (default: 300).
223229
*/
224230
jwtLifetimeSeconds?: number;
231+
232+
/**
233+
* Space-separated scopes values requested by the client.
234+
*/
235+
scope?: string;
225236
}
226237

227238
/**
@@ -258,7 +269,8 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
258269
client_name: options.clientName ?? 'private-key-jwt-client',
259270
redirect_uris: [],
260271
grant_types: ['client_credentials'],
261-
token_endpoint_auth_method: 'private_key_jwt'
272+
token_endpoint_auth_method: 'private_key_jwt',
273+
scope: options.scope
262274
};
263275
this.addClientAuthentication = createPrivateKeyJwtAuth({
264276
issuer: options.clientId,
@@ -333,6 +345,11 @@ export interface StaticPrivateKeyJwtProviderOptions {
333345
* Optional client name for metadata.
334346
*/
335347
clientName?: string;
348+
349+
/**
350+
* Space-separated scopes values requested by the client.
351+
*/
352+
scope?: string;
336353
}
337354

338355
/**
@@ -356,7 +373,8 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
356373
client_name: options.clientName ?? 'static-private-key-jwt-client',
357374
redirect_uris: [],
358375
grant_types: ['client_credentials'],
359-
token_endpoint_auth_method: 'private_key_jwt'
376+
token_endpoint_auth_method: 'private_key_jwt',
377+
scope: options.scope
360378
};
361379

362380
const assertion = options.jwtBearerAssertion;

packages/client/test/client/authExtensions.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ describe('auth-extensions providers (end-to-end with auth())', () => {
5151
expect(tokens?.access_token).toBe('test-access-token');
5252
});
5353

54+
it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => {
55+
const provider = new ClientCredentialsProvider({
56+
clientId: 'my-client',
57+
clientSecret: 'my-secret',
58+
clientName: 'test-client',
59+
scope: 'read write'
60+
});
61+
62+
expect(provider.clientMetadata.scope).toBe('read write');
63+
64+
const fetchMock = createMockOAuthFetch({
65+
resourceServerUrl: RESOURCE_SERVER_URL,
66+
authServerUrl: AUTH_SERVER_URL,
67+
onTokenRequest: async (_url, init) => {
68+
const params = init?.body as URLSearchParams;
69+
expect(params).toBeInstanceOf(URLSearchParams);
70+
expect(params.get('grant_type')).toBe('client_credentials');
71+
expect(params.get('scope')).toBe('read write');
72+
}
73+
});
74+
75+
const result = await auth(provider, {
76+
serverUrl: RESOURCE_SERVER_URL,
77+
fetchFn: fetchMock
78+
});
79+
80+
expect(result).toBe('AUTHORIZED');
81+
});
82+
5483
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
5584
const provider = new PrivateKeyJwtProvider({
5685
clientId: 'client-id',
@@ -94,6 +123,38 @@ describe('auth-extensions providers (end-to-end with auth())', () => {
94123
expect(assertionFromRequest).toBeTruthy();
95124
});
96125

126+
it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => {
127+
const provider = new PrivateKeyJwtProvider({
128+
clientId: 'client-id',
129+
privateKey: 'a-string-secret-at-least-256-bits-long',
130+
algorithm: 'HS256',
131+
clientName: 'private-key-jwt-client',
132+
scope: 'openid profile'
133+
});
134+
135+
expect(provider.clientMetadata.scope).toBe('openid profile');
136+
137+
const fetchMock = createMockOAuthFetch({
138+
resourceServerUrl: RESOURCE_SERVER_URL,
139+
authServerUrl: AUTH_SERVER_URL,
140+
onTokenRequest: async (_url, init) => {
141+
const params = init?.body as URLSearchParams;
142+
expect(params).toBeInstanceOf(URLSearchParams);
143+
expect(params.get('grant_type')).toBe('client_credentials');
144+
expect(params.get('scope')).toBe('openid profile');
145+
expect(params.get('client_assertion')).toBeTruthy();
146+
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
147+
}
148+
});
149+
150+
const result = await auth(provider, {
151+
serverUrl: RESOURCE_SERVER_URL,
152+
fetchFn: fetchMock
153+
});
154+
155+
expect(result).toBe('AUTHORIZED');
156+
});
157+
97158
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
98159
const provider = new PrivateKeyJwtProvider({
99160
clientId: 'client-id',
@@ -151,6 +212,39 @@ describe('auth-extensions providers (end-to-end with auth())', () => {
151212
expect(tokens).toBeTruthy();
152213
expect(tokens?.access_token).toBe('test-access-token');
153214
});
215+
216+
it('sends scope in token request when StaticPrivateKeyJwtProvider is configured with scope', async () => {
217+
const staticAssertion = 'header.payload.signature';
218+
219+
const provider = new StaticPrivateKeyJwtProvider({
220+
clientId: 'static-client',
221+
jwtBearerAssertion: staticAssertion,
222+
clientName: 'static-private-key-jwt-client',
223+
scope: 'api:read api:write'
224+
});
225+
226+
expect(provider.clientMetadata.scope).toBe('api:read api:write');
227+
228+
const fetchMock = createMockOAuthFetch({
229+
resourceServerUrl: RESOURCE_SERVER_URL,
230+
authServerUrl: AUTH_SERVER_URL,
231+
onTokenRequest: async (_url, init) => {
232+
const params = init?.body as URLSearchParams;
233+
expect(params).toBeInstanceOf(URLSearchParams);
234+
expect(params.get('grant_type')).toBe('client_credentials');
235+
expect(params.get('scope')).toBe('api:read api:write');
236+
expect(params.get('client_assertion')).toBe(staticAssertion);
237+
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
238+
}
239+
});
240+
241+
const result = await auth(provider, {
242+
serverUrl: RESOURCE_SERVER_URL,
243+
fetchFn: fetchMock
244+
});
245+
246+
expect(result).toBe('AUTHORIZED');
247+
});
154248
});
155249

156250
describe('createPrivateKeyJwtAuth', () => {

0 commit comments

Comments
 (0)