Skip to content

Commit 8263a5a

Browse files
committed
Implement SEP-990 Enterprise Managed OAuth
1 parent 108f2f3 commit 8263a5a

File tree

7 files changed

+1320
-4
lines changed

7 files changed

+1320
-4
lines changed

docs/client.md

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

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

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

157212
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
@@ -501,8 +545,16 @@ async function authInternal(
501545
});
502546
}
503547

548+
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
549+
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
550+
504551
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
505552

553+
// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
554+
if (resource) {
555+
await provider.saveResourceUrl?.(String(resource));
556+
}
557+
506558
// Apply scope selection strategy (SEP-835):
507559
// 1. WWW-Authenticate scope (passed via `scope` param)
508560
// 2. PRM scopes_supported
@@ -562,6 +614,7 @@ async function authInternal(
562614
metadata,
563615
resource,
564616
authorizationCode,
617+
scope,
565618
fetchFn
566619
});
567620

@@ -1401,21 +1454,25 @@ export async function fetchToken(
14011454
metadata,
14021455
resource,
14031456
authorizationCode,
1457+
scope,
14041458
fetchFn
14051459
}: {
14061460
metadata?: AuthorizationServerMetadata;
14071461
resource?: URL;
14081462
/** Authorization code for the default `authorization_code` grant flow */
14091463
authorizationCode?: string;
1464+
/** Optional scope parameter from auth() options */
1465+
scope?: string;
14101466
fetchFn?: FetchLike;
14111467
} = {}
14121468
): Promise<OAuthTokens> {
1413-
const scope = provider.clientMetadata.scope;
1469+
// Prefer scope from options, fallback to provider.clientMetadata.scope
1470+
const effectiveScope = scope ?? provider.clientMetadata.scope;
14141471

14151472
// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
14161473
let tokenRequestParams: URLSearchParams | undefined;
14171474
if (provider.prepareTokenRequest) {
1418-
tokenRequestParams = await provider.prepareTokenRequest(scope);
1475+
tokenRequestParams = await provider.prepareTokenRequest(effectiveScope);
14191476
}
14201477

14211478
// Default to authorization_code grant if no custom prepareTokenRequest

0 commit comments

Comments
 (0)