diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 58ec23ddd..3a7a190ff 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -184,50 +184,6 @@ export interface OAuthClientProvider { */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; - /** - * Saves the authorization server URL after RFC 9728 discovery. - * This method is called by {@linkcode auth} after successful discovery of the - * authorization server via protected resource metadata. - * - * Providers implementing Cross-App Access or other flows that need access to - * the discovered authorization server URL should implement this method. - * - * @param authorizationServerUrl - The authorization server URL discovered via RFC 9728 - */ - saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; - - /** - * Returns the previously saved authorization server URL, if available. - * - * Providers implementing Cross-App Access can use this to access the - * authorization server URL discovered during the OAuth flow. - * - * @returns The authorization server URL, or `undefined` if not available - */ - authorizationServerUrl?(): string | undefined | Promise; - - /** - * Saves the resource URL after RFC 9728 discovery. - * This method is called by {@linkcode auth} after successful discovery of the - * resource metadata. - * - * Providers implementing Cross-App Access or other flows that need access to - * the discovered resource URL should implement this method. - * - * @param resourceUrl - The resource URL discovered via RFC 9728 - */ - saveResourceUrl?(resourceUrl: string): void | Promise; - - /** - * Returns the previously saved resource URL, if available. - * - * Providers implementing Cross-App Access can use this to access the - * resource URL discovered during the OAuth flow. - * - * @returns The resource URL, or `undefined` if not available - */ - resourceUrl?(): string | undefined | Promise; - /** * Saves the OAuth discovery state after RFC 9728 and authorization server metadata * discovery. Providers can persist this state to avoid redundant discovery requests @@ -545,16 +501,8 @@ async function authInternal( }); } - // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) - await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - // Save resource URL for providers that need it (e.g., CrossAppAccessProvider) - if (resource) { - await provider.saveResourceUrl?.(String(resource)); - } - // Apply scope selection strategy (SEP-835): // 1. WWW-Authenticate scope (passed via `scope` param) // 2. PRM scopes_supported diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index ae614f7ba..7fb440313 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -8,7 +8,7 @@ import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; import type { CryptoKey, JWK } from 'jose'; -import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; +import type { AddClientAuthentication, OAuthClientProvider, OAuthDiscoveryState } from './auth.js'; /** * Helper to produce a `private_key_jwt` client authentication function. @@ -545,8 +545,7 @@ export class CrossAppAccessProvider implements OAuthClientProvider { private _clientMetadata: OAuthClientMetadata; private _assertionCallback: AssertionCallback; private _fetchFn: FetchLike; - private _authorizationServerUrl?: string; - private _resourceUrl?: string; + private _discoveryState?: OAuthDiscoveryState; private _scope?: string; constructor(options: CrossAppAccessProviderOptions) { @@ -600,40 +599,17 @@ export class CrossAppAccessProvider implements OAuthClientProvider { throw new Error('codeVerifier is not used for jwt-bearer flow'); } - /** - * Saves the authorization server URL discovered during OAuth flow. - * This is called by the auth() function after RFC 9728 discovery. - */ - saveAuthorizationServerUrl?(authorizationServerUrl: string): void { - this._authorizationServerUrl = authorizationServerUrl; - } - - /** - * Returns the cached authorization server URL if available. - */ - authorizationServerUrl?(): string | undefined { - return this._authorizationServerUrl; + saveDiscoveryState(state: OAuthDiscoveryState): void { + this._discoveryState = state; } - /** - * Saves the resource URL discovered during OAuth flow. - * This is called by the auth() function after RFC 9728 discovery. - */ - saveResourceUrl?(resourceUrl: string): void { - this._resourceUrl = resourceUrl; - } - - /** - * Returns the cached resource URL if available. - */ - resourceUrl?(): string | undefined { - return this._resourceUrl; + discoveryState(): OAuthDiscoveryState | undefined { + return this._discoveryState; } async prepareTokenRequest(scope?: string): Promise { - // Get the authorization server URL and resource URL from cached state - const authServerUrl = this._authorizationServerUrl; - const resourceUrl = this._resourceUrl; + const authServerUrl = this._discoveryState?.authorizationServerUrl; + const resourceUrl = this._discoveryState?.resourceMetadata?.resource; if (!authServerUrl) { throw new Error('Authorization server URL not available. Ensure auth() has been called first.'); diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 9e0219dfe..ab39f1376 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -8,11 +8,11 @@ * @module */ -import type { FetchLike } from '@modelcontextprotocol/core'; -import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core'; +import type { FetchLike, OAuthTokens } from '@modelcontextprotocol/core'; +import { IdJagTokenExchangeResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core'; import type { ClientAuthMethod } from './auth.js'; -import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth.js'; +import { applyClientAuthentication, discoverAuthorizationServerMetadata, parseErrorResponse } from './auth.js'; /** * Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange. @@ -104,7 +104,7 @@ export interface JwtAuthGrantResult { * * @param options - Configuration for the token exchange request * @returns The JWT Authorization Grant and related metadata - * @throws {Error} If the token exchange fails or returns an error response + * @throws {OAuthError} If the token exchange fails or returns an error response * * @example * ```ts @@ -154,16 +154,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO }); if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - - // Try to parse as OAuth error response - const parseResult = OAuthErrorResponseSchema.safeParse(errorBody); - if (parseResult.success) { - const { error, error_description } = parseResult.data; - throw new Error(`Token exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`); - } - - throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`); + throw await parseErrorResponse(response); } const parseResult = IdJagTokenExchangeResponseSchema.safeParse(await response.json()); @@ -186,7 +177,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO * * @param options - Configuration including IdP URL for discovery * @returns The JWT Authorization Grant and related metadata - * @throws {Error} If discovery fails or the token exchange fails + * @throws {OAuthError} If the token exchange fails or returns an error response * * @example * ```ts @@ -226,7 +217,7 @@ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequest * * @param options - Configuration for the JWT grant exchange * @returns OAuth tokens (access token, token type, etc.) - * @throws {Error} If the exchange fails or returns an error response + * @throws {OAuthError} If the exchange fails or returns an error response * * Defaults to `client_secret_basic` (HTTP Basic Authorization header), matching * `CrossAppAccessProvider`'s declared `token_endpoint_auth_method` and the @@ -257,7 +248,7 @@ export async function exchangeJwtAuthGrant(options: { */ authMethod?: ClientAuthMethod; fetchFn?: FetchLike; -}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> { +}): Promise { const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options; // Prepare JWT bearer grant request per RFC 7523 @@ -279,16 +270,7 @@ export async function exchangeJwtAuthGrant(options: { }); if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - - // Try to parse as OAuth error response - const parseResult = OAuthErrorResponseSchema.safeParse(errorBody); - if (parseResult.success) { - const { error, error_description } = parseResult.data; - throw new Error(`JWT grant exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`); - } - - throw new Error(`JWT grant exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`); + throw await parseErrorResponse(response); } const responseBody = await response.json(); diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 13af7225c..c0a679479 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -467,38 +467,33 @@ describe('CrossAppAccessProvider', () => { clientSecret: 'secret' }); - // Manually set authorization server URL but not resource URL - provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); + // Save discovery state without resourceMetadata + provider.saveDiscoveryState({ + authorizationServerUrl: AUTH_SERVER_URL + }); await expect(provider.prepareTokenRequest()).rejects.toThrow( 'Resource URL not available — server may not implement RFC 9728 Protected Resource Metadata' ); }); - it('stores and retrieves authorization server URL', () => { + it('stores and retrieves discovery state', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', clientId: 'client', clientSecret: 'secret' }); - expect(provider.authorizationServerUrl?.()).toBeUndefined(); - - provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); - expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL); - }); + expect(provider.discoveryState()).toBeUndefined(); - it('stores and retrieves resource URL', () => { - const provider = new CrossAppAccessProvider({ - assertion: async () => 'jwt-grant', - clientId: 'client', - clientSecret: 'secret' + provider.saveDiscoveryState({ + authorizationServerUrl: AUTH_SERVER_URL, + resourceMetadata: { resource: RESOURCE_SERVER_URL } }); - expect(provider.resourceUrl?.()).toBeUndefined(); - - provider.saveResourceUrl?.(RESOURCE_SERVER_URL); - expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL); + const state = provider.discoveryState(); + expect(state?.authorizationServerUrl).toBe(AUTH_SERVER_URL); + expect(state?.resourceMetadata?.resource).toBe(RESOURCE_SERVER_URL); }); it('has correct client metadata', () => { diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index 1b595c4da..b648ba0e3 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -1,4 +1,5 @@ import type { FetchLike } from '@modelcontextprotocol/core'; +import { OAuthError } from '@modelcontextprotocol/core'; import { describe, expect, it, vi } from 'vitest'; import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js'; @@ -174,14 +175,15 @@ describe('crossAppAccess', () => { }); it('handles OAuth error responses', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ - error: 'invalid_grant', - error_description: 'Audience validation failed' - }) - } as Response); + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: 'Audience validation failed' + }), + { status: 400 } + ) + ); await expect( requestJwtAuthorizationGrant({ @@ -193,15 +195,13 @@ describe('crossAppAccess', () => { clientSecret: 'secret', fetchFn: mockFetch }) - ).rejects.toThrow('Token exchange failed: invalid_grant - Audience validation failed'); + ).rejects.toThrow(OAuthError); }); it('handles non-OAuth error responses', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ message: 'Internal server error' }) - } as Response); + const mockFetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ message: 'Internal server error' }), { status: 500 })); await expect( requestJwtAuthorizationGrant({ @@ -213,7 +213,7 @@ describe('crossAppAccess', () => { clientSecret: 'secret', fetchFn: mockFetch }) - ).rejects.toThrow('Token exchange failed with status 500'); + ).rejects.toThrow(OAuthError); }); }); @@ -385,14 +385,15 @@ describe('crossAppAccess', () => { }); it('handles OAuth error responses', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ - error: 'invalid_grant', - error_description: 'JWT signature verification failed' - }) - } as Response); + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: 'JWT signature verification failed' + }), + { status: 400 } + ) + ); await expect( exchangeJwtAuthGrant({ @@ -402,7 +403,7 @@ describe('crossAppAccess', () => { clientSecret: 'secret', fetchFn: mockFetch }) - ).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed'); + ).rejects.toThrow(OAuthError); }); it('validates token response with schema', async () => { diff --git a/packages/core/src/shared/auth.ts b/packages/core/src/shared/auth.ts index deee583aa..f1ad7dc58 100644 --- a/packages/core/src/shared/auth.ts +++ b/packages/core/src/shared/auth.ts @@ -151,7 +151,7 @@ export const IdJagTokenExchangeResponseSchema = z issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'), access_token: z.string(), token_type: z.string().optional(), - expires_in: z.number().optional(), + expires_in: z.coerce.number().optional(), scope: z.string().optional() }) .strip();