diff --git a/docs/client.md b/docs/client.md index 52cb4caf0..efce5f17f 100644 --- a/docs/client.md +++ b/docs/client.md @@ -58,3 +58,59 @@ These examples show how to: - Perform dynamic client registration if needed. - Acquire access tokens. - Attach OAuth credentials to Streamable HTTP requests. + +#### Cross-App Access Middleware + +The `withCrossAppAccess` middleware enables secure authentication for MCP clients accessing protected servers through OAuth-based Cross-App Access flows. It automatically handles token acquisition and adds Authorization headers to requests. + +```typescript +import { Client } from '@modelcontextprotocol/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { withCrossAppAccess } from '@modelcontextprotocol/client'; + +// Configure Cross-App Access middleware +const enhancedFetch = withCrossAppAccess({ + idpUrl: 'https://idp.example.com', + mcpResourceUrl: 'https://mcp-server.example.com', + mcpAuthorisationServerUrl: 'https://mcp-auth.example.com', + idToken: 'your-id-token', + idpClientId: 'your-idp-client-id', + idpClientSecret: 'your-idp-client-secret', + mcpClientId: 'your-mcp-client-id', + mcpClientSecret: 'your-mcp-client-secret', + scope: ['read', 'write'] // Optional scopes +})(fetch); + +// Use the enhanced fetch with your client transport +const transport = new StreamableHTTPClientTransport( + new URL('https://mcp-server.example.com/mcp'), + enhancedFetch +); + +const client = new Client({ + name: 'secure-client', + version: '1.0.0' +}); + +await client.connect(transport); +``` + +The middleware performs a two-step OAuth flow: + +1. Exchanges your ID token for an authorization grant from the IdP +2. Exchanges the grant for an access token from the MCP authorization server +3. Automatically adds the access token to all subsequent requests + +**Configuration Options:** + +- **`idpUrl`**: Identity Provider's base URL for OAuth discovery +- **`idToken`**: Identity token obtained from user authentication with the IdP +- **`idpClientId`** / **`idpClientSecret`**: Credentials for authentication with the IdP +- **`mcpResourceUrl`**: MCP resource server URL (used in token exchange request) +- **`mcpAuthorisationServerUrl`**: MCP authorization server URL for OAuth discovery +- **`mcpClientId`** / **`mcpClientSecret`**: Credentials for authentication with the MCP server +- **`scope`**: Optional array of scope strings (e.g., `['read', 'write']`) + +**Token Caching:** + +The middleware caches the access token after the first successful exchange, so the token exchange flow only happens once. Subsequent requests reuse the cached token without additional OAuth calls. diff --git a/packages/client/package.json b/packages/client/package.json index e62a5bf16..93236252d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -49,6 +49,7 @@ "eventsource-parser": "catalog:runtimeClientOnly", "jose": "catalog:runtimeClientOnly", "pkce-challenge": "catalog:runtimeShared", + "qs": "catalog:runtimeClientOnly", "zod": "catalog:runtimeShared" }, "peerDependencies": { @@ -73,6 +74,7 @@ "@types/content-type": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", "@types/eventsource": "catalog:devTools", + "@types/qs": "^6.9.18", "@typescript/native-preview": "catalog:devTools", "@eslint/js": "catalog:devTools", "eslint": "catalog:devTools", diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index 331a920e2..96ac661ef 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -2,6 +2,8 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import type { OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; +import type { XAAOptions } from './xaa-util.js'; +import { getAccessToken } from './xaa-util.js'; /** * Middleware function that wraps and enhances fetch functionality. @@ -234,6 +236,35 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => { }; }; +/** + * Creates a fetch wrapper that handles Cross App Access authentication automatically. + * + * This wrapper will: + * - Add Authorization headers with access tokens + * + * @param options - XAA configuration options + * @returns A fetch middleware function + */ +export const withCrossAppAccess = (options: XAAOptions): Middleware => { + return wrappedFetchFunction => { + let accessToken: string | undefined = undefined; + + return async (url, init = {}): Promise => { + if (!accessToken) { + accessToken = await getAccessToken(options, wrappedFetchFunction); + } + + const headers = new Headers(init.headers); + + headers.set('Authorization', `Bearer ${accessToken}`); + + init.headers = headers; + + return wrappedFetchFunction(url, init); + }; + }; +}; + /** * Composes multiple fetch middleware functions into a single middleware pipeline. * Middleware are applied in the order they appear, creating a chain of handlers. diff --git a/packages/client/src/client/xaa-util.ts b/packages/client/src/client/xaa-util.ts new file mode 100644 index 000000000..7036bd7a9 --- /dev/null +++ b/packages/client/src/client/xaa-util.ts @@ -0,0 +1,594 @@ +import type { FetchLike } from '@modelcontextprotocol/core'; +import qs from 'qs'; + +import { discoverAuthorizationServerMetadata } from './auth.js'; +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const OAuthErrorTypes = [ + 'invalid_request', + 'invalid_client', + 'invalid_grant', + 'unauthorized_client', + 'unsupported_grant_type', + 'invalid_scope' +] as const; + +// ============================================================================ +// ENUMS +// ============================================================================ + +const enum OAuthGrantType { + JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer', + TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange' +} + +const enum OAuthTokenType { + ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token', + ID_TOKEN = 'urn:ietf:params:oauth:token-type:id_token', + JWT_ID_JAG = 'urn:ietf:params:oauth:token-type:id-jag', + SAML2 = 'urn:ietf:params:oauth:token-type:saml2' +} + +const enum OAuthClientAssertionType { + JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' +} + +// ============================================================================ +// TYPES +// ============================================================================ + +type OAuthErrorType = (typeof OAuthErrorTypes)[number]; + +type OAuthError = { + error: OAuthErrorType; + error_description?: string; + error_uri?: string; +}; + +type OAuthAccessTokenResponseType = { + access_token: string; + token_type: string; + scope?: string; + expires_in?: number; + refresh_token?: string; +}; + +type OAuthTokenExchangeResponseType = { + access_token: string; + issued_token_type: OAuthTokenType; + token_type: string; + scope?: string; + expires_in?: number; + refresh_token?: string; +}; + +type ClientIdFields = { + client_id: string; + client_secret?: string; +}; + +type ClientAssertionFields = { + client_assertion_type: OAuthClientAssertionType; + client_assertion: string; +}; + +type ClientIdOption = { + clientID: string; + clientSecret?: string; +}; + +type ClientAssertionOption = { + clientAssertion: string; +}; + +type ExchangeTokenResult = + | { + payload: OAuthTokenExchangeResponseType; + } + | { + error: OAuthError | HttpResponse; + }; + +type AccessTokenResult = + | { + payload: OAuthAccessTokenResponseType; + } + | { + error: OAuthError | HttpResponse; + }; + +type GetJwtAuthGrantBaseOptions = { + tokenUrl: string; + resource: string; + audience: string; + subjectTokenType: SubjectTokenType; + subjectToken: string; + scopes?: string | Set | string[]; +}; + +type SubjectTokenType = 'oidc' | 'saml'; + +type RequestFields = { + grant_type: OAuthGrantType.TOKEN_EXCHANGE; + requested_token_type: OAuthTokenType.JWT_ID_JAG; + resource?: string; + audience: string; + scope: string; + subject_token: string; + subject_token_type: OAuthTokenType; +}; + +type ExchangeJwtAuthGrantBaseOptions = { + tokenUrl: string; + authorizationGrant: string; + scopes?: string | Set | string[]; +}; + +type ExchangeRequestFields = { + grant_type: OAuthGrantType.JWT_BEARER; + assertion: string; + scope: string; +}; + +export type XAAOptions = { + idpUrl: string; + mcpResourceUrl: string; + mcpAuthorisationServerUrl: string; + idToken: string; + idpClientId: string; + idpClientSecret: string; + mcpClientId: string; + mcpClientSecret: string; + scope?: string[]; +}; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +const invalidOAuthErrorResponse = (field: string, requirement: string, payload?: Record) => + new InvalidPayloadError( + `The field '${field}' ${requirement} per RFC6749. See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2.`, + { payload } + ); + +const invalidRFC6749PayloadError = (field: string, requirement: string, payload?: Record) => + new InvalidPayloadError( + `The field '${field}' ${requirement} per RFC8693. See https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.`, + { payload } + ); + +const invalidRFC7523PayloadError = (field: string, requirement: string, payload?: Record) => + new InvalidPayloadError( + `The field '${field}' ${requirement} per RFC7523. See https://datatracker.ietf.org/doc/html/rfc7523#section-2.1.`, + { payload } + ); + +const invalidRFC8693PayloadError = (field: string, requirement: string, payload?: Record) => + new InvalidPayloadError( + `The field '${field}' ${requirement} per RFC8693. See https://datatracker.ietf.org/doc/html/rfc8693#section-2.2.1.`, + { payload } + ); + +const transformScopes = (scopes?: string | Set | string[] | null) => { + if (scopes) { + if (Array.isArray(scopes)) { + return scopes.join(' '); + } + + if (scopes instanceof Set) { + return Array.from(scopes).join(' '); + } + + if (typeof scopes === 'string') { + return scopes; + } + + throw new InvalidArgumentError('scopes', 'Expected a valid string, array of strings, or Set of strings.'); + } + + return ''; +}; + +// ============================================================================ +// METHODS +// ============================================================================ + +const requestIdJwtAuthzGrant = async ( + opts: GetJwtAuthGrantBaseOptions & (ClientIdOption | ClientAssertionOption), + wrappedFetchFunction: FetchLike +): Promise => { + const { resource, subjectToken, subjectTokenType, audience, scopes, tokenUrl } = opts; + + if (!tokenUrl || typeof tokenUrl !== 'string') { + throw new InvalidArgumentError('opts.tokenUrl', 'A valid url is required.'); + } + + if (!resource || typeof resource !== 'string') { + throw new InvalidArgumentError('opts.resource', 'A valid string is required.'); + } + + if (!audience || typeof audience !== 'string') { + throw new InvalidArgumentError('opts.audience', 'A valid string is required.'); + } + + if (!subjectToken || typeof subjectToken !== 'string') { + throw new InvalidArgumentError('opts.subjectToken'); + } + + let subjectTokenUrn: OAuthTokenType; + + switch (subjectTokenType) { + case 'saml': + subjectTokenUrn = OAuthTokenType.SAML2; + break; + case 'oidc': + subjectTokenUrn = OAuthTokenType.ID_TOKEN; + break; + default: + throw new InvalidArgumentError('opts.subjectTokenType', 'A valid SubjectTokenType constant is required.'); + } + + const scope = transformScopes(scopes); + + let clientAssertionData: ClientIdFields | ClientAssertionFields; + + if ('clientID' in opts) { + clientAssertionData = { + client_id: opts.clientID, + ...(opts.clientSecret ? { client_secret: opts.clientSecret } : null) + }; + } else if ('clientAssertion' in opts) { + clientAssertionData = { + client_assertion_type: OAuthClientAssertionType.JWT_BEARER, + client_assertion: opts.clientAssertion + }; + } else { + throw new InvalidArgumentError('opts.clientAssertion', 'Expected a valid client assertion jwt or client id and secret.'); + } + + const requestData: RequestFields & (ClientIdFields | ClientAssertionFields) = { + grant_type: OAuthGrantType.TOKEN_EXCHANGE, + requested_token_type: OAuthTokenType.JWT_ID_JAG, + audience, + resource, + scope, + subject_token: subjectToken, + subject_token_type: subjectTokenUrn, + ...clientAssertionData + }; + + const body = qs.stringify(requestData); + + const response = await wrappedFetchFunction(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body + }); + + const resStatus = response.status; + + if (resStatus === 400) { + return { + error: new OAuthBadRequest((await response.json()) as Record) + }; + } + + if (resStatus > 200 && resStatus < 600) { + return { + error: new HttpResponse(response.url, response.status, response.statusText, await response.text()) + }; + } + + const payload = new OauthTokenExchangeResponse((await response.json()) as Record); + + if (payload.issued_token_type !== OAuthTokenType.JWT_ID_JAG) { + throw new InvalidPayloadError( + `The field 'issued_token_type' must have the value '${OAuthTokenType.JWT_ID_JAG}' per the Identity Assertion Authorization Grant Draft Section 5.2.` + ); + } + + if (payload.token_type.toLowerCase() !== 'n_a') { + throw new InvalidPayloadError( + `The field 'token_type' must have the value 'n_a' per the Identity Assertion Authorization Grant Draft Section 5.2.` + ); + } + + return { payload }; +}; + +const exchangeIdJwtAuthzGrant = async ( + opts: ExchangeJwtAuthGrantBaseOptions & (ClientIdOption | ClientAssertionOption), + wrappedFetchFunction: FetchLike +): Promise => { + const { tokenUrl, authorizationGrant, scopes } = opts; + + if (!tokenUrl || typeof tokenUrl !== 'string') { + throw new InvalidArgumentError('opts.tokenUrl', 'A valid url is required.'); + } + + if (!authorizationGrant || typeof authorizationGrant !== 'string') { + throw new InvalidArgumentError('opts.authorizationGrant', 'A valid authorization grant is required.'); + } + + const scope = transformScopes(scopes); + + let clientAssertionData: ClientIdFields | ClientAssertionFields; + + if ('clientID' in opts) { + clientAssertionData = { + client_id: opts.clientID, + ...(opts.clientSecret ? { client_secret: opts.clientSecret } : null) + }; + } else if ('clientAssertion' in opts) { + clientAssertionData = { + client_assertion_type: OAuthClientAssertionType.JWT_BEARER, + client_assertion: opts.clientAssertion + }; + } else { + throw new InvalidArgumentError('opts.clientAssertion', 'Expected a valid client assertion jwt or client id and secret.'); + } + + const requestData: ExchangeRequestFields & (ClientIdFields | ClientAssertionFields) = { + grant_type: OAuthGrantType.JWT_BEARER, + assertion: authorizationGrant, + scope, + ...clientAssertionData + }; + + const body = qs.stringify(requestData); + + const response = await wrappedFetchFunction(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body + }); + + const resStatus = response.status; + + if (resStatus === 400) { + return { + error: new OAuthBadRequest((await response.json()) as Record) + }; + } + + if (resStatus > 200 && resStatus < 600) { + return { + error: new HttpResponse(response.url, response.status, response.statusText, await response.text()) + }; + } + + const payload = new OauthJwtBearerAccessTokenResponse((await response.json()) as Record); + + return { payload }; +}; + +/** + * Retrieving an access token using the Id jag exchange + * @param options + * @param wrappedFetchFunction + * @returns access token string + */ +export const getAccessToken = async (options: XAAOptions, wrappedFetchFunction: FetchLike): Promise => { + let authGrantResponse: ExchangeTokenResult; + try { + const idpMetadata = await discoverAuthorizationServerMetadata(options.idpUrl, { + fetchFn: wrappedFetchFunction + }); + //Since subjecttokentype currently only supports oidc, we hardcode it here + authGrantResponse = await requestIdJwtAuthzGrant( + { + tokenUrl: idpMetadata?.token_endpoint || options.idpUrl, + audience: options.mcpAuthorisationServerUrl, + resource: options.mcpResourceUrl, + subjectToken: options.idToken, + subjectTokenType: 'oidc', + scopes: options.scope, + clientID: options.idpClientId, + clientSecret: options.idpClientSecret + }, + wrappedFetchFunction + ); + } catch (error: unknown) { + throw new Error(`Failed to obtain authorization grant : ${error}`); + } + + if ('error' in authGrantResponse) { + throw new Error('Failed to obtain authorization grant'); + } + + const { payload: authGrantToken } = authGrantResponse; + + let accessTokenResponse: AccessTokenResult; + + try { + const mcpMetadata = await discoverAuthorizationServerMetadata(options.mcpAuthorisationServerUrl, { + fetchFn: wrappedFetchFunction + }); + accessTokenResponse = await exchangeIdJwtAuthzGrant( + { + tokenUrl: mcpMetadata?.token_endpoint || options.mcpAuthorisationServerUrl, + authorizationGrant: authGrantToken.access_token, + scopes: options.scope, + clientID: options.mcpClientId, + clientSecret: options.mcpClientSecret + }, + wrappedFetchFunction + ); + } catch (error: unknown) { + throw new Error(`Failed to exchange the authorization grant for access token: ${error}`); + } + + if ('error' in accessTokenResponse) { + throw new Error(`Failed to exchange authorization grant for access token`); + } + return accessTokenResponse.payload.access_token; +}; + +// ============================================================================ +// CLASSES +// ============================================================================ + +class InvalidArgumentError extends Error { + constructor(argument: string, message?: string) { + super(`Invalid argument ${argument}.${message ? ` ${message}` : ''}`); + this.name = this.constructor.name; + } +} + +class InvalidPayloadError extends Error { + data?: Record; + + constructor(message: string, data?: Record) { + super(`Invalid payload. ${message}`); + this.name = this.constructor.name; + if (data && typeof data === 'object') { + this.data = data; + } + } +} + +class HttpResponse { + url: string; + + status: number; + + statusText: string; + + body?: string; + + constructor(url: string, status: number, statusText: string, body?: string) { + this.url = url; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +class OAuthBadRequest implements OAuthError { + error: OAuthErrorType; + + error_description?: string; + + error_uri?: string; + + constructor(payload: Record) { + const { error, error_description, error_uri } = payload as OAuthError; + + if (!error || !OAuthErrorTypes.includes(error)) { + throw invalidOAuthErrorResponse('error', 'must be present and a valid value', payload); + } + + this.error = error; + + if (error_description) { + if (typeof error_description !== 'string') { + throw invalidOAuthErrorResponse('error_description', 'must be a valid string', payload); + } + + this.error_description = error_description; + } + + if (error_uri) { + if (typeof error_uri !== 'string') { + throw invalidOAuthErrorResponse('error_uri', 'must be a valid string', payload); + } + + this.error_uri = error_uri; + } + } +} + +class OauthJwtBearerAccessTokenResponse implements OAuthAccessTokenResponseType { + access_token: string; + + token_type: string; + + scope?: string; + + expires_in?: number; + + refresh_token?: string; + + constructor(payload: Record) { + const { access_token, token_type, scope, expires_in, refresh_token } = payload as OAuthAccessTokenResponseType; + + if (!access_token || typeof access_token !== 'string') { + throw invalidRFC6749PayloadError('access_token', 'must be present and a valid value', payload); + } + + this.access_token = access_token; + + if (!token_type || typeof token_type !== 'string' || token_type.toLowerCase() !== 'bearer') { + throw invalidRFC7523PayloadError('token_type', "must have the value 'bearer'", payload); + } + + this.token_type = token_type; + + if (scope && typeof scope === 'string') { + this.scope = scope; + } + + if (typeof expires_in === 'number' && expires_in > 0) { + this.expires_in = expires_in; + } + + if (refresh_token && typeof refresh_token === 'string') { + this.refresh_token = refresh_token; + } + } +} + +class OauthTokenExchangeResponse implements OAuthTokenExchangeResponseType { + access_token: string; + + issued_token_type: OAuthTokenType; + + token_type: string; + + scope?: string; + + expires_in?: number; + + refresh_token?: string; + + constructor(payload: Record) { + const { access_token, issued_token_type, token_type, scope, expires_in, refresh_token } = payload as OAuthTokenExchangeResponseType; + + if (!access_token || typeof access_token !== 'string') { + throw invalidRFC8693PayloadError('access_token', 'must be present and a valid value', payload); + } + + this.access_token = access_token; + + if (!issued_token_type || typeof issued_token_type !== 'string') { + throw invalidRFC8693PayloadError('issued_token_type', 'must be present and a valid value', payload); + } + + this.issued_token_type = issued_token_type; + + if (!token_type || typeof token_type !== 'string') { + throw invalidRFC8693PayloadError('token_type', 'must be present and a valid value', payload); + } + + this.token_type = token_type; + + if (scope && typeof scope === 'string') { + this.scope = scope; + } + + if (typeof expires_in === 'number' && expires_in > 0) { + this.expires_in = expires_in; + } + + if (refresh_token && typeof refresh_token === 'string') { + this.refresh_token = refresh_token; + } + } +} diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index 451715423..ea161dcc9 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -2,7 +2,7 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import type { Mocked, MockedFunction, MockInstance } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth.js'; -import { applyMiddlewares, createMiddleware, withLogging, withOAuth } from '../../src/client/middleware.js'; +import { applyMiddlewares, createMiddleware, withLogging, withOAuth, withCrossAppAccess } from '../../src/client/middleware.js'; vi.mock('../../src/client/auth.js', async () => { const actual = await vi.importActual('../../src/client/auth.js'); @@ -13,10 +13,20 @@ vi.mock('../../src/client/auth.js', async () => { }; }); +vi.mock('../../src/client/xaa-util.js', async () => { + const actual = await vi.importActual('../../src/client/xaa-util.js'); + return { + ...actual, + getAccessToken: vi.fn() + }; +}); + import { auth, extractWWWAuthenticateParams } from '../../src/client/auth.js'; +import { getAccessToken } from '../../src/client/xaa-util.js'; const mockAuth = auth as MockedFunction; const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as MockedFunction; +const mockGetAccessToken = getAccessToken as MockedFunction; describe('withOAuth', () => { let mockProvider: Mocked; @@ -615,6 +625,49 @@ describe('withLogging', () => { }); }); +describe('withCrossAppAccess', () => { + let mockFetch: MockedFunction; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + }); + + it('should add Authorization header when tokens are available (with explicit baseUrl)', async () => { + // Mock getAccessToken to return 'test-token' + mockGetAccessToken.mockResolvedValue('test-token'); + + mockFetch.mockResolvedValue(new Response('success', { status: 200 })); + + const enhancedFetch = withCrossAppAccess({ + idpUrl: 'https://idp.example.com/token', + mcpResourceUrl: 'https://resource.example.com', + mcpAuthorisationServerUrl: 'https://authorisationServerUrl.example.com/token', + idToken: 'idToken', + idpClientId: 'idpClientId', + idpClientSecret: 'idpClientSecret', + mcpClientId: 'mcpClientId', + mcpClientSecret: 'mcpClientSecret' + })(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + // Verify getAccessToken was called + expect(mockGetAccessToken).toHaveBeenCalledTimes(1); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.any(Headers) + }) + ); + + const callArgs = mockFetch.mock.calls[0]!; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer test-token'); + }); +}); + describe('applyMiddleware', () => { let mockFetch: MockedFunction; diff --git a/packages/client/test/client/xaa-util.test.ts b/packages/client/test/client/xaa-util.test.ts new file mode 100644 index 000000000..4917df669 --- /dev/null +++ b/packages/client/test/client/xaa-util.test.ts @@ -0,0 +1,994 @@ +import { getAccessToken, type XAAOptions } from '../../src/client/xaa-util.js'; +import type { FetchLike } from '@modelcontextprotocol/core'; +import { MockedFunction } from 'vitest'; + +// Mock fetch function +const mockFetch = vi.fn() as MockedFunction; + +// Helper function to mock metadata discovery +const mockMetadataDiscovery = (url: string) => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + issuer: url, + authorization_endpoint: `${url}/authorize`, + token_endpoint: `${url}/token`, + response_types_supported: ['code'] + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); +}; + +describe('XAA Util', () => { + let xaaOptions: XAAOptions; + + beforeEach(() => { + mockFetch.mockReset(); + + xaaOptions = { + idpUrl: 'https://idp.example.com', + mcpResourceUrl: 'https://resource.example.com', + mcpAuthorisationServerUrl: 'https://auth.example.com', + idToken: 'test-id-token', + idpClientId: 'idp-client-id', + idpClientSecret: 'idp-client-secret', + mcpClientId: 'mcp-client-id', + mcpClientSecret: 'mcp-client-secret', + scope: ['read', 'write'] + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAccessToken', () => { + describe('successful token exchange flow', () => { + it('should successfully exchange tokens and return access token', async () => { + // Mock IDP metadata discovery + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + issuer: 'https://idp.example.com', + authorization_endpoint: 'https://idp.example.com/authorize', + token_endpoint: 'https://idp.example.com/token', + response_types_supported: ['code'] + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); + + // Mock first token exchange response (authorization grant) + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A', + expires_in: 3600 + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); + + // Mock MCP metadata discovery + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); + + // Mock second token exchange response (access token) + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer', + expires_in: 3600 + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + expect(mockFetch).toHaveBeenCalledTimes(4); + + // Verify first call is IDP metadata discovery + const firstCall = mockFetch.mock.calls[0]!; + expect(firstCall[0].toString()).toContain('idp.example.com'); + + // Verify second call is authorization grant request + const secondCall = mockFetch.mock.calls[1]!; + expect(secondCall[0]).toBe('https://idp.example.com/token'); + expect(secondCall[1]?.method).toBe('POST'); + expect(secondCall[1]?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + // Verify third call is MCP metadata discovery + const thirdCall = mockFetch.mock.calls[2]!; + expect(thirdCall[0].toString()).toContain('auth.example.com'); + + // Verify fourth call is access token request + const fourthCall = mockFetch.mock.calls[3]!; + expect(fourthCall[0]).toBe('https://auth.example.com/token'); + expect(fourthCall[1]?.method).toBe('POST'); + expect(fourthCall[1]?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + }); + + it('should handle scopes passed as array', async () => { + xaaOptions.scope = ['read', 'write', 'admin']; + + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('should handle scopes passed as Set', async () => { + xaaOptions.scope = ['read', 'write']; + + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + }); + + it('should handle optional scope field not provided', async () => { + delete xaaOptions.scope; + + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('should handle response with optional fields', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A', + expires_in: 7200, + scope: 'read write', + refresh_token: 'refresh-token-value' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read write', + refresh_token: 'access-refresh-token' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + }); + }); + + describe('authorization grant request failures', () => { + it('should throw error when authorization grant request fails with 400', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'Invalid token exchange request' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should throw error when authorization grant request fails with 401', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error when authorization grant request fails with 500', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error when authorization grant request throws network error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error when authorization grant response has invalid error type', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'unknown_error', + error_description: 'Unknown error occurred' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow(); + }); + + it('should throw error when authorization grant response has invalid issued_token_type', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'invalid-token-type', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error when authorization grant response has invalid token_type', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error when authorization grant response missing access_token', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow(); + }); + }); + + describe('access token exchange request failures', () => { + beforeEach(() => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + // Mock successful authorization grant request + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + }); + + it('should throw error when access token request fails with 400', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: 'Invalid authorization grant' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow( + 'Failed to exchange authorization grant for access token' + ); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('should throw error when access token request fails with 401', async () => { + mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow( + 'Failed to exchange authorization grant for access token' + ); + }); + + it('should throw error when access token request fails with 500', async () => { + mockFetch.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow( + 'Failed to exchange authorization grant for access token' + ); + }); + + it('should throw error when access token request throws network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow( + 'Failed to exchange the authorization grant for access token' + ); + }); + + it('should throw error when access token response has invalid error type', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'custom_error', + error_description: 'Custom error' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow(); + }); + + it('should throw error when access token response has invalid token_type', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Invalid' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow( + 'Failed to exchange the authorization grant for access token' + ); + }); + + it('should throw error when access token response missing access_token', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow(); + }); + + it('should throw error when access token response missing token_type', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow(); + }); + }); + + describe('OAuth error handling', () => { + it('should throw error for invalid_request error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'The request is missing a required parameter', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error for invalid_client error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid_client', + error_description: 'Client authentication failed' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error for invalid_grant error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: 'The provided authorization grant is invalid' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow( + 'Failed to exchange authorization grant for access token' + ); + }); + + it('should throw error for unauthorized_client error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'unauthorized_client', + error_description: 'The client is not authorized to use this grant type' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error for unsupported_grant_type error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'unsupported_grant_type', + error_description: 'The grant type is not supported' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error for invalid_scope error', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid_scope', + error_description: 'The requested scope is invalid or exceeds the granted scope' + }), + { status: 400 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + }); + + describe('edge cases and validation', () => { + it('should throw error for empty response body', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error for malformed JSON response', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce(new Response('not valid json', { status: 200 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should throw error for response with unexpected status codes', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce(new Response('Accepted', { status: 202 })); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow('Failed to obtain authorization grant'); + }); + + it('should correctly construct URLs with trailing slashes', async () => { + xaaOptions.idpUrl = 'https://idp.example.com/'; + xaaOptions.mcpAuthorisationServerUrl = 'https://auth.example.com/'; + + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com/'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com/'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + // Check the token endpoint URL from metadata discovery + expect(mockFetch.mock.calls[1]![0]).toContain('https://idp.example.com'); + expect(mockFetch.mock.calls[3]![0]).toContain('https://auth.example.com'); + }); + + it('should maintain proper request headers for both calls', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + await getAccessToken(xaaOptions, mockFetch); + + // Check token request headers (calls 1 and 3, since 0 and 2 are metadata) + expect(mockFetch.mock.calls[1]![1]?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + expect(mockFetch.mock.calls[3]![1]?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + }); + + it('should pass client credentials correctly in request body', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + await getAccessToken(xaaOptions, mockFetch); + + // Verify first token request includes IDP credentials (call index 1, since 0 is metadata) + const firstBody = mockFetch.mock.calls[1]![1]?.body as string; + expect(firstBody).toContain(`grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange`); + expect(firstBody).toContain(`requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag`); + expect(firstBody).toContain(`audience=${encodeURIComponent(xaaOptions.mcpAuthorisationServerUrl)}`); + expect(firstBody).toContain(`scope=${encodeURIComponent(xaaOptions.scope!.join(' '))}`); + expect(firstBody).toContain(`subject_token=${encodeURIComponent(xaaOptions.idToken)}`); + expect(firstBody).toContain(`subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token`); + expect(firstBody).toContain(`client_id=${encodeURIComponent(xaaOptions.idpClientId)}`); + expect(firstBody).toContain(`client_secret=${encodeURIComponent(xaaOptions.idpClientSecret)}`); + + // Verify second token request includes MCP credentials (call index 3, since 2 is metadata) + const secondBody = mockFetch.mock.calls[3]![1]?.body as string; + expect(secondBody).toContain(`grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer`); + expect(secondBody).toContain(`assertion=auth-grant-token`); + expect(secondBody).toContain(`scope=${encodeURIComponent(xaaOptions.scope!.join(' '))}`); + expect(secondBody).toContain(`client_id=${encodeURIComponent(xaaOptions.mcpClientId)}`); + expect(secondBody).toContain(`client_secret=${encodeURIComponent(xaaOptions.mcpClientSecret)}`); + }); + }); + + describe('token type validation', () => { + it('should accept case-insensitive "N_A" for authorization grant token_type', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'n_a' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + }); + + it('should accept case-insensitive "Bearer" for access token token_type', async () => { + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + }); + + it('should throw error for invalid issued_token_type values', async () => { + const invalidTokenTypes = [ + 'urn:ietf:params:oauth:token-type:access_token', + 'urn:ietf:params:oauth:token-type:jwt', + 'custom-token-type' + ]; + + for (const invalidType of invalidTokenTypes) { + mockFetch.mockReset(); + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: invalidType, + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + await expect(getAccessToken(xaaOptions, mockFetch)).rejects.toThrow(); + } + }); + }); + + describe('request body encoding', () => { + it('should properly encode special characters in credentials', async () => { + xaaOptions.idpClientSecret = 'secret@123!#$%^&*()'; + xaaOptions.mcpClientSecret = 'pass+word=special&chars'; + + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + + // Check that special characters are properly encoded in the first token request body (call index 1) + const firstBody = mockFetch.mock.calls[1]![1]?.body as string; + expect(firstBody).toContain(`grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange`); + expect(firstBody).toContain(`requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag`); + expect(firstBody).toContain(`audience=${encodeURIComponent(xaaOptions.mcpAuthorisationServerUrl)}`); + expect(firstBody).toContain(`scope=${encodeURIComponent(xaaOptions.scope!.join(' '))}`); + expect(firstBody).toContain(`subject_token=${encodeURIComponent(xaaOptions.idToken)}`); + expect(firstBody).toContain(`subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token`); + expect(firstBody).toContain(`client_id=${encodeURIComponent(xaaOptions.idpClientId)}`); + expect(firstBody).toContain(`client_secret=secret%40123%21%23%24%25%5E%26%2A%28%29`); + + // Check that special characters are properly encoded in the second token request body (call index 3) + const secondBody = mockFetch.mock.calls[3]![1]?.body as string; + expect(secondBody).toContain(`grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer`); + expect(secondBody).toContain(`assertion=auth-grant-token`); + expect(secondBody).toContain(`scope=${encodeURIComponent(xaaOptions.scope!.join(' '))}`); + expect(secondBody).toContain( + `client_id=${encodeURIComponent(xaaOptions.mcpClientId)}&` + `client_secret=pass%2Bword%3Dspecial%26chars` + ); + }); + + it('should properly encode scope values', async () => { + xaaOptions.scope = ['read:user', 'write:data', 'admin:all']; + + // Mock IDP metadata discovery + mockMetadataDiscovery('https://idp.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'auth-grant-token', + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }), + { status: 200 } + ) + ); + + // Mock MCP metadata discovery + mockMetadataDiscovery('https://auth.example.com'); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'final-access-token', + token_type: 'Bearer' + }), + { status: 200 } + ) + ); + + const result = await getAccessToken(xaaOptions, mockFetch); + + expect(result).toBe('final-access-token'); + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dbf8253..437220d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ catalogs: jose: specifier: ^6.1.1 version: 6.1.3 + qs: + specifier: ^6.13.0 + version: 6.14.0 runtimeServerOnly: '@hono/node-server': specifier: ^1.19.7 @@ -409,6 +412,9 @@ importers: pkce-challenge: specifier: catalog:runtimeShared version: 5.0.0 + qs: + specifier: catalog:runtimeClientOnly + version: 6.14.0 zod: specifier: catalog:runtimeShared version: 3.25.76 @@ -443,6 +449,9 @@ importers: '@types/eventsource': specifier: catalog:devTools version: 1.1.15 + '@types/qs': + specifier: ^6.9.18 + version: 6.14.0 '@typescript/native-preview': specifier: catalog:devTools version: 7.0.0-dev.20251218.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 12bae8326..84b473e33 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ catalogs: cross-spawn: ^7.0.5 eventsource: ^3.0.2 eventsource-parser: ^3.0.0 + qs: ^6.13.0 devTools: typescript: ^5.9.3 vitest: ^4.0.8 diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/taskResumability.test.ts index 1e4d8a0fd..178a95202 100644 --- a/test/integration/test/taskResumability.test.ts +++ b/test/integration/test/taskResumability.test.ts @@ -2,13 +2,13 @@ import { randomUUID } from 'node:crypto'; import { createServer, type Server } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; import { CallToolResultSchema, LoggingMessageNotificationSchema, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers'; import { listenOnRandomPort, zodTestMatrix } from '@modelcontextprotocol/test-helpers';