Implement SEP-990 Enterprise Managed OAuth (#1593)
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
54051729c6
commit
ccb78f2043
11 changed files with 1518 additions and 8 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
303
packages/client/src/client/crossAppAccess.ts
Normal file
303
packages/client/src/client/crossAppAccess.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
428
packages/client/test/client/crossAppAccess.test.ts
Normal file
428
packages/client/test/client/crossAppAccess.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue