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
4 changed files with 1518 additions and 8 deletions

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';