From 4582dfc9b4037d931abf64c6fa7332a5f4e03210 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Wed, 21 Jan 2026 11:54:48 +0530 Subject: [PATCH 01/13] Resolving merge conflicts --- docs/client.md | 56 + packages/client/package.json | 2 + packages/client/src/client/middleware.ts | 30 + packages/client/src/client/xaa-util.ts | 593 +++++++++++ .../client/test/client/middleware.test.ts | 55 +- packages/client/test/client/xaa-util.test.ts | 994 ++++++++++++++++++ pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + 8 files changed, 1739 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/client/xaa-util.ts create mode 100644 packages/client/test/client/xaa-util.test.ts diff --git a/docs/client.md b/docs/client.md index 41f91656a..c30d0dbd1 100644 --- a/docs/client.md +++ b/docs/client.md @@ -62,3 +62,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 7b27a1972..af7d17df4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -50,6 +50,7 @@ "eventsource-parser": "catalog:runtimeClientOnly", "jose": "catalog:runtimeClientOnly", "pkce-challenge": "catalog:runtimeShared", + "qs": "catalog:runtimeClientOnly", "zod": "catalog:runtimeShared" }, "peerDependencies": { @@ -74,6 +75,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 3fd52e41a..cc6cb1385 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -2,6 +2,7 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import type { OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; +import { getAccessToken, XAAOptions } from './xaa-util.js'; /** * Middleware function that wraps and enhances fetch functionality. @@ -230,6 +231,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..63929b1a6 --- /dev/null +++ b/packages/client/src/client/xaa-util.ts @@ -0,0 +1,593 @@ +import qs from 'qs'; +import type { FetchLike } from '@modelcontextprotocol/core'; +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; + } + } +} \ No newline at end of file diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index 451715423..303f98c47 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..2264c05d9 --- /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'); + }); + }); + }); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2d18d927..bbc05d990 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,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.8 @@ -466,6 +469,9 @@ importers: pkce-challenge: specifier: catalog:runtimeShared version: 5.0.1 + qs: + specifier: catalog:runtimeClientOnly + version: 6.14.0 zod: specifier: catalog:runtimeShared version: 4.3.5 @@ -500,6 +506,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.20260105.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd1db2a5b..5f8b0ccb0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,7 @@ catalogs: eventsource: ^3.0.2 eventsource-parser: ^3.0.0 jose: ^6.1.1 + qs: ^6.13.0 runtimeServerOnly: '@hono/node-server': ^1.19.8 content-type: ^1.0.5 From 3f01cfa2c40815fe0cb00c8ba52b6e68e68700fa Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Sat, 20 Dec 2025 18:15:00 +0530 Subject: [PATCH 02/13] Fixing PR build error by resolving typescript issues --- .../client/test/client/middleware.test.ts | 2 +- packages/client/test/client/xaa-util.test.ts | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index 303f98c47..ea161dcc9 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -662,7 +662,7 @@ describe('withCrossAppAccess', () => { }) ); - const callArgs = mockFetch.mock.calls[0]; + const callArgs = mockFetch.mock.calls[0]!; const headers = callArgs[1]?.headers as Headers; expect(headers.get('Authorization')).toBe('Bearer test-token'); }); diff --git a/packages/client/test/client/xaa-util.test.ts b/packages/client/test/client/xaa-util.test.ts index 2264c05d9..b244abf7a 100644 --- a/packages/client/test/client/xaa-util.test.ts +++ b/packages/client/test/client/xaa-util.test.ts @@ -118,11 +118,11 @@ describe('XAA Util', () => { expect(mockFetch).toHaveBeenCalledTimes(4); // Verify first call is IDP metadata discovery - const firstCall = mockFetch.mock.calls[0]; + 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]; + 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({ @@ -130,11 +130,11 @@ describe('XAA Util', () => { }); // Verify third call is MCP metadata discovery - const thirdCall = mockFetch.mock.calls[2]; + 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]; + 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({ @@ -709,8 +709,8 @@ describe('XAA Util', () => { 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'); + 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 () => { @@ -744,10 +744,10 @@ describe('XAA Util', () => { 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({ + expect(mockFetch.mock.calls[1]![1]?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); - expect(mockFetch.mock.calls[3][1]?.headers).toEqual({ + expect(mockFetch.mock.calls[3]![1]?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); }); @@ -783,7 +783,7 @@ describe('XAA Util', () => { 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; + 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)}`); @@ -794,7 +794,7 @@ describe('XAA Util', () => { 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; + 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(' '))}`); @@ -935,7 +935,7 @@ describe('XAA Util', () => { 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; + 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)}`); @@ -946,7 +946,7 @@ describe('XAA Util', () => { 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; + 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(' '))}`); From a783bde78c1fc474e8d01c9d40497e1725e7bef8 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Sat, 20 Dec 2025 18:20:51 +0530 Subject: [PATCH 03/13] Fixed prettier and lint errors --- packages/client/src/client/middleware.ts | 3 ++- packages/client/src/client/xaa-util.ts | 5 +++-- packages/client/test/client/xaa-util.test.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index cc6cb1385..aa5cef1c3 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -2,7 +2,8 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import type { OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; -import { getAccessToken, XAAOptions } from './xaa-util.js'; +import type { XAAOptions } from './xaa-util.js'; +import { getAccessToken } from './xaa-util.js'; /** * Middleware function that wraps and enhances fetch functionality. diff --git a/packages/client/src/client/xaa-util.ts b/packages/client/src/client/xaa-util.ts index 63929b1a6..7036bd7a9 100644 --- a/packages/client/src/client/xaa-util.ts +++ b/packages/client/src/client/xaa-util.ts @@ -1,5 +1,6 @@ -import qs from 'qs'; import type { FetchLike } from '@modelcontextprotocol/core'; +import qs from 'qs'; + import { discoverAuthorizationServerMetadata } from './auth.js'; // ============================================================================ // CONSTANTS @@ -590,4 +591,4 @@ class OauthTokenExchangeResponse implements OAuthTokenExchangeResponseType { this.refresh_token = refresh_token; } } -} \ No newline at end of file +} diff --git a/packages/client/test/client/xaa-util.test.ts b/packages/client/test/client/xaa-util.test.ts index b244abf7a..4917df669 100644 --- a/packages/client/test/client/xaa-util.test.ts +++ b/packages/client/test/client/xaa-util.test.ts @@ -991,4 +991,4 @@ describe('XAA Util', () => { }); }); }); -}); \ No newline at end of file +}); From 2d7d9a2d48c671db4aff078b8acb590cfbe4699b Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Wed, 21 Jan 2026 12:01:53 +0530 Subject: [PATCH 04/13] Fix lockfile after rebase - regenerate qs package entries --- pnpm-lock.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbc05d990..111ae72c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4639,6 +4639,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -9710,6 +9714,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + qs@6.14.1: dependencies: side-channel: 1.1.0 From c4e4092d81d7bc4d84a29bfc97662e980e13bf1e Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Wed, 21 Jan 2026 12:08:56 +0530 Subject: [PATCH 05/13] Fix linting errors: rename xaa-util to xaaUtil, fix unicorn rules --- packages/client/src/client/middleware.ts | 6 +- packages/client/src/client/xaa-util.ts | 11 +- packages/client/src/client/xaaUtil.ts | 597 ++++++++++++++++++ .../client/test/client/middleware.test.ts | 6 +- packages/client/test/client/xaa-util.test.ts | 2 +- 5 files changed, 611 insertions(+), 11 deletions(-) create mode 100644 packages/client/src/client/xaaUtil.ts diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index aa5cef1c3..9d15a6b15 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -2,8 +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'; +import type { XAAOptions } from './xaaUtil.js'; +import { getAccessToken } from './xaaUtil.js'; /** * Middleware function that wraps and enhances fetch functionality. @@ -243,7 +243,7 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => { */ export const withCrossAppAccess = (options: XAAOptions): Middleware => { return wrappedFetchFunction => { - let accessToken: string | undefined = undefined; + let accessToken: string | undefined; return async (url, init = {}): Promise => { if (!accessToken) { diff --git a/packages/client/src/client/xaa-util.ts b/packages/client/src/client/xaa-util.ts index 7036bd7a9..db23d6b9f 100644 --- a/packages/client/src/client/xaa-util.ts +++ b/packages/client/src/client/xaa-util.ts @@ -179,7 +179,7 @@ const transformScopes = (scopes?: string | Set | string[] | null) => { } if (scopes instanceof Set) { - return Array.from(scopes).join(' '); + return [...scopes].join(' '); } if (typeof scopes === 'string') { @@ -221,14 +221,17 @@ const requestIdJwtAuthzGrant = async ( let subjectTokenUrn: OAuthTokenType; switch (subjectTokenType) { - case 'saml': + case 'saml': { subjectTokenUrn = OAuthTokenType.SAML2; break; - case 'oidc': + } + case 'oidc': { subjectTokenUrn = OAuthTokenType.ID_TOKEN; break; - default: + } + default: { throw new InvalidArgumentError('opts.subjectTokenType', 'A valid SubjectTokenType constant is required.'); + } } const scope = transformScopes(scopes); diff --git a/packages/client/src/client/xaaUtil.ts b/packages/client/src/client/xaaUtil.ts new file mode 100644 index 000000000..db23d6b9f --- /dev/null +++ b/packages/client/src/client/xaaUtil.ts @@ -0,0 +1,597 @@ +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 [...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 ea161dcc9..a8d4cd71e 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -13,8 +13,8 @@ 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'); +vi.mock('../../src/client/xaaUtil.js', async () => { + const actual = await vi.importActual('../../src/client/xaaUtil.js'); return { ...actual, getAccessToken: vi.fn() @@ -22,7 +22,7 @@ vi.mock('../../src/client/xaa-util.js', async () => { }); import { auth, extractWWWAuthenticateParams } from '../../src/client/auth.js'; -import { getAccessToken } from '../../src/client/xaa-util.js'; +import { getAccessToken } from '../../src/client/xaaUtil.js'; const mockAuth = auth as MockedFunction; const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as MockedFunction; diff --git a/packages/client/test/client/xaa-util.test.ts b/packages/client/test/client/xaa-util.test.ts index 4917df669..38495018e 100644 --- a/packages/client/test/client/xaa-util.test.ts +++ b/packages/client/test/client/xaa-util.test.ts @@ -1,4 +1,4 @@ -import { getAccessToken, type XAAOptions } from '../../src/client/xaa-util.js'; +import { getAccessToken, type XAAOptions } from '../../src/client/xaaUtil.js'; import type { FetchLike } from '@modelcontextprotocol/core'; import { MockedFunction } from 'vitest'; From bb49eac0d9e00702c3731c00fba7041298f2ee4a Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Wed, 21 Jan 2026 12:11:41 +0530 Subject: [PATCH 06/13] Remove old xaa-util.ts file (duplicate after rename) --- packages/client/src/client/xaa-util.ts | 597 ------------------------- 1 file changed, 597 deletions(-) delete mode 100644 packages/client/src/client/xaa-util.ts diff --git a/packages/client/src/client/xaa-util.ts b/packages/client/src/client/xaa-util.ts deleted file mode 100644 index db23d6b9f..000000000 --- a/packages/client/src/client/xaa-util.ts +++ /dev/null @@ -1,597 +0,0 @@ -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 [...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; - } - } -} From ec00df3b2058c5ded5b0c985c3bf6fb94b6665a9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:03:28 +0000 Subject: [PATCH 07/13] Reimplement XAA as OAuthClientProvider with Layer 2 utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the standalone middleware approach from PR #1328 with: - CrossAppAccessProvider in authExtensions.ts: plugs into withOAuth for token caching, 401 retry, client_secret_basic, and Zod validation - assertion callback: receives orchestrator context (AS URL, resource URL, scope, fetchFn), returns JAG string. Decouples IDP interaction from provider - requestJwtAuthorizationGrant in crossAppAccess.ts: standalone Layer 2 utility for RFC 8693 token exchange (ID token → JAG) - saveResourceUrl on OAuthClientProvider: orchestrator saves resource URL so providers can use it in prepareTokenRequest Removes: withCrossAppAccess middleware, xaaUtil.ts (597 lines), qs dependency Adds: conformance test scenario (auth/cross-app-access-complete-flow) - passes 9/9 --- packages/client/src/client/auth.ts | 12 + packages/client/src/client/authExtensions.ts | 179 +++++- packages/client/src/client/crossAppAccess.ts | 133 +++++ packages/client/src/client/middleware.ts | 31 - packages/client/src/client/xaaUtil.ts | 597 ------------------- packages/client/src/index.ts | 1 + src/conformance/everything-client.ts | 62 +- 7 files changed, 385 insertions(+), 630 deletions(-) create mode 100644 packages/client/src/client/crossAppAccess.ts delete mode 100644 packages/client/src/client/xaaUtil.ts diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 2946f3f41..1dcf2e4a5 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -188,6 +188,14 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the resource URL determined during the OAuth flow. + * + * Called by {@linkcode auth} after resource URL selection so providers can + * use it in grant-specific logic (e.g., Cross-App Access token exchange). + */ + saveResourceUrl?(url: URL): void | Promise; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -422,6 +430,10 @@ async function authInternal( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + if (resource) { + await provider.saveResourceUrl?.(resource); + } + const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }); diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index d5f63bd66..e53a80e62 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -5,7 +5,7 @@ * for common machine-to-machine authentication scenarios. */ -import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; @@ -396,3 +396,180 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { return params; } } + +/** + * Context passed to the assertion callback in {@link CrossAppAccessProvider}. + */ +export interface CrossAppAccessAssertionContext { + /** The MCP authorization server URL (use as `audience` in token exchange). */ + authorizationServerUrl: string; + /** The MCP resource URL (use as `resource` in token exchange). */ + resourceUrl: string; + /** Scope requested by the orchestrator. */ + scope?: string; + /** Fetch function from the provider, if configured. */ + fetchFn?: FetchLike; +} + +/** + * Options for creating a CrossAppAccessProvider. + */ +export interface CrossAppAccessProviderOptions { + /** + * Returns the JWT Authorization Grant (JAG) assertion. + * Called each time tokens need to be obtained (initial auth and 401 retry). + * + * Use {@link requestJwtAuthorizationGrant} from `crossAppAccess.ts` for the + * standard RFC 8693 token exchange flow, or implement custom logic. + */ + assertion: (context: CrossAppAccessAssertionContext) => Promise; + + /** MCP client ID for authentication with the MCP authorization server. */ + clientId: string; + + /** MCP client secret. */ + clientSecret?: string; + + /** Optional client name for metadata. */ + clientName?: string; + + /** Optional scopes to request. */ + scope?: string[]; + + /** Optional fetch function passed through to the assertion callback. */ + fetchFn?: FetchLike; +} + +/** + * OAuth provider for Cross-App Access using the Identity Assertion Authorization Grant. + * + * Implements a two-step OAuth flow: + * 1. Obtains a JWT Authorization Grant (JAG) via the `assertion` callback + * (typically RFC 8693 Token Exchange with an IDP) + * 2. Exchanges the JAG for an access token at the MCP authorization server + * via RFC 7523 JWT Bearer grant (handled by `withOAuth` infrastructure) + * + * Step 1 is delegated to the caller via the `assertion` callback. Step 2 is + * executed by the SDK's standard token request machinery, providing token + * caching, 401 retry, and refresh handling automatically. + * + * @example + * ```typescript + * import { CrossAppAccessProvider, requestJwtAuthorizationGrant } from '@modelcontextprotocol/client'; + * + * const provider = new CrossAppAccessProvider({ + * assertion: async (ctx) => requestJwtAuthorizationGrant({ + * tokenEndpoint: 'https://idp.example.com/token', + * audience: ctx.authorizationServerUrl, + * resource: ctx.resourceUrl, + * idToken: await getIdToken(), + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret', + * scope: ctx.scope, + * fetchFn: ctx.fetchFn + * }), + * clientId: 'my-mcp-client', + * clientSecret: 'my-mcp-secret', + * scope: ['read', 'write'] + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class CrossAppAccessProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + private _options: CrossAppAccessProviderOptions; + private _authServerUrl?: string | URL; + private _resourceUrl?: URL; + + constructor(options: CrossAppAccessProviderOptions) { + this._options = options; + this._clientInfo = { + client_id: options.clientId, + client_secret: options.clientSecret + }; + this._clientMetadata = { + client_name: options.clientName ?? 'cross-app-access-client', + redirect_uris: [], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_method: options.clientSecret ? 'client_secret_basic' : 'none', + scope: options.scope?.join(' ') + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for cross-app access flow'); + } + + saveCodeVerifier(): void { + // Not used for cross-app access + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for cross-app access flow'); + } + + saveAuthorizationServerUrl(url: string | URL): void { + this._authServerUrl = url; + } + + authorizationServerUrl(): string | URL | undefined { + return this._authServerUrl; + } + + saveResourceUrl(url: URL): void { + this._resourceUrl = url; + } + + /** + * Calls the assertion callback to get a JAG, then returns JWT Bearer + * grant params for the MCP AS token request. + */ + async prepareTokenRequest(scope?: string): Promise { + const effectiveScope = scope ?? this._options.scope?.join(' '); + + const assertion = await this._options.assertion({ + authorizationServerUrl: String(this._authServerUrl ?? ''), + resourceUrl: this._resourceUrl?.href ?? '', + scope: effectiveScope, + fetchFn: this._options.fetchFn + }); + + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion + }); + if (effectiveScope) { + params.set('scope', effectiveScope); + } + return params; + } +} diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts new file mode 100644 index 000000000..357e0721c --- /dev/null +++ b/packages/client/src/client/crossAppAccess.ts @@ -0,0 +1,133 @@ +/** + * Cross-App Access utilities for the Identity Assertion Authorization Grant flow. + * + * Provides standalone functions for RFC 8693 Token Exchange (ID token → JAG). + * Used by {@link CrossAppAccessProvider} and available for direct use. + */ + +import type { FetchLike } from '@modelcontextprotocol/core'; + +import { discoverAuthorizationServerMetadata } from './auth.js'; + +/** + * Options for requesting a JWT Authorization Grant from an Identity Provider. + */ +export interface RequestJwtAuthGrantOptions { + /** The IDP's token endpoint URL. */ + tokenEndpoint: string; + /** The MCP authorization server URL (used as the `audience` parameter). */ + audience: string; + /** The MCP resource server URL (used as the `resource` parameter). */ + resource: string; + /** The OIDC ID token to exchange. */ + idToken: string; + /** Client ID for authentication with the IDP. */ + clientId: string; + /** Client secret for authentication with the IDP. */ + clientSecret?: string; + /** Optional scopes to request. */ + scope?: string; + /** Optional fetch function for HTTP requests. */ + fetchFn?: FetchLike; +} + +/** + * Requests a JWT Authorization Grant (JAG) from an Identity Provider via + * RFC 8693 Token Exchange. Returns the JAG to be used as a JWT Bearer + * assertion (RFC 7523) against the MCP authorization server. + */ +export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise { + const effectiveFetch = options.fetchFn ?? fetch; + + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + subject_token: options.idToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + audience: options.audience, + resource: options.resource, + client_id: options.clientId, + ...(options.clientSecret ? { client_secret: options.clientSecret } : {}), + ...(options.scope ? { scope: options.scope } : {}) + }); + + const response = await effectiveFetch(options.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }, + body + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`JWT Authorization Grant request failed (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as Record; + + if (typeof data.access_token !== 'string' || !data.access_token) { + throw new Error('Token exchange response missing access_token'); + } + if (data.issued_token_type !== 'urn:ietf:params:oauth:token-type:id-jag') { + throw new Error(`Expected issued_token_type 'urn:ietf:params:oauth:token-type:id-jag', got '${data.issued_token_type}'`); + } + if (typeof data.token_type !== 'string' || data.token_type.toLowerCase() !== 'n_a') { + throw new Error(`Expected token_type 'n_a', got '${data.token_type}'`); + } + + return data.access_token; +} + +/** + * Options for discovering and requesting a JWT Authorization Grant. + */ +export interface DiscoverAndRequestJwtAuthGrantOptions { + /** Identity Provider's base URL for OAuth/OIDC discovery. */ + idpUrl: string; + /** IDP token endpoint URL. When provided, skips IDP metadata discovery. */ + idpTokenEndpoint?: string; + /** The MCP authorization server URL (used as the `audience` parameter). */ + audience: string; + /** The MCP resource server URL (used as the `resource` parameter). */ + resource: string; + /** The OIDC ID token to exchange. */ + idToken: string; + /** Client ID for authentication with the IDP. */ + clientId: string; + /** Client secret for authentication with the IDP. */ + clientSecret?: string; + /** Optional scopes to request. */ + scope?: string; + /** Optional fetch function for HTTP requests. */ + fetchFn?: FetchLike; +} + +/** + * Discovers the IDP's token endpoint via metadata, then requests a JAG. + * Convenience wrapper over {@link requestJwtAuthorizationGrant}. + */ +export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { + let tokenEndpoint = options.idpTokenEndpoint; + + if (!tokenEndpoint) { + try { + const idpMetadata = await discoverAuthorizationServerMetadata(options.idpUrl, { fetchFn: options.fetchFn }); + tokenEndpoint = idpMetadata?.token_endpoint; + } catch { + // Discovery failed — fall back to idpUrl + } + } + + return requestJwtAuthorizationGrant({ + tokenEndpoint: tokenEndpoint ?? options.idpUrl, + audience: options.audience, + resource: options.resource, + idToken: options.idToken, + clientId: options.clientId, + clientSecret: options.clientSecret, + scope: options.scope, + fetchFn: options.fetchFn + }); +} diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index 9d15a6b15..3fd52e41a 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -2,8 +2,6 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import type { OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; -import type { XAAOptions } from './xaaUtil.js'; -import { getAccessToken } from './xaaUtil.js'; /** * Middleware function that wraps and enhances fetch functionality. @@ -232,35 +230,6 @@ 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; - - 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/xaaUtil.ts b/packages/client/src/client/xaaUtil.ts deleted file mode 100644 index db23d6b9f..000000000 --- a/packages/client/src/client/xaaUtil.ts +++ /dev/null @@ -1,597 +0,0 @@ -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 [...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/src/index.ts b/packages/client/src/index.ts index 787cfd2f0..ac9d5d25f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,5 +1,6 @@ export * from './client/auth.js'; export * from './client/authExtensions.js'; +export * from './client/crossAppAccess.js'; export * from './client/client.js'; export * from './client/middleware.js'; export * from './client/sse.js'; diff --git a/src/conformance/everything-client.ts b/src/conformance/everything-client.ts index d21c8e66a..3803eb57a 100644 --- a/src/conformance/everything-client.ts +++ b/src/conformance/everything-client.ts @@ -17,7 +17,10 @@ import { StreamableHTTPClientTransport, ElicitRequestSchema, ClientCredentialsProvider, - PrivateKeyJwtProvider + CrossAppAccessProvider, + PrivateKeyJwtProvider, + discoverOAuthProtectedResourceMetadata, + requestJwtAuthorizationGrant } from '@modelcontextprotocol/client'; import { z } from 'zod'; import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js'; @@ -47,6 +50,15 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/client-credentials-basic'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-complete-flow'), + client_id: z.string(), + client_secret: z.string(), + idp_client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + idp_token_endpoint: z.string() }) ]); @@ -245,6 +257,54 @@ async function runClientCredentialsBasic(serverUrl: string): Promise { registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// Cross-App Access (SEP-990) scenario +// ============================================================================ + +async function runCrossAppAccess(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-complete-flow') { + throw new Error(`Expected cross-app-access context, got ${ctx.name}`); + } + + // Discover the MCP authorization server URL via RFC 9728 + const prm = await discoverOAuthProtectedResourceMetadata(serverUrl); + const mcpAuthServerUrl = prm.authorization_servers?.[0] ?? serverUrl; + + const provider = new CrossAppAccessProvider({ + assertion: async (actx) => requestJwtAuthorizationGrant({ + tokenEndpoint: ctx.idp_token_endpoint, + audience: actx.authorizationServerUrl, + resource: actx.resourceUrl, + idToken: ctx.idp_id_token, + clientId: ctx.idp_client_id, + scope: actx.scope, + fetchFn: actx.fetchFn + }), + clientId: ctx.client_id, + clientSecret: ctx.client_secret + }); + + // Pre-set the auth server URL so the provider knows it before prepareTokenRequest + provider.saveAuthorizationServerUrl(mcpAuthServerUrl); + + const client = new Client({ name: 'conformance-cross-app-access', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Connected via Cross-App Access'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccess); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ From 37477aaa6cc7972c778c17ef833ab13f6b070429 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:08:12 +0000 Subject: [PATCH 08/13] Remove leftover artifacts from middleware approach - Delete xaa-util.test.ts (tests for removed xaaUtil.ts) - Revert docs/client.md withCrossAppAccess documentation - Remove qs and @types/qs dependencies from package.json - Remove qs from pnpm-workspace.yaml catalog - Revert middleware.test.ts withCrossAppAccess test additions --- docs/client.md | 56 - packages/client/package.json | 2 - .../client/test/client/middleware.test.ts | 55 +- packages/client/test/client/xaa-util.test.ts | 994 ------------------ pnpm-workspace.yaml | 1 - 5 files changed, 1 insertion(+), 1107 deletions(-) delete mode 100644 packages/client/test/client/xaa-util.test.ts diff --git a/docs/client.md b/docs/client.md index c30d0dbd1..41f91656a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -62,59 +62,3 @@ 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 af7d17df4..7b27a1972 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -50,7 +50,6 @@ "eventsource-parser": "catalog:runtimeClientOnly", "jose": "catalog:runtimeClientOnly", "pkce-challenge": "catalog:runtimeShared", - "qs": "catalog:runtimeClientOnly", "zod": "catalog:runtimeShared" }, "peerDependencies": { @@ -75,7 +74,6 @@ "@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/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index a8d4cd71e..451715423 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, withCrossAppAccess } from '../../src/client/middleware.js'; +import { applyMiddlewares, createMiddleware, withLogging, withOAuth } from '../../src/client/middleware.js'; vi.mock('../../src/client/auth.js', async () => { const actual = await vi.importActual('../../src/client/auth.js'); @@ -13,20 +13,10 @@ vi.mock('../../src/client/auth.js', async () => { }; }); -vi.mock('../../src/client/xaaUtil.js', async () => { - const actual = await vi.importActual('../../src/client/xaaUtil.js'); - return { - ...actual, - getAccessToken: vi.fn() - }; -}); - import { auth, extractWWWAuthenticateParams } from '../../src/client/auth.js'; -import { getAccessToken } from '../../src/client/xaaUtil.js'; const mockAuth = auth as MockedFunction; const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as MockedFunction; -const mockGetAccessToken = getAccessToken as MockedFunction; describe('withOAuth', () => { let mockProvider: Mocked; @@ -625,49 +615,6 @@ 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 deleted file mode 100644 index 38495018e..000000000 --- a/packages/client/test/client/xaa-util.test.ts +++ /dev/null @@ -1,994 +0,0 @@ -import { getAccessToken, type XAAOptions } from '../../src/client/xaaUtil.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-workspace.yaml b/pnpm-workspace.yaml index 5f8b0ccb0..dd1db2a5b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,7 +34,6 @@ catalogs: eventsource: ^3.0.2 eventsource-parser: ^3.0.0 jose: ^6.1.1 - qs: ^6.13.0 runtimeServerOnly: '@hono/node-server': ^1.19.8 content-type: ^1.0.5 From 8019baa976e4d7b814be4abae1faea4d5b002818 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:15:24 +0000 Subject: [PATCH 09/13] DRY up crossAppAccess options: extend RequestJwtAuthGrantOptions via Omit --- packages/client/src/client/crossAppAccess.ts | 34 ++++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 357e0721c..752061408 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -82,26 +82,14 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO /** * Options for discovering and requesting a JWT Authorization Grant. + * Extends {@link RequestJwtAuthGrantOptions} but replaces `tokenEndpoint` + * with `idpUrl` / `idpTokenEndpoint` for automatic discovery. */ -export interface DiscoverAndRequestJwtAuthGrantOptions { +export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit { /** Identity Provider's base URL for OAuth/OIDC discovery. */ idpUrl: string; /** IDP token endpoint URL. When provided, skips IDP metadata discovery. */ idpTokenEndpoint?: string; - /** The MCP authorization server URL (used as the `audience` parameter). */ - audience: string; - /** The MCP resource server URL (used as the `resource` parameter). */ - resource: string; - /** The OIDC ID token to exchange. */ - idToken: string; - /** Client ID for authentication with the IDP. */ - clientId: string; - /** Client secret for authentication with the IDP. */ - clientSecret?: string; - /** Optional scopes to request. */ - scope?: string; - /** Optional fetch function for HTTP requests. */ - fetchFn?: FetchLike; } /** @@ -109,11 +97,13 @@ export interface DiscoverAndRequestJwtAuthGrantOptions { * Convenience wrapper over {@link requestJwtAuthorizationGrant}. */ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { - let tokenEndpoint = options.idpTokenEndpoint; + const { idpUrl, idpTokenEndpoint, ...rest } = options; + + let tokenEndpoint = idpTokenEndpoint; if (!tokenEndpoint) { try { - const idpMetadata = await discoverAuthorizationServerMetadata(options.idpUrl, { fetchFn: options.fetchFn }); + const idpMetadata = await discoverAuthorizationServerMetadata(idpUrl, { fetchFn: options.fetchFn }); tokenEndpoint = idpMetadata?.token_endpoint; } catch { // Discovery failed — fall back to idpUrl @@ -121,13 +111,7 @@ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequest } return requestJwtAuthorizationGrant({ - tokenEndpoint: tokenEndpoint ?? options.idpUrl, - audience: options.audience, - resource: options.resource, - idToken: options.idToken, - clientId: options.clientId, - clientSecret: options.clientSecret, - scope: options.scope, - fetchFn: options.fetchFn + ...rest, + tokenEndpoint: tokenEndpoint ?? idpUrl }); } From 56ae9fadfc758738f9a88eefa57b328133618641 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:18:13 +0000 Subject: [PATCH 10/13] Extract NonInteractiveOAuthProvider base class to DRY up M2M providers --- packages/client/src/client/authExtensions.ts | 307 +++++++------------ 1 file changed, 107 insertions(+), 200 deletions(-) diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index e53a80e62..cc6500f86 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -10,6 +10,62 @@ import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; +/** + * Abstract base class for non-interactive (machine-to-machine) OAuthClientProvider + * implementations. Provides the common boilerplate shared by all M2M providers: + * token/client-info storage, no-op PKCE/redirect methods, and a simple + * `prepareTokenRequest` override point. + * + * Subclasses only need to set up `_clientInfo` / `_clientMetadata` in the + * constructor and optionally override `prepareTokenRequest`. + */ +export abstract class NonInteractiveOAuthProvider implements OAuthClientProvider { + protected _tokens?: OAuthTokens; + protected _clientInfo: OAuthClientInformation; + protected _clientMetadata: OAuthClientMetadata; + + constructor(clientInfo: OAuthClientInformation, clientMetadata: OAuthClientMetadata) { + this._clientInfo = clientInfo; + this._clientMetadata = clientMetadata; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for non-interactive flows'); + } + + saveCodeVerifier(): void { + // Not used for non-interactive flows + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for non-interactive flows'); + } +} + /** * Helper to produce a private_key_jwt client authentication function. * @@ -123,58 +179,20 @@ export interface ClientCredentialsProviderOptions { * authProvider: provider * }); */ -export class ClientCredentialsProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; - +export class ClientCredentialsProvider extends NonInteractiveOAuthProvider { constructor(options: ClientCredentialsProviderOptions) { - this._clientInfo = { - client_id: options.clientId, - client_secret: options.clientSecret - }; - this._clientMetadata = { - client_name: options.clientName ?? 'client-credentials-client', - redirect_uris: [], - grant_types: ['client_credentials'], - token_endpoint_auth_method: 'client_secret_basic' - }; - } - - get redirectUrl(): undefined { - return undefined; - } - - get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; - } - - clientInformation(): OAuthClientInformation { - return this._clientInfo; - } - - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } - - tokens(): OAuthTokens | undefined { - return this._tokens; - } - - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; - } - - redirectToAuthorization(): void { - throw new Error('redirectToAuthorization is not used for client_credentials flow'); - } - - saveCodeVerifier(): void { - // Not used for client_credentials - } - - codeVerifier(): string { - throw new Error('codeVerifier is not used for client_credentials flow'); + super( + { + client_id: options.clientId, + client_secret: options.clientSecret + }, + { + client_name: options.clientName ?? 'client-credentials-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_basic' + } + ); } prepareTokenRequest(scope?: string): URLSearchParams { @@ -232,22 +250,21 @@ export interface PrivateKeyJwtProviderOptions { * authProvider: provider * }); */ -export class PrivateKeyJwtProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; +export class PrivateKeyJwtProvider extends NonInteractiveOAuthProvider { addClientAuthentication: AddClientAuthentication; constructor(options: PrivateKeyJwtProviderOptions) { - this._clientInfo = { - client_id: options.clientId - }; - this._clientMetadata = { - client_name: options.clientName ?? 'private-key-jwt-client', - redirect_uris: [], - grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' - }; + super( + { + client_id: options.clientId + }, + { + client_name: options.clientName ?? 'private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt' + } + ); this.addClientAuthentication = createPrivateKeyJwtAuth({ issuer: options.clientId, subject: options.clientId, @@ -257,42 +274,6 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { }); } - get redirectUrl(): undefined { - return undefined; - } - - get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; - } - - clientInformation(): OAuthClientInformation { - return this._clientInfo; - } - - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } - - tokens(): OAuthTokens | undefined { - return this._tokens; - } - - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; - } - - redirectToAuthorization(): void { - throw new Error('redirectToAuthorization is not used for client_credentials flow'); - } - - saveCodeVerifier(): void { - // Not used for client_credentials - } - - codeVerifier(): string { - throw new Error('codeVerifier is not used for client_credentials flow'); - } - prepareTokenRequest(scope?: string): URLSearchParams { const params = new URLSearchParams({ grant_type: 'client_credentials' }); if (scope) params.set('scope', scope); @@ -330,22 +311,21 @@ export interface StaticPrivateKeyJwtProviderOptions { * signing a JWT on each request, it accepts a pre-built JWT assertion string and * uses it directly for authentication. */ -export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; +export class StaticPrivateKeyJwtProvider extends NonInteractiveOAuthProvider { addClientAuthentication: AddClientAuthentication; constructor(options: StaticPrivateKeyJwtProviderOptions) { - this._clientInfo = { - client_id: options.clientId - }; - this._clientMetadata = { - client_name: options.clientName ?? 'static-private-key-jwt-client', - redirect_uris: [], - grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' - }; + super( + { + client_id: options.clientId + }, + { + client_name: options.clientName ?? 'static-private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt' + } + ); const assertion = options.jwtBearerAssertion; this.addClientAuthentication = async (_headers, params) => { @@ -354,42 +334,6 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { }; } - get redirectUrl(): undefined { - return undefined; - } - - get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; - } - - clientInformation(): OAuthClientInformation { - return this._clientInfo; - } - - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } - - tokens(): OAuthTokens | undefined { - return this._tokens; - } - - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; - } - - redirectToAuthorization(): void { - throw new Error('redirectToAuthorization is not used for client_credentials flow'); - } - - saveCodeVerifier(): void { - // Not used for client_credentials - } - - codeVerifier(): string { - throw new Error('codeVerifier is not used for client_credentials flow'); - } - prepareTokenRequest(scope?: string): URLSearchParams { const params = new URLSearchParams({ grant_type: 'client_credentials' }); if (scope) params.set('scope', scope); @@ -478,63 +422,26 @@ export interface CrossAppAccessProviderOptions { * }); * ``` */ -export class CrossAppAccessProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; +export class CrossAppAccessProvider extends NonInteractiveOAuthProvider { private _options: CrossAppAccessProviderOptions; private _authServerUrl?: string | URL; private _resourceUrl?: URL; constructor(options: CrossAppAccessProviderOptions) { + super( + { + client_id: options.clientId, + client_secret: options.clientSecret + }, + { + client_name: options.clientName ?? 'cross-app-access-client', + redirect_uris: [], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_method: options.clientSecret ? 'client_secret_basic' : 'none', + scope: options.scope?.join(' ') + } + ); this._options = options; - this._clientInfo = { - client_id: options.clientId, - client_secret: options.clientSecret - }; - this._clientMetadata = { - client_name: options.clientName ?? 'cross-app-access-client', - redirect_uris: [], - grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], - token_endpoint_auth_method: options.clientSecret ? 'client_secret_basic' : 'none', - scope: options.scope?.join(' ') - }; - } - - get redirectUrl(): undefined { - return undefined; - } - - get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; - } - - clientInformation(): OAuthClientInformation { - return this._clientInfo; - } - - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } - - tokens(): OAuthTokens | undefined { - return this._tokens; - } - - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; - } - - redirectToAuthorization(): void { - throw new Error('redirectToAuthorization is not used for cross-app access flow'); - } - - saveCodeVerifier(): void { - // Not used for cross-app access - } - - codeVerifier(): string { - throw new Error('codeVerifier is not used for cross-app access flow'); } saveAuthorizationServerUrl(url: string | URL): void { From d60fa7359647c13b109c112b2a9624ea4b5f7ebb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:31:53 +0000 Subject: [PATCH 11/13] Use Zod schema + parseErrorResponse for token exchange validation Replace hand-rolled typeof checks and generic Error throws with: - JagTokenExchangeResponseSchema (Zod) for response validation - parseErrorResponse() for error responses (typed OAuthError) Consistent with how auth.ts validates all other OAuth responses. --- packages/client/src/client/crossAppAccess.ts | 39 ++++++++++++-------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 752061408..187f1be0e 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -6,8 +6,29 @@ */ import type { FetchLike } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; -import { discoverAuthorizationServerMetadata } from './auth.js'; +import { discoverAuthorizationServerMetadata, parseErrorResponse } from './auth.js'; + +/** + * RFC 8693 Token Exchange response schema for the JAG flow. + * + * Validates the three required fields: + * - `access_token`: the issued JAG + * - `issued_token_type`: must be `urn:ietf:params:oauth:token-type:id-jag` + * - `token_type`: must be `N_A` (case-insensitive per RFC 8693 §2.2.1) + */ +const JagTokenExchangeResponseSchema = z + .object({ + access_token: z.string(), + issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'), + token_type: z + .string() + .refine((v) => v.toLowerCase() === 'n_a', { + message: "Expected token_type 'N_A'" + }) + }) + .strip(); /** * Options for requesting a JWT Authorization Grant from an Identity Provider. @@ -61,22 +82,10 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO }); if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - throw new Error(`JWT Authorization Grant request failed (${response.status}): ${errorText}`); - } - - const data = (await response.json()) as Record; - - if (typeof data.access_token !== 'string' || !data.access_token) { - throw new Error('Token exchange response missing access_token'); - } - if (data.issued_token_type !== 'urn:ietf:params:oauth:token-type:id-jag') { - throw new Error(`Expected issued_token_type 'urn:ietf:params:oauth:token-type:id-jag', got '${data.issued_token_type}'`); - } - if (typeof data.token_type !== 'string' || data.token_type.toLowerCase() !== 'n_a') { - throw new Error(`Expected token_type 'n_a', got '${data.token_type}'`); + throw await parseErrorResponse(response); } + const data = JagTokenExchangeResponseSchema.parse(await response.json()); return data.access_token; } From 1fe18d8b3a60b3c64f6e363e0d0cd8495e723f04 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:35:02 +0000 Subject: [PATCH 12/13] Move JagTokenExchangeResponseSchema to core/shared/auth.ts with other OAuth schemas --- packages/client/src/client/crossAppAccess.ts | 22 +------------------- packages/core/src/shared/auth.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 187f1be0e..b9accfe78 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -6,30 +6,10 @@ */ import type { FetchLike } from '@modelcontextprotocol/core'; -import * as z from 'zod/v4'; +import { JagTokenExchangeResponseSchema } from '@modelcontextprotocol/core'; import { discoverAuthorizationServerMetadata, parseErrorResponse } from './auth.js'; -/** - * RFC 8693 Token Exchange response schema for the JAG flow. - * - * Validates the three required fields: - * - `access_token`: the issued JAG - * - `issued_token_type`: must be `urn:ietf:params:oauth:token-type:id-jag` - * - `token_type`: must be `N_A` (case-insensitive per RFC 8693 §2.2.1) - */ -const JagTokenExchangeResponseSchema = z - .object({ - access_token: z.string(), - issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'), - token_type: z - .string() - .refine((v) => v.toLowerCase() === 'n_a', { - message: "Expected token_type 'N_A'" - }) - }) - .strip(); - /** * Options for requesting a JWT Authorization Grant from an Identity Provider. */ diff --git a/packages/core/src/shared/auth.ts b/packages/core/src/shared/auth.ts index f1268f3bc..70537c469 100644 --- a/packages/core/src/shared/auth.ts +++ b/packages/core/src/shared/auth.ts @@ -138,6 +138,26 @@ export const OAuthTokensSchema = z }) .strip(); +/** + * RFC 8693 Token Exchange response for the JAG (JWT Authorization Grant) flow. + * + * Validates the three required fields: + * - `access_token`: the issued JAG + * - `issued_token_type`: must be `urn:ietf:params:oauth:token-type:id-jag` + * - `token_type`: must be `N_A` (case-insensitive per RFC 8693 §2.2.1) + */ +export const JagTokenExchangeResponseSchema = z + .object({ + access_token: z.string(), + issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'), + token_type: z + .string() + .refine((v) => v.toLowerCase() === 'n_a', { + message: "Expected token_type 'N_A'" + }) + }) + .strip(); + /** * OAuth 2.1 error response */ @@ -219,6 +239,7 @@ export type OpenIdProviderMetadata = z.infer; export type OAuthTokens = z.infer; +export type JagTokenExchangeResponse = z.infer; export type OAuthErrorResponse = z.infer; export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; From 2f95627c5d5352de4b74a41490aa4340633ad437 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:35:51 +0000 Subject: [PATCH 13/13] Remove silent idpUrl fallback: fail explicitly if discovery yields no token_endpoint --- packages/client/src/client/crossAppAccess.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index b9accfe78..9fcc90e5b 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -91,16 +91,16 @@ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequest let tokenEndpoint = idpTokenEndpoint; if (!tokenEndpoint) { - try { - const idpMetadata = await discoverAuthorizationServerMetadata(idpUrl, { fetchFn: options.fetchFn }); - tokenEndpoint = idpMetadata?.token_endpoint; - } catch { - // Discovery failed — fall back to idpUrl + const idpMetadata = await discoverAuthorizationServerMetadata(idpUrl, { fetchFn: options.fetchFn }); + tokenEndpoint = idpMetadata?.token_endpoint; + + if (!tokenEndpoint) { + throw new Error(`IDP metadata discovery for ${idpUrl} did not return a token_endpoint`); } } return requestJwtAuthorizationGrant({ ...rest, - tokenEndpoint: tokenEndpoint ?? idpUrl + tokenEndpoint }); }