Skip to content

Commit 1b1eda6

Browse files
committed
Implement SEP-990 Enterprise Managed OAuth
1 parent c19dfd9 commit 1b1eda6

7 files changed

Lines changed: 1320 additions & 4 deletions

File tree

docs/client.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,61 @@ For user-facing applications, implement the {@linkcode @modelcontextprotocol/cli
153153

154154
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).
155155

156+
### Cross-App Access (Enterprise Managed Authorization)
157+
158+
{@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.
159+
160+
This provider handles a two-step OAuth flow:
161+
1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange
162+
2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant
163+
164+
```ts
165+
import { CrossAppAccessProvider } from '@modelcontextprotocol/client';
166+
import { discoverAndRequestJwtAuthGrant } from '@modelcontextprotocol/client/crossAppAccess';
167+
168+
const authProvider = new CrossAppAccessProvider({
169+
// Callback to obtain JWT Authorization Grant
170+
assertion: async (ctx) => {
171+
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
172+
const result = await discoverAndRequestJwtAuthGrant({
173+
idpUrl: 'https://idp.example.com',
174+
audience: ctx.authorizationServerUrl, // MCP auth server
175+
resource: ctx.resourceUrl, // MCP resource URL
176+
idToken: await getMyIdToken(), // Your ID token acquisition
177+
clientId: 'my-idp-client',
178+
clientSecret: 'my-idp-secret',
179+
scope: ctx.scope,
180+
fetchFn: ctx.fetchFn
181+
});
182+
return result.jwtAuthGrant;
183+
},
184+
185+
// MCP server credentials
186+
clientId: 'my-mcp-client',
187+
clientSecret: 'my-mcp-secret',
188+
clientName: 'my-app' // Optional
189+
});
190+
191+
const transport = new StreamableHTTPClientTransport(
192+
new URL('http://localhost:3000/mcp'),
193+
{ authProvider }
194+
);
195+
```
196+
197+
The `assertion` callback receives a context object with:
198+
- `authorizationServerUrl` – The MCP server's authorization server (discovered automatically)
199+
- `resourceUrl` – The MCP resource URL (discovered automatically)
200+
- `scope` – Optional scope passed to `auth()` or from `clientMetadata`
201+
- `fetchFn` – Fetch implementation to use for HTTP requests
202+
203+
For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client/crossAppAccess`:
204+
- `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP
205+
- `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition
206+
- `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server
207+
208+
> [!NOTE]
209+
> 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.
210+
156211
## Tools
157212

158213
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).

packages/client/src/client/auth.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,50 @@ export interface OAuthClientProvider {
184184
*/
185185
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;
186186

187+
/**
188+
* Saves the authorization server URL after RFC 9728 discovery.
189+
* This method is called by {@linkcode auth} after successful discovery of the
190+
* authorization server via protected resource metadata.
191+
*
192+
* Providers implementing Cross-App Access or other flows that need access to
193+
* the discovered authorization server URL should implement this method.
194+
*
195+
* @param authorizationServerUrl - The authorization server URL discovered via RFC 9728
196+
*/
197+
saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise<void>;
198+
199+
/**
200+
* Returns the previously saved authorization server URL, if available.
201+
*
202+
* Providers implementing Cross-App Access can use this to access the
203+
* authorization server URL discovered during the OAuth flow.
204+
*
205+
* @returns The authorization server URL, or `undefined` if not available
206+
*/
207+
authorizationServerUrl?(): string | undefined | Promise<string | undefined>;
208+
209+
/**
210+
* Saves the resource URL after RFC 9728 discovery.
211+
* This method is called by {@linkcode auth} after successful discovery of the
212+
* resource metadata.
213+
*
214+
* Providers implementing Cross-App Access or other flows that need access to
215+
* the discovered resource URL should implement this method.
216+
*
217+
* @param resourceUrl - The resource URL discovered via RFC 9728
218+
*/
219+
saveResourceUrl?(resourceUrl: string): void | Promise<void>;
220+
221+
/**
222+
* Returns the previously saved resource URL, if available.
223+
*
224+
* Providers implementing Cross-App Access can use this to access the
225+
* resource URL discovered during the OAuth flow.
226+
*
227+
* @returns The resource URL, or `undefined` if not available
228+
*/
229+
resourceUrl?(): string | undefined | Promise<string | undefined>;
230+
187231
/**
188232
* Saves the OAuth discovery state after RFC 9728 and authorization server metadata
189233
* discovery. Providers can persist this state to avoid redundant discovery requests
@@ -497,8 +541,16 @@ async function authInternal(
497541
});
498542
}
499543

544+
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
545+
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
546+
500547
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
501548

549+
// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
550+
if (resource) {
551+
await provider.saveResourceUrl?.(String(resource));
552+
}
553+
502554
// Handle client registration if needed
503555
let clientInformation = await Promise.resolve(provider.clientInformation());
504556
if (!clientInformation) {
@@ -550,6 +602,7 @@ async function authInternal(
550602
metadata,
551603
resource,
552604
authorizationCode,
605+
scope,
553606
fetchFn
554607
});
555608

@@ -1389,21 +1442,25 @@ export async function fetchToken(
13891442
metadata,
13901443
resource,
13911444
authorizationCode,
1445+
scope,
13921446
fetchFn
13931447
}: {
13941448
metadata?: AuthorizationServerMetadata;
13951449
resource?: URL;
13961450
/** Authorization code for the default `authorization_code` grant flow */
13971451
authorizationCode?: string;
1452+
/** Optional scope parameter from auth() options */
1453+
scope?: string;
13981454
fetchFn?: FetchLike;
13991455
} = {}
14001456
): Promise<OAuthTokens> {
1401-
const scope = provider.clientMetadata.scope;
1457+
// Prefer scope from options, fallback to provider.clientMetadata.scope
1458+
const effectiveScope = scope ?? provider.clientMetadata.scope;
14021459

14031460
// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
14041461
let tokenRequestParams: URLSearchParams | undefined;
14051462
if (provider.prepareTokenRequest) {
1406-
tokenRequestParams = await provider.prepareTokenRequest(scope);
1463+
tokenRequestParams = await provider.prepareTokenRequest(effectiveScope);
14071464
}
14081465

14091466
// Default to authorization_code grant if no custom prepareTokenRequest

0 commit comments

Comments
 (0)