Skip to content

Commit 689aa96

Browse files
committed
fix(xaa): use OAuthTokens type, parseErrorResponse, and coerce expires_in
- Replace inline return type with OAuthTokens in exchangeJwtAuthGrant - Use parseErrorResponse for proper OAuthError in both token exchange functions - Use z.coerce.number() for expires_in in IdJagTokenExchangeResponseSchema - Update tests to use real Response objects and check OAuthError type
1 parent ccb78f2 commit 689aa96

3 files changed

Lines changed: 35 additions & 52 deletions

File tree

packages/client/src/client/crossAppAccess.ts

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

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

1414
import type { ClientAuthMethod } from './auth.js';
15-
import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth.js';
15+
import { applyClientAuthentication, discoverAuthorizationServerMetadata, parseErrorResponse } from './auth.js';
1616

1717
/**
1818
* Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange.
@@ -104,7 +104,7 @@ export interface JwtAuthGrantResult {
104104
*
105105
* @param options - Configuration for the token exchange request
106106
* @returns The JWT Authorization Grant and related metadata
107-
* @throws {Error} If the token exchange fails or returns an error response
107+
* @throws {OAuthError} If the token exchange fails or returns an error response
108108
*
109109
* @example
110110
* ```ts
@@ -154,16 +154,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
154154
});
155155

156156
if (!response.ok) {
157-
const errorBody = await response.json().catch(() => ({}));
158-
159-
// Try to parse as OAuth error response
160-
const parseResult = OAuthErrorResponseSchema.safeParse(errorBody);
161-
if (parseResult.success) {
162-
const { error, error_description } = parseResult.data;
163-
throw new Error(`Token exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`);
164-
}
165-
166-
throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`);
157+
throw await parseErrorResponse(response);
167158
}
168159

169160
const parseResult = IdJagTokenExchangeResponseSchema.safeParse(await response.json());
@@ -186,7 +177,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
186177
*
187178
* @param options - Configuration including IdP URL for discovery
188179
* @returns The JWT Authorization Grant and related metadata
189-
* @throws {Error} If discovery fails or the token exchange fails
180+
* @throws {OAuthError} If the token exchange fails or returns an error response
190181
*
191182
* @example
192183
* ```ts
@@ -226,7 +217,7 @@ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequest
226217
*
227218
* @param options - Configuration for the JWT grant exchange
228219
* @returns OAuth tokens (access token, token type, etc.)
229-
* @throws {Error} If the exchange fails or returns an error response
220+
* @throws {OAuthError} If the exchange fails or returns an error response
230221
*
231222
* Defaults to `client_secret_basic` (HTTP Basic Authorization header), matching
232223
* `CrossAppAccessProvider`'s declared `token_endpoint_auth_method` and the
@@ -257,7 +248,7 @@ export async function exchangeJwtAuthGrant(options: {
257248
*/
258249
authMethod?: ClientAuthMethod;
259250
fetchFn?: FetchLike;
260-
}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> {
251+
}): Promise<OAuthTokens> {
261252
const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options;
262253

263254
// Prepare JWT bearer grant request per RFC 7523
@@ -279,16 +270,7 @@ export async function exchangeJwtAuthGrant(options: {
279270
});
280271

281272
if (!response.ok) {
282-
const errorBody = await response.json().catch(() => ({}));
283-
284-
// Try to parse as OAuth error response
285-
const parseResult = OAuthErrorResponseSchema.safeParse(errorBody);
286-
if (parseResult.success) {
287-
const { error, error_description } = parseResult.data;
288-
throw new Error(`JWT grant exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`);
289-
}
290-
291-
throw new Error(`JWT grant exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`);
273+
throw await parseErrorResponse(response);
292274
}
293275

294276
const responseBody = await response.json();

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

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FetchLike } from '@modelcontextprotocol/core';
2+
import { OAuthError } from '@modelcontextprotocol/core';
23
import { describe, expect, it, vi } from 'vitest';
34

45
import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js';
@@ -174,14 +175,15 @@ describe('crossAppAccess', () => {
174175
});
175176

176177
it('handles OAuth error responses', async () => {
177-
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
178-
ok: false,
179-
status: 400,
180-
json: async () => ({
181-
error: 'invalid_grant',
182-
error_description: 'Audience validation failed'
183-
})
184-
} as Response);
178+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(
179+
new Response(
180+
JSON.stringify({
181+
error: 'invalid_grant',
182+
error_description: 'Audience validation failed'
183+
}),
184+
{ status: 400 }
185+
)
186+
);
185187

186188
await expect(
187189
requestJwtAuthorizationGrant({
@@ -193,15 +195,13 @@ describe('crossAppAccess', () => {
193195
clientSecret: 'secret',
194196
fetchFn: mockFetch
195197
})
196-
).rejects.toThrow('Token exchange failed: invalid_grant - Audience validation failed');
198+
).rejects.toThrow(OAuthError);
197199
});
198200

199201
it('handles non-OAuth error responses', async () => {
200-
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
201-
ok: false,
202-
status: 500,
203-
json: async () => ({ message: 'Internal server error' })
204-
} as Response);
202+
const mockFetch = vi
203+
.fn<FetchLike>()
204+
.mockResolvedValue(new Response(JSON.stringify({ message: 'Internal server error' }), { status: 500 }));
205205

206206
await expect(
207207
requestJwtAuthorizationGrant({
@@ -213,7 +213,7 @@ describe('crossAppAccess', () => {
213213
clientSecret: 'secret',
214214
fetchFn: mockFetch
215215
})
216-
).rejects.toThrow('Token exchange failed with status 500');
216+
).rejects.toThrow(OAuthError);
217217
});
218218
});
219219

@@ -385,14 +385,15 @@ describe('crossAppAccess', () => {
385385
});
386386

387387
it('handles OAuth error responses', async () => {
388-
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
389-
ok: false,
390-
status: 400,
391-
json: async () => ({
392-
error: 'invalid_grant',
393-
error_description: 'JWT signature verification failed'
394-
})
395-
} as Response);
388+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(
389+
new Response(
390+
JSON.stringify({
391+
error: 'invalid_grant',
392+
error_description: 'JWT signature verification failed'
393+
}),
394+
{ status: 400 }
395+
)
396+
);
396397

397398
await expect(
398399
exchangeJwtAuthGrant({
@@ -402,7 +403,7 @@ describe('crossAppAccess', () => {
402403
clientSecret: 'secret',
403404
fetchFn: mockFetch
404405
})
405-
).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed');
406+
).rejects.toThrow(OAuthError);
406407
});
407408

408409
it('validates token response with schema', async () => {

packages/core/src/shared/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export const IdJagTokenExchangeResponseSchema = z
151151
issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'),
152152
access_token: z.string(),
153153
token_type: z.string().optional(),
154-
expires_in: z.number().optional(),
154+
expires_in: z.coerce.number().optional(),
155155
scope: z.string().optional()
156156
})
157157
.strip();

0 commit comments

Comments
 (0)