Implement SEP-990 Enterprise Managed OAuth (#1593)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
sagar-okta 2026-03-15 19:48:14 +05:30 • committed by GitHub
commit ccb78f2043
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1518 additions and 8 deletions

View file

@ -19,6 +19,8 @@ import {
Client,
ClientCredentialsProvider,
createMiddleware,
CrossAppAccessProvider,
discoverAndRequestJwtAuthGrant,
PrivateKeyJwtProvider,
ProtocolError,
SdkError,
@ -152,6 +154,51 @@ For user-facing applications, implement the {@linkcode @modelcontextprotocol/cli
For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts).
### Cross-App Access (Enterprise Managed Authorization)
{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf.
This provider handles a two-step OAuth flow:
1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange
2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant
```ts source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess"
const authProvider = new CrossAppAccessProvider({
assertion: async ctx => {
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl,
resource: ctx.resourceUrl,
idToken: await getIdToken(),
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return result.jwtAuthGrant;
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret'
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
```
The `assertion` callback receives a context object with:
- `authorizationServerUrl` – The MCP server's authorization server (discovered automatically)
- `resourceUrl` – The MCP resource URL (discovered automatically)
- `scope` – Optional scope passed to `auth()` or from `clientMetadata`
- `fetchFn` – Fetch implementation to use for HTTP requests
For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`:
- `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP
- `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition
- `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server
> [!NOTE]
> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth standards.
## Tools
Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview).

View file

@ -14,6 +14,8 @@ import {
Client,
ClientCredentialsProvider,
createMiddleware,
CrossAppAccessProvider,
discoverAndRequestJwtAuthGrant,
PrivateKeyJwtProvider,
ProtocolError,
SdkError,
@ -135,6 +137,33 @@ async function auth_privateKeyJwt(pemEncodedKey: string) {
return transport;
}
/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */
async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
//#region auth_crossAppAccess
const authProvider = new CrossAppAccessProvider({
assertion: async ctx => {
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl,
resource: ctx.resourceUrl,
idToken: await getIdToken(),
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return result.jwtAuthGrant;
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret'
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
//#endregion auth_crossAppAccess
return transport;
}
// ---------------------------------------------------------------------------
// Using server features
// ---------------------------------------------------------------------------
@ -513,6 +542,7 @@ void disconnect_streamableHttp;
void serverInstructions_basic;
void auth_clientCredentials;
void auth_privateKeyJwt;
void auth_crossAppAccess;
void callTool_basic;
void callTool_structuredOutput;
void callTool_progress;

View file

@ -184,6 +184,50 @@ export interface OAuthClientProvider {
*/
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;
/**
* Saves the authorization server URL after RFC 9728 discovery.
* This method is called by {@linkcode auth} after successful discovery of the
* authorization server via protected resource metadata.
*
* Providers implementing Cross-App Access or other flows that need access to
* the discovered authorization server URL should implement this method.
*
* @param authorizationServerUrl - The authorization server URL discovered via RFC 9728
*/
saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise<void>;
/**
* Returns the previously saved authorization server URL, if available.
*
* Providers implementing Cross-App Access can use this to access the
* authorization server URL discovered during the OAuth flow.
*
* @returns The authorization server URL, or `undefined` if not available
*/
authorizationServerUrl?(): string | undefined | Promise<string | undefined>;
/**
* Saves the resource URL after RFC 9728 discovery.
* This method is called by {@linkcode auth} after successful discovery of the
* resource metadata.
*
* Providers implementing Cross-App Access or other flows that need access to
* the discovered resource URL should implement this method.
*
* @param resourceUrl - The resource URL discovered via RFC 9728
*/
saveResourceUrl?(resourceUrl: string): void | Promise<void>;
/**
* Returns the previously saved resource URL, if available.
*
* Providers implementing Cross-App Access can use this to access the
* resource URL discovered during the OAuth flow.
*
* @returns The resource URL, or `undefined` if not available
*/
resourceUrl?(): string | undefined | Promise<string | undefined>;
/**
* Saves the OAuth discovery state after RFC 9728 and authorization server metadata
* discovery. Providers can persist this state to avoid redundant discovery requests
@ -307,7 +351,7 @@ export function selectClientAuthMethod(clientInformation: OAuthClientInformation
* @param params - URL search parameters to modify
* @throws {Error} When required credentials are missing
*/
function applyClientAuthentication(
export function applyClientAuthentication(
method: ClientAuthMethod,
clientInformation: OAuthClientInformation,
headers: Headers,
@ -501,8 +545,16 @@ async function authInternal(
});
}
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
if (resource) {
await provider.saveResourceUrl?.(String(resource));
}
// Apply scope selection strategy (SEP-835):
// 1. WWW-Authenticate scope (passed via `scope` param)
// 2. PRM scopes_supported
@ -562,6 +614,7 @@ async function authInternal(
metadata,
resource,
authorizationCode,
scope,
fetchFn
});
@ -1401,21 +1454,25 @@ export async function fetchToken(
metadata,
resource,
authorizationCode,
scope,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
resource?: URL;
/** Authorization code for the default `authorization_code` grant flow */
authorizationCode?: string;
/** Optional scope parameter from auth() options */
scope?: string;
fetchFn?: FetchLike;
} = {}
): Promise<OAuthTokens> {
const scope = provider.clientMetadata.scope;
// Prefer scope from options, fallback to provider.clientMetadata.scope
const effectiveScope = scope ?? provider.clientMetadata.scope;
// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
let tokenRequestParams: URLSearchParams | undefined;
if (provider.prepareTokenRequest) {
tokenRequestParams = await provider.prepareTokenRequest(scope);
tokenRequestParams = await provider.prepareTokenRequest(effectiveScope);
}
// Default to authorization_code grant if no custom prepareTokenRequest

View file

@ -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';
@ -408,3 +408,266 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
return params;
}
}
/**
* Context provided to the assertion callback in {@linkcode CrossAppAccessProvider}.
* Contains orchestrator-discovered information needed for JWT Authorization Grant requests.
*/
export interface CrossAppAccessContext {
/**
* The authorization server URL of the target MCP server.
* Discovered via RFC 9728 protected resource metadata.
*/
authorizationServerUrl: string;
/**
* The resource URL of the target MCP server.
* Discovered via RFC 9728 protected resource metadata.
*/
resourceUrl: string;
/**
* Optional scope being requested for the MCP server.
*/
scope?: string;
/**
* Fetch function to use for HTTP requests (e.g., for IdP token exchange).
*/
fetchFn: FetchLike;
}
/**
* Callback function type that provides a JWT Authorization Grant (ID-JAG).
*
* The callback receives context about the target MCP server (authorization server URL,
* resource URL, scope) and should return a JWT Authorization Grant that will be used
* to obtain an access token from the MCP server.
*/
export type AssertionCallback = (context: CrossAppAccessContext) => string | Promise<string>;
/**
* Options for creating a {@linkcode CrossAppAccessProvider}.
*/
export interface CrossAppAccessProviderOptions {
/**
* Callback function that provides a JWT Authorization Grant (ID-JAG).
*
* The callback receives the MCP server's authorization server URL, resource URL,
* and requested scope, and should return a JWT Authorization Grant obtained from
* the enterprise IdP via RFC 8693 token exchange.
*
* You can use the utility functions from the `crossAppAccess` module
* for standard flows, or implement custom logic.
*
* @example
* ```ts
* assertion: async (ctx) => {
* const result = await discoverAndRequestJwtAuthGrant({
* idpUrl: 'https://idp.example.com',
* audience: ctx.authorizationServerUrl,
* resource: ctx.resourceUrl,
* idToken: await getIdToken(),
* clientId: 'my-idp-client',
* clientSecret: 'my-idp-secret',
* scope: ctx.scope,
* fetchFn: ctx.fetchFn
* });
* return result.jwtAuthGrant;
* }
* ```
*/
assertion: AssertionCallback;
/**
* The `client_id` registered with the MCP server's authorization server.
*/
clientId: string;
/**
* The `client_secret` for authenticating with the MCP server's authorization server.
*/
clientSecret: string;
/**
* Optional client name for metadata.
*/
clientName?: string;
/**
* Custom fetch implementation. Defaults to global fetch.
*/
fetchFn?: FetchLike;
}
/**
* OAuth provider for Cross-App Access (Enterprise Managed Authorization) using JWT Authorization Grant.
*
* This provider implements the Enterprise Managed Authorization flow (SEP-990) where:
* 1. User authenticates with an enterprise IdP and the client obtains an ID Token
* 2. Client exchanges the ID Token for a JWT Authorization Grant (ID-JAG) via RFC 8693 token exchange
* 3. Client uses the JAG to obtain an access token from the MCP server via RFC 7523 JWT bearer grant
*
* The provider handles steps 2-3 automatically, with the JAG acquisition delegated to
* a callback function that you provide. This allows flexibility in how you obtain and
* cache ID Tokens from the IdP.
*
* @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx
*
* @example
* ```ts
* const provider = new CrossAppAccessProvider({
* assertion: async (ctx) => {
* const result = await discoverAndRequestJwtAuthGrant({
* idpUrl: 'https://idp.example.com',
* audience: ctx.authorizationServerUrl,
* resource: ctx.resourceUrl,
* idToken: await getIdToken(), // Your function to get ID token
* clientId: 'my-idp-client',
* clientSecret: 'my-idp-secret',
* scope: ctx.scope,
* fetchFn: ctx.fetchFn
* });
* return result.jwtAuthGrant;
* },
* clientId: 'my-mcp-client',
* clientSecret: 'my-mcp-secret'
* });
*
* const transport = new StreamableHTTPClientTransport(serverUrl, {
* authProvider: provider
* });
* ```
*/
export class CrossAppAccessProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
private _assertionCallback: AssertionCallback;
private _fetchFn: FetchLike;
private _authorizationServerUrl?: string;
private _resourceUrl?: string;
private _scope?: string;
constructor(options: CrossAppAccessProviderOptions) {
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: 'client_secret_basic'
};
this._assertionCallback = options.assertion;
this._fetchFn = options.fetchFn ?? fetch;
}
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 jwt-bearer flow');
}
saveCodeVerifier(): void {
// Not used for jwt-bearer
}
codeVerifier(): string {
throw new Error('codeVerifier is not used for jwt-bearer flow');
}
/**
* Saves the authorization server URL discovered during OAuth flow.
* This is called by the auth() function after RFC 9728 discovery.
*/
saveAuthorizationServerUrl?(authorizationServerUrl: string): void {
this._authorizationServerUrl = authorizationServerUrl;
}
/**
* Returns the cached authorization server URL if available.
*/
authorizationServerUrl?(): string | undefined {
return this._authorizationServerUrl;
}
/**
* Saves the resource URL discovered during OAuth flow.
* This is called by the auth() function after RFC 9728 discovery.
*/
saveResourceUrl?(resourceUrl: string): void {
this._resourceUrl = resourceUrl;
}
/**
* Returns the cached resource URL if available.
*/
resourceUrl?(): string | undefined {
return this._resourceUrl;
}
async prepareTokenRequest(scope?: string): Promise<URLSearchParams> {
// Get the authorization server URL and resource URL from cached state
const authServerUrl = this._authorizationServerUrl;
const resourceUrl = this._resourceUrl;
if (!authServerUrl) {
throw new Error('Authorization server URL not available. Ensure auth() has been called first.');
}
if (!resourceUrl) {
throw new Error(
'Resource URL not available — server may not implement RFC 9728 ' +
'Protected Resource Metadata (required for Cross-App Access), or ' +
'auth() has not been called'
);
}
// Store scope for assertion callback
this._scope = scope;
// Call the assertion callback to get the JWT Authorization Grant
const jwtAuthGrant = await this._assertionCallback({
authorizationServerUrl: authServerUrl,
resourceUrl: resourceUrl,
scope: this._scope,
fetchFn: this._fetchFn
});
// Return params for JWT bearer grant per RFC 7523
const params = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwtAuthGrant
});
if (scope) {
params.set('scope', scope);
}
return params;
}
}

View file

@ -0,0 +1,303 @@
/**
* Cross-App Access (Enterprise Managed Authorization) Layer 2 utilities.
*
* Provides standalone functions for RFC 8693 Token Exchange and RFC 7523 JWT Authorization Grant
* flows as specified in the Enterprise Managed Authorization specification (SEP-990).
*
* @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx
* @module
*/
import type { FetchLike } from '@modelcontextprotocol/core';
import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core';
import type { ClientAuthMethod } from './auth.js';
import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth.js';
/**
* Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange.
*/
export interface RequestJwtAuthGrantOptions {
/**
* The IdP's token endpoint URL where the token exchange request will be sent.
*/
tokenEndpoint: string | URL;
/**
* The authorization server URL of the target MCP server (used as `audience` in the token exchange request).
*/
audience: string | URL;
/**
* The resource identifier of the target MCP server (RFC 9728).
*/
resource: string | URL;
/**
* The identity assertion (ID Token) from the enterprise IdP.
* This should be the OpenID Connect ID Token obtained during user authentication.
*/
idToken: string;
/**
* The client ID registered with the IdP for token exchange.
*/
clientId: string;
/**
* The client secret for authenticating with the IdP.
*
* Optional: the IdP may register the MCP client as a public client. RFC 8693 does
* not mandate confidential clients for token exchange. Omitting this parameter
* omits `client_secret` from the request body.
*/
clientSecret?: string;
/**
* Optional space-separated list of scopes to request for the target MCP server.
*/
scope?: string;
/**
* Custom fetch implementation. Defaults to global fetch.
*/
fetchFn?: FetchLike;
}
/**
* Options for discovering the IdP's token endpoint and requesting a JWT Authorization Grant.
* Extends {@linkcode RequestJwtAuthGrantOptions} with IdP discovery.
*/
export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit<RequestJwtAuthGrantOptions, 'tokenEndpoint'> {
/**
* The IdP's issuer URL for OAuth metadata discovery.
* Will be used to discover the token endpoint via `.well-known/oauth-authorization-server`.
*/
idpUrl: string | URL;
}
/**
* Result from a successful JWT Authorization Grant token exchange.
*/
export interface JwtAuthGrantResult {
/**
* The JWT Authorization Grant (ID-JAG) that can be used to request an access token from the MCP server.
*/
jwtAuthGrant: string;
/**
* Optional expiration time in seconds for the JWT Authorization Grant.
*/
expiresIn?: number;
/**
* Optional scope granted by the IdP (may differ from requested scope).
*/
scope?: string;
}
/**
* Requests a JWT Authorization Grant (ID-JAG) from an enterprise IdP using RFC 8693 Token Exchange.
*
* This function performs step 2 of the Enterprise Managed Authorization flow:
* exchanges an ID Token for a JWT Authorization Grant that can be used with the target MCP server.
*
* @param options - Configuration for the token exchange request
* @returns The JWT Authorization Grant and related metadata
* @throws {Error} If the token exchange fails or returns an error response
*
* @example
* ```ts
* const result = await requestJwtAuthorizationGrant({
* tokenEndpoint: 'https://idp.example.com/token',
* audience: 'https://auth.chat.example/',
* resource: 'https://mcp.chat.example/',
* idToken: 'eyJhbGciOiJS...',
* clientId: 'my-idp-client',
* clientSecret: 'my-idp-secret',
* scope: 'chat.read chat.history'
* });
*
* // Use result.jwtAuthGrant with the MCP server's authorization server
* ```
*/
export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise<JwtAuthGrantResult> {
const { tokenEndpoint, audience, resource, idToken, clientId, clientSecret, scope, fetchFn = fetch } = options;
// Prepare token exchange request per RFC 8693
const params = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
audience: String(audience),
resource: String(resource),
subject_token: idToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
client_id: clientId
});
// Only include client_secret when provided — sending an empty/undefined secret
// triggers `invalid_client` on strict IdPs that registered this as a public client.
if (clientSecret) {
params.set('client_secret', clientSecret);
}
if (scope) {
params.set('scope', scope);
}
const response = await fetchFn(String(tokenEndpoint), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
// Try to parse as OAuth error response
const parseResult = OAuthErrorResponseSchema.safeParse(errorBody);
if (parseResult.success) {
const { error, error_description } = parseResult.data;
throw new Error(`Token exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`);
}
throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`);
}
const parseResult = IdJagTokenExchangeResponseSchema.safeParse(await response.json());
if (!parseResult.success) {
throw new Error(`Invalid token exchange response: ${parseResult.error.message}`);
}
return {
jwtAuthGrant: parseResult.data.access_token,
expiresIn: parseResult.data.expires_in,
scope: parseResult.data.scope
};
}
/**
* Discovers the IdP's token endpoint and requests a JWT Authorization Grant.
*
* This is a convenience wrapper around {@linkcode requestJwtAuthorizationGrant} that
* first performs OAuth metadata discovery to find the token endpoint.
*
* @param options - Configuration including IdP URL for discovery
* @returns The JWT Authorization Grant and related metadata
* @throws {Error} If discovery fails or the token exchange fails
*
* @example
* ```ts
* const result = await discoverAndRequestJwtAuthGrant({
* idpUrl: 'https://idp.example.com',
* audience: 'https://auth.chat.example/',
* resource: 'https://mcp.chat.example/',
* idToken: await getIdToken(),
* clientId: 'my-idp-client',
* clientSecret: 'my-idp-secret'
* });
* ```
*/
export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise<JwtAuthGrantResult> {
const { idpUrl, fetchFn = fetch, ...restOptions } = options;
// Discover IdP's authorization server metadata
const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn });
if (!metadata?.token_endpoint) {
throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`);
}
// Perform token exchange
return requestJwtAuthorizationGrant({
...restOptions,
tokenEndpoint: metadata.token_endpoint,
fetchFn
});
}
/**
* Exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server.
*
* This function performs step 3 of the Enterprise Managed Authorization flow:
* uses the JWT Authorization Grant to obtain an access token from the MCP server.
*
* @param options - Configuration for the JWT grant exchange
* @returns OAuth tokens (access token, token type, etc.)
* @throws {Error} If the exchange fails or returns an error response
*
* Defaults to `client_secret_basic` (HTTP Basic Authorization header), matching
* `CrossAppAccessProvider`'s declared `token_endpoint_auth_method` and the
* SEP-990 conformance test requirements. Use `authMethod: 'client_secret_post'` only
* when the authorization server explicitly requires it.
*
* @example
* ```ts
* const tokens = await exchangeJwtAuthGrant({
* tokenEndpoint: 'https://auth.chat.example/token',
* jwtAuthGrant: 'eyJhbGci...',
* clientId: 'my-mcp-client',
* clientSecret: 'my-mcp-secret'
* });
*
* // Use tokens.access_token to access the MCP server
* ```
*/
export async function exchangeJwtAuthGrant(options: {
tokenEndpoint: string | URL;
jwtAuthGrant: string;
clientId: string;
clientSecret?: string;
/**
* Client authentication method. Defaults to `'client_secret_basic'` to align with
* `CrossAppAccessProvider` and SEP-990 conformance requirements.
* Callers with no `clientSecret` should pass `'none'` for public-client auth.
*/
authMethod?: ClientAuthMethod;
fetchFn?: FetchLike;
}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> {
const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options;
// Prepare JWT bearer grant request per RFC 7523
const params = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwtAuthGrant
});
const headers = new Headers({
'Content-Type': 'application/x-www-form-urlencoded'
});
applyClientAuthentication(authMethod, { client_id: clientId, client_secret: clientSecret }, headers, params);
const response = await fetchFn(String(tokenEndpoint), {
method: 'POST',
headers,
body: params.toString()
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
// Try to parse as OAuth error response
const parseResult = OAuthErrorResponseSchema.safeParse(errorBody);
if (parseResult.success) {
const { error, error_description } = parseResult.data;
throw new Error(`JWT grant exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`);
}
throw new Error(`JWT grant exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`);
}
const responseBody = await response.json();
// Validate response using core schema
const parseResult = OAuthTokensSchema.safeParse(responseBody);
if (!parseResult.success) {
throw new Error(`Invalid token response: ${parseResult.error.message}`);
}
return parseResult.data;
}

View file

@ -1,6 +1,7 @@
export * from './client/auth.js';
export * from './client/authExtensions.js';
export * from './client/client.js';
export * from './client/crossAppAccess.js';
export * from './client/middleware.js';
export * from './client/sse.js';
export * from './client/stdio.js';

View file

@ -1,10 +1,11 @@
import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { auth } from '../../src/client/auth.js';
import {
ClientCredentialsProvider,
createPrivateKeyJwtAuth,
CrossAppAccessProvider,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider
} from '../../src/client/authExtensions.js';
@ -330,3 +331,301 @@ describe('createPrivateKeyJwtAuth', () => {
);
});
});
describe('CrossAppAccessProvider', () => {
const RESOURCE_SERVER_URL = 'https://mcp.chat.example/';
const AUTH_SERVER_URL = 'https://auth.chat.example';
const IDP_URL = 'https://idp.example.com';
it('successfully authenticates using Cross-App Access flow', async () => {
let assertionCallbackInvoked = false;
let jwtGrantUsed = '';
const provider = new CrossAppAccessProvider({
assertion: async ctx => {
assertionCallbackInvoked = true;
expect(ctx.authorizationServerUrl).toBe(AUTH_SERVER_URL);
expect(ctx.resourceUrl).toBe(RESOURCE_SERVER_URL);
expect(ctx.scope).toBeUndefined();
expect(ctx.fetchFn).toBeDefined();
return 'jwt-authorization-grant-token';
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
clientName: 'xaa-test-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer');
jwtGrantUsed = params.get('assertion') || '';
expect(jwtGrantUsed).toBe('jwt-authorization-grant-token');
// Verify client authentication
const headers = new Headers(init?.headers);
const authHeader = headers.get('Authorization');
expect(authHeader).toBeTruthy();
const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64');
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
expect(assertionCallbackInvoked).toBe(true);
expect(jwtGrantUsed).toBe('jwt-authorization-grant-token');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
it('passes scope to assertion callback', async () => {
let capturedScope: string | undefined;
const provider = new CrossAppAccessProvider({
assertion: async ctx => {
capturedScope = ctx.scope;
return 'jwt-grant';
},
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
scope: 'chat.read chat.history',
fetchFn: fetchMock
});
expect(capturedScope).toBe('chat.read chat.history');
});
it('passes custom fetchFn to assertion callback', async () => {
let capturedFetchFn: unknown;
const customFetch = vi.fn(fetch);
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
// Wrap the mock to track calls
const wrappedFetch = vi.fn((...args: Parameters<typeof fetchMock>) => fetchMock(...args));
const provider = new CrossAppAccessProvider({
assertion: async ctx => {
capturedFetchFn = ctx.fetchFn;
return 'jwt-grant';
},
clientId: 'client',
clientSecret: 'secret',
fetchFn: customFetch
});
await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: wrappedFetch
});
// The assertion callback should receive the custom fetch function
expect(capturedFetchFn).toBe(customFetch);
});
it('throws error when authorization server URL is not available', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
// Try to call prepareTokenRequest without going through auth()
await expect(provider.prepareTokenRequest()).rejects.toThrow(
'Authorization server URL not available. Ensure auth() has been called first.'
);
});
it('throws error when resource URL is not available', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
// Manually set authorization server URL but not resource URL
provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL);
await expect(provider.prepareTokenRequest()).rejects.toThrow(
'Resource URL not available — server may not implement RFC 9728 Protected Resource Metadata'
);
});
it('stores and retrieves authorization server URL', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.authorizationServerUrl?.()).toBeUndefined();
provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL);
expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL);
});
it('stores and retrieves resource URL', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.resourceUrl?.()).toBeUndefined();
provider.saveResourceUrl?.(RESOURCE_SERVER_URL);
expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL);
});
it('has correct client metadata', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret',
clientName: 'custom-xaa-client'
});
const metadata = provider.clientMetadata;
expect(metadata.client_name).toBe('custom-xaa-client');
expect(metadata.redirect_uris).toEqual([]);
expect(metadata.grant_types).toEqual(['urn:ietf:params:oauth:grant-type:jwt-bearer']);
expect(metadata.token_endpoint_auth_method).toBe('client_secret_basic');
});
it('uses default client name when not provided', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.clientMetadata.client_name).toBe('cross-app-access-client');
});
it('returns undefined for redirectUrl (non-interactive flow)', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.redirectUrl).toBeUndefined();
});
it('throws error for redirectToAuthorization (not used in jwt-bearer)', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(() => provider.redirectToAuthorization()).toThrow('redirectToAuthorization is not used for jwt-bearer flow');
});
it('throws error for codeVerifier (not used in jwt-bearer)', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(() => provider.codeVerifier()).toThrow('codeVerifier is not used for jwt-bearer flow');
});
it('handles assertion callback errors gracefully', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => {
throw new Error('Failed to get ID token from IdP');
},
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
await expect(
auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
})
).rejects.toThrow('Failed to get ID token from IdP');
});
it('allows assertion callback to return a promise', async () => {
const provider = new CrossAppAccessProvider({
assertion: ctx => {
return new Promise(resolve => {
setTimeout(() => resolve('async-jwt-grant'), 10);
});
},
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params.get('assertion')).toBe('async-jwt-grant');
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
});
it('includes scope in token request params when provided', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params.get('scope')).toBe('chat.read chat.write');
}
});
await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
scope: 'chat.read chat.write',
fetchFn: fetchMock
});
});
});

View file

@ -0,0 +1,428 @@
import type { FetchLike } from '@modelcontextprotocol/core';
import { describe, expect, it, vi } from 'vitest';
import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js';
describe('crossAppAccess', () => {
describe('requestJwtAuthorizationGrant', () => {
it('successfully exchanges ID token for JWT Authorization Grant', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
token_type: 'N_A',
expires_in: 300,
scope: 'chat.read chat.history'
})
} as Response);
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: 'chat.read chat.history',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
expect(result.expiresIn).toBe(300);
expect(result.scope).toBe('chat.read chat.history');
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://idp.example.com/token');
expect(init?.method).toBe('POST');
expect(init?.headers).toEqual({
'Content-Type': 'application/x-www-form-urlencoded'
});
const body = new URLSearchParams(init?.body as string);
expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange');
expect(body.get('requested_token_type')).toBe('urn:ietf:params:oauth:token-type:id-jag');
expect(body.get('audience')).toBe('https://auth.chat.example/');
expect(body.get('resource')).toBe('https://mcp.chat.example/');
expect(body.get('subject_token')).toBe('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...');
expect(body.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token');
expect(body.get('client_id')).toBe('my-idp-client');
expect(body.get('client_secret')).toBe('my-idp-secret');
expect(body.get('scope')).toBe('chat.read chat.history');
});
it('works without optional scope parameter', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'N_A'
})
} as Response);
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('jag-token');
const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string);
expect(body.get('scope')).toBeNull();
});
it('omits client_secret from body when not provided (public client)', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'N_A'
})
} as Response);
await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'public-client',
fetchFn: mockFetch
});
const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string);
expect(body.get('client_id')).toBe('public-client');
// Must be absent — not empty string, not the literal "undefined"
expect(body.has('client_secret')).toBe(false);
});
it('throws error when issued_token_type is incorrect', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
access_token: 'token',
token_type: 'N_A'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Invalid token exchange response');
});
it('accepts token_type other than N_A (issued_token_type is the real check)', async () => {
// RFC 6749 §5.1: token_type is case-insensitive; RFC 8693 §2.2.1: informational
// when the issued token isn't an access token. Real IdPs return 'n_a', 'Bearer', etc.
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'n_a'
})
} as Response);
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('jag-token');
});
it('throws error when access_token is missing', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
token_type: 'N_A'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Invalid token exchange response');
});
it('handles OAuth error responses', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({
error: 'invalid_grant',
error_description: 'Audience validation failed'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Token exchange failed: invalid_grant - Audience validation failed');
});
it('handles non-OAuth error responses', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ message: 'Internal server error' })
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Token exchange failed with status 500');
});
});
describe('discoverAndRequestJwtAuthGrant', () => {
it('discovers token endpoint and performs token exchange', async () => {
const mockFetch = vi.fn<FetchLike>();
// Mock discovery response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
issuer: 'https://idp.example.com',
authorization_endpoint: 'https://idp.example.com/authorize',
token_endpoint: 'https://idp.example.com/token',
jwks_uri: 'https://idp.example.com/jwks',
response_types_supported: ['code'],
grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange']
})
} as Response);
// Mock token exchange response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'N_A',
expires_in: 300
})
} as Response);
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('jag-token');
expect(result.expiresIn).toBe(300);
expect(mockFetch).toHaveBeenCalledTimes(2);
// First call is discovery
expect(String(mockFetch.mock.calls[0]![0])).toContain('.well-known/oauth-authorization-server');
// Second call is token exchange
expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token');
});
it('throws error when token endpoint is not discovered', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issuer: 'https://idp.example.com',
authorization_endpoint: 'https://idp.example.com/authorize'
// Missing token_endpoint and response_types_supported
})
} as Response);
await expect(
discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow(); // Zod validation error
});
});
describe('exchangeJwtAuthGrant', () => {
it('exchanges JAG for access token using client_secret_basic by default', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'mcp-access-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'chat.read chat.history'
})
} as Response);
const result = await exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
fetchFn: mockFetch
});
expect(result.access_token).toBe('mcp-access-token');
expect(result.token_type).toBe('Bearer');
expect(result.expires_in).toBe(3600);
expect(result.scope).toBe('chat.read chat.history');
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://auth.chat.example/token');
expect(init?.method).toBe('POST');
// SEP-990 conformance: credentials in Authorization header, NOT in body
const headers = new Headers(init?.headers as Headers);
const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64');
expect(headers.get('Authorization')).toBe(`Basic ${expectedCredentials}`);
const body = new URLSearchParams(init?.body as string);
expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer');
expect(body.get('assertion')).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
expect(body.has('client_id')).toBe(false);
expect(body.has('client_secret')).toBe(false);
});
it('supports client_secret_post when explicitly requested', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'mcp-access-token',
token_type: 'Bearer'
})
} as Response);
await exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'jwt',
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
authMethod: 'client_secret_post',
fetchFn: mockFetch
});
const [, init] = mockFetch.mock.calls[0]!;
const headers = new Headers(init?.headers as Headers);
expect(headers.get('Authorization')).toBeNull();
const body = new URLSearchParams(init?.body as string);
expect(body.get('client_id')).toBe('my-mcp-client');
expect(body.get('client_secret')).toBe('my-mcp-secret');
});
it('supports authMethod none for public clients', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'mcp-access-token',
token_type: 'Bearer'
})
} as Response);
await exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'jwt',
clientId: 'my-public-client',
authMethod: 'none',
fetchFn: mockFetch
});
const [, init] = mockFetch.mock.calls[0]!;
const headers = new Headers(init?.headers as Headers);
expect(headers.get('Authorization')).toBeNull();
const body = new URLSearchParams(init?.body as string);
expect(body.get('client_id')).toBe('my-public-client');
expect(body.has('client_secret')).toBe(false);
});
it('handles OAuth error responses', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({
error: 'invalid_grant',
error_description: 'JWT signature verification failed'
})
} as Response);
await expect(
exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'invalid-jwt',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed');
});
it('validates token response with schema', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
// Missing required fields
token_type: 'Bearer'
})
} as Response);
await expect(
exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'jwt',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Invalid token response');
});
});
});

View file

@ -139,6 +139,25 @@ export const OAuthTokensSchema = z
})
.strip();
/**
* RFC 8693 §2.2.1 Token Exchange response for ID-JAG tokens.
*
* `token_type` is intentionally optional: per RFC 8693 §2.2.1 it is informational when
* the issued token is not an access token, and per RFC 6749 §5.1 it is case-insensitive,
* so strict checking rejects conformant IdPs.
*/
export const IdJagTokenExchangeResponseSchema = z
.object({
issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'),
access_token: z.string(),
token_type: z.string().optional(),
expires_in: z.number().optional(),
scope: z.string().optional()
})
.strip();
export type IdJagTokenExchangeResponse = z.infer<typeof IdJagTokenExchangeResponseSchema>;
/**
* OAuth 2.1 error response
*/

View file

@ -1,5 +1,4 @@
# Conformance scenarios not yet implemented in the v2 TypeScript SDK.
# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries.
client:
- auth/cross-app-access-complete-flow
client: []

View file

@ -12,7 +12,14 @@
* consolidating all the individual test clients into one.
*/
import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import {
Client,
ClientCredentialsProvider,
CrossAppAccessProvider,
PrivateKeyJwtProvider,
requestJwtAuthorizationGrant,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';
import * as z from 'zod/v4';
import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider.js';
@ -48,6 +55,15 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [
name: z.literal('auth/pre-registration'),
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()
})
]);
@ -246,6 +262,54 @@ async function runClientCredentialsBasic(serverUrl: string): Promise<void> {
registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
/**
* Cross-App Access (SEP-990 Enterprise Managed Authorization).
*
* Exchanges an IdP-issued ID token for an ID-JAG (RFC 8693 token exchange at the IdP),
* then exchanges the ID-JAG for an access token at the AS (RFC 7523 JWT bearer grant
* with client_secret_basic). The provider drives discovery + the JWT bearer step; the
* assertion callback handles the IdP exchange using the context-supplied ID token.
*/
async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise<void> {
const ctx = parseContext();
if (ctx.name !== 'auth/cross-app-access-complete-flow') {
throw new Error(`Expected cross-app-access context, got ${ctx.name}`);
}
const provider = new CrossAppAccessProvider({
clientId: ctx.client_id,
clientSecret: ctx.client_secret,
assertion: async authCtx => {
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: ctx.idp_token_endpoint,
audience: authCtx.authorizationServerUrl,
resource: authCtx.resourceUrl,
idToken: ctx.idp_id_token,
clientId: ctx.idp_client_id,
fetchFn: authCtx.fetchFn
});
return result.jwtAuthGrant;
}
});
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('Successfully connected with cross-app-access auth');
await client.listTools();
logger.debug('Successfully listed tools');
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccessCompleteFlow);
// ============================================================================
// Pre-registration scenario (no dynamic client registration)
// ============================================================================