Skip to content

Commit ef35f0b

Browse files
committed
fix(xaa): conformance + compat fixes for Cross-App Access
On top of #1593. See /tmp/sdk-xaa-fixes-pr.md for full details. - clientSecret optional in requestJwtAuthorizationGrant - exchangeJwtAuthGrant uses applyClientAuthentication dispatcher (client_secret_basic default) - drop case-sensitive token_type !== 'N_A' check - better PRM error message in authExtensions - IdJagTokenExchangeResponseSchema Zod validation - export applyClientAuthentication + applyBasicAuth + applyPostAuth
1 parent 6d61b6f commit ef35f0b

File tree

6 files changed

+178
-58
lines changed

6 files changed

+178
-58
lines changed

packages/client/src/client/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ export function selectClientAuthMethod(clientInformation: OAuthClientInformation
351351
* @param params - URL search parameters to modify
352352
* @throws {Error} When required credentials are missing
353353
*/
354-
function applyClientAuthentication(
354+
export function applyClientAuthentication(
355355
method: ClientAuthMethod,
356356
clientInformation: OAuthClientInformation,
357357
headers: Headers,

packages/client/src/client/authExtensions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,11 @@ export class CrossAppAccessProvider implements OAuthClientProvider {
640640
}
641641

642642
if (!resourceUrl) {
643-
throw new Error('Resource URL not available. Ensure auth() has been called first.');
643+
throw new Error(
644+
'Resource URL not available — server may not implement RFC 9728 ' +
645+
'Protected Resource Metadata (required for Cross-App Access), or ' +
646+
'auth() has not been called'
647+
);
644648
}
645649

646650
// Store scope for assertion callback

packages/client/src/client/crossAppAccess.ts

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
* @module
99
*/
1010

11-
import type { FetchLike } from '@modelcontextprotocol/core';
12-
import { OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core';
11+
import type { FetchLike, OAuthClientInformation } from '@modelcontextprotocol/core';
12+
import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core';
1313

14-
import { discoverAuthorizationServerMetadata } from './auth.js';
14+
import type { ClientAuthMethod } from './auth.js';
15+
import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth.js';
1516

1617
/**
1718
* Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange.
@@ -45,8 +46,12 @@ export interface RequestJwtAuthGrantOptions {
4546

4647
/**
4748
* The client secret for authenticating with the IdP.
49+
*
50+
* Optional: the IdP may register the MCP client as a public client. RFC 8693 does
51+
* not mandate confidential clients for token exchange. Omitting this parameter
52+
* omits `client_secret` from the request body.
4853
*/
49-
clientSecret: string;
54+
clientSecret?: string;
5055

5156
/**
5257
* Optional space-separated list of scopes to request for the target MCP server.
@@ -127,10 +132,15 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
127132
resource: String(resource),
128133
subject_token: idToken,
129134
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
130-
client_id: clientId,
131-
client_secret: clientSecret
135+
client_id: clientId
132136
});
133137

138+
// Only include client_secret when provided — sending an empty/undefined secret
139+
// triggers `invalid_client` on strict IdPs that registered this as a public client.
140+
if (clientSecret) {
141+
params.set('client_secret', clientSecret);
142+
}
143+
134144
if (scope) {
135145
params.set('scope', scope);
136146
}
@@ -156,33 +166,15 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
156166
throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`);
157167
}
158168

159-
const responseBody = (await response.json()) as {
160-
issued_token_type?: string;
161-
token_type?: string;
162-
access_token?: string;
163-
expires_in?: number;
164-
scope?: string;
165-
};
166-
167-
// Validate response structure
168-
if (responseBody.issued_token_type !== 'urn:ietf:params:oauth:token-type:id-jag') {
169-
throw new Error(
170-
`Invalid issued_token_type: expected 'urn:ietf:params:oauth:token-type:id-jag', got '${responseBody.issued_token_type}'`
171-
);
172-
}
173-
174-
if (responseBody.token_type !== 'N_A') {
175-
throw new Error(`Invalid token_type: expected 'N_A', got '${responseBody.token_type}'`);
176-
}
177-
178-
if (typeof responseBody.access_token !== 'string') {
179-
throw new TypeError('Missing or invalid access_token in token exchange response');
169+
const parseResult = IdJagTokenExchangeResponseSchema.safeParse(await response.json());
170+
if (!parseResult.success) {
171+
throw new Error(`Invalid token exchange response: ${parseResult.error.message}`);
180172
}
181173

182174
return {
183-
jwtAuthGrant: responseBody.access_token, // Per RFC 8693, the JAG is returned in access_token field
184-
expiresIn: responseBody.expires_in,
185-
scope: responseBody.scope
175+
jwtAuthGrant: parseResult.data.access_token,
176+
expiresIn: parseResult.data.expires_in,
177+
scope: parseResult.data.scope
186178
};
187179
}
188180

@@ -236,6 +228,11 @@ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequest
236228
* @returns OAuth tokens (access token, token type, etc.)
237229
* @throws {Error} If the exchange fails or returns an error response
238230
*
231+
* Defaults to `client_secret_basic` (HTTP Basic Authorization header), matching
232+
* {@linkcode CrossAppAccessProvider}'s declared `token_endpoint_auth_method` and the
233+
* SEP-990 conformance test requirements. Use `authMethod: 'client_secret_post'` only
234+
* when the authorization server explicitly requires it.
235+
*
239236
* @example
240237
* ```ts
241238
* const tokens = await exchangeJwtAuthGrant({
@@ -252,24 +249,37 @@ export async function exchangeJwtAuthGrant(options: {
252249
tokenEndpoint: string | URL;
253250
jwtAuthGrant: string;
254251
clientId: string;
255-
clientSecret: string;
252+
clientSecret?: string;
253+
/**
254+
* Client authentication method. Defaults to `'client_secret_basic'` to align with
255+
* {@linkcode CrossAppAccessProvider} and SEP-990 conformance requirements.
256+
* Callers with no `clientSecret` should pass `'none'` for public-client auth.
257+
*/
258+
authMethod?: ClientAuthMethod;
256259
fetchFn?: FetchLike;
257260
}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> {
258-
const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, fetchFn = fetch } = options;
261+
const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options;
259262

260263
// Prepare JWT bearer grant request per RFC 7523
261264
const params = new URLSearchParams({
262265
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
263-
assertion: jwtAuthGrant,
264-
client_id: clientId,
265-
client_secret: clientSecret
266+
assertion: jwtAuthGrant
267+
});
268+
269+
const headers = new Headers({
270+
'Content-Type': 'application/x-www-form-urlencoded'
266271
});
267272

273+
applyClientAuthentication(
274+
authMethod,
275+
{ client_id: clientId, client_secret: clientSecret },
276+
headers,
277+
params
278+
);
279+
268280
const response = await fetchFn(String(tokenEndpoint), {
269281
method: 'POST',
270-
headers: {
271-
'Content-Type': 'application/x-www-form-urlencoded'
272-
},
282+
headers,
273283
body: params.toString()
274284
});
275285

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,9 @@ describe('CrossAppAccessProvider', () => {
470470
// Manually set authorization server URL but not resource URL
471471
provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL);
472472

473-
await expect(provider.prepareTokenRequest()).rejects.toThrow('Resource URL not available. Ensure auth() has been called first.');
473+
await expect(provider.prepareTokenRequest()).rejects.toThrow(
474+
'Resource URL not available — server may not implement RFC 9728 Protected Resource Metadata'
475+
);
474476
});
475477

476478
it('stores and retrieves authorization server URL', () => {

packages/client/test/client/crossAppAccess.test.ts

Lines changed: 102 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@ describe('crossAppAccess', () => {
7878
expect(body.get('scope')).toBeNull();
7979
});
8080

81+
it('omits client_secret from body when not provided (public client)', async () => {
82+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
83+
ok: true,
84+
json: async () => ({
85+
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
86+
access_token: 'jag-token',
87+
token_type: 'N_A'
88+
})
89+
} as Response);
90+
91+
await requestJwtAuthorizationGrant({
92+
tokenEndpoint: 'https://idp.example.com/token',
93+
audience: 'https://auth.chat.example/',
94+
resource: 'https://mcp.chat.example/',
95+
idToken: 'id-token',
96+
clientId: 'public-client',
97+
fetchFn: mockFetch
98+
});
99+
100+
const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string);
101+
expect(body.get('client_id')).toBe('public-client');
102+
// Must be absent — not empty string, not the literal "undefined"
103+
expect(body.has('client_secret')).toBe(false);
104+
});
105+
81106
it('throws error when issued_token_type is incorrect', async () => {
82107
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
83108
ok: true,
@@ -98,30 +123,32 @@ describe('crossAppAccess', () => {
98123
clientSecret: 'secret',
99124
fetchFn: mockFetch
100125
})
101-
).rejects.toThrow("Invalid issued_token_type: expected 'urn:ietf:params:oauth:token-type:id-jag'");
126+
).rejects.toThrow('Invalid token exchange response');
102127
});
103128

104-
it('throws error when token_type is incorrect', async () => {
129+
it('accepts token_type other than N_A (issued_token_type is the real check)', async () => {
130+
// RFC 6749 §5.1: token_type is case-insensitive; RFC 8693 §2.2.1: informational
131+
// when the issued token isn't an access token. Real IdPs return 'n_a', 'Bearer', etc.
105132
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
106133
ok: true,
107134
json: async () => ({
108135
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
109-
access_token: 'token',
110-
token_type: 'Bearer'
136+
access_token: 'jag-token',
137+
token_type: 'n_a'
111138
})
112139
} as Response);
113140

114-
await expect(
115-
requestJwtAuthorizationGrant({
116-
tokenEndpoint: 'https://idp.example.com/token',
117-
audience: 'https://auth.chat.example/',
118-
resource: 'https://mcp.chat.example/',
119-
idToken: 'id-token',
120-
clientId: 'client',
121-
clientSecret: 'secret',
122-
fetchFn: mockFetch
123-
})
124-
).rejects.toThrow("Invalid token_type: expected 'N_A'");
141+
const result = await requestJwtAuthorizationGrant({
142+
tokenEndpoint: 'https://idp.example.com/token',
143+
audience: 'https://auth.chat.example/',
144+
resource: 'https://mcp.chat.example/',
145+
idToken: 'id-token',
146+
clientId: 'client',
147+
clientSecret: 'secret',
148+
fetchFn: mockFetch
149+
});
150+
151+
expect(result.jwtAuthGrant).toBe('jag-token');
125152
});
126153

127154
it('throws error when access_token is missing', async () => {
@@ -143,7 +170,7 @@ describe('crossAppAccess', () => {
143170
clientSecret: 'secret',
144171
fetchFn: mockFetch
145172
})
146-
).rejects.toThrow('Missing or invalid access_token in token exchange response');
173+
).rejects.toThrow('Invalid token exchange response');
147174
});
148175

149176
it('handles OAuth error responses', async () => {
@@ -263,7 +290,7 @@ describe('crossAppAccess', () => {
263290
});
264291

265292
describe('exchangeJwtAuthGrant', () => {
266-
it('successfully exchanges JWT Authorization Grant for access token', async () => {
293+
it('exchanges JAG for access token using client_secret_basic by default', async () => {
267294
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
268295
ok: true,
269296
json: async () => ({
@@ -292,13 +319,71 @@ describe('crossAppAccess', () => {
292319
expect(url).toBe('https://auth.chat.example/token');
293320
expect(init?.method).toBe('POST');
294321

322+
// SEP-990 conformance: credentials in Authorization header, NOT in body
323+
const headers = new Headers(init?.headers as Headers);
324+
const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64');
325+
expect(headers.get('Authorization')).toBe(`Basic ${expectedCredentials}`);
326+
295327
const body = new URLSearchParams(init?.body as string);
296328
expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer');
297329
expect(body.get('assertion')).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
330+
expect(body.has('client_id')).toBe(false);
331+
expect(body.has('client_secret')).toBe(false);
332+
});
333+
334+
it('supports client_secret_post when explicitly requested', async () => {
335+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
336+
ok: true,
337+
json: async () => ({
338+
access_token: 'mcp-access-token',
339+
token_type: 'Bearer'
340+
})
341+
} as Response);
342+
343+
await exchangeJwtAuthGrant({
344+
tokenEndpoint: 'https://auth.chat.example/token',
345+
jwtAuthGrant: 'jwt',
346+
clientId: 'my-mcp-client',
347+
clientSecret: 'my-mcp-secret',
348+
authMethod: 'client_secret_post',
349+
fetchFn: mockFetch
350+
});
351+
352+
const [, init] = mockFetch.mock.calls[0]!;
353+
const headers = new Headers(init?.headers as Headers);
354+
expect(headers.get('Authorization')).toBeNull();
355+
356+
const body = new URLSearchParams(init?.body as string);
298357
expect(body.get('client_id')).toBe('my-mcp-client');
299358
expect(body.get('client_secret')).toBe('my-mcp-secret');
300359
});
301360

361+
it('supports authMethod none for public clients', async () => {
362+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
363+
ok: true,
364+
json: async () => ({
365+
access_token: 'mcp-access-token',
366+
token_type: 'Bearer'
367+
})
368+
} as Response);
369+
370+
await exchangeJwtAuthGrant({
371+
tokenEndpoint: 'https://auth.chat.example/token',
372+
jwtAuthGrant: 'jwt',
373+
clientId: 'my-public-client',
374+
authMethod: 'none',
375+
fetchFn: mockFetch
376+
});
377+
378+
const [, init] = mockFetch.mock.calls[0]!;
379+
const headers = new Headers(init?.headers as Headers);
380+
expect(headers.get('Authorization')).toBeNull();
381+
382+
const body = new URLSearchParams(init?.body as string);
383+
expect(body.get('client_id')).toBe('my-public-client');
384+
expect(body.has('client_secret')).toBe(false);
385+
});
386+
302387
it('handles OAuth error responses', async () => {
303388
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
304389
ok: false,

packages/core/src/shared/auth.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,25 @@ export const OAuthTokensSchema = z
139139
})
140140
.strip();
141141

142+
/**
143+
* RFC 8693 §2.2.1 Token Exchange response for ID-JAG tokens.
144+
*
145+
* `token_type` is intentionally optional: per RFC 8693 §2.2.1 it is informational when
146+
* the issued token is not an access token, and per RFC 6749 §5.1 it is case-insensitive,
147+
* so strict checking rejects conformant IdPs.
148+
*/
149+
export const IdJagTokenExchangeResponseSchema = z
150+
.object({
151+
issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'),
152+
access_token: z.string(),
153+
token_type: z.string().optional(),
154+
expires_in: z.number().optional(),
155+
scope: z.string().optional()
156+
})
157+
.strip();
158+
159+
export type IdJagTokenExchangeResponse = z.infer<typeof IdJagTokenExchangeResponseSchema>;
160+
142161
/**
143162
* OAuth 2.1 error response
144163
*/

0 commit comments

Comments
 (0)