Skip to content

Commit ec00df3

Browse files
committed
Reimplement XAA as OAuthClientProvider with Layer 2 utilities
Replaces the standalone middleware approach from PR #1328 with: - CrossAppAccessProvider in authExtensions.ts: plugs into withOAuth for token caching, 401 retry, client_secret_basic, and Zod validation - assertion callback: receives orchestrator context (AS URL, resource URL, scope, fetchFn), returns JAG string. Decouples IDP interaction from provider - requestJwtAuthorizationGrant in crossAppAccess.ts: standalone Layer 2 utility for RFC 8693 token exchange (ID token → JAG) - saveResourceUrl on OAuthClientProvider: orchestrator saves resource URL so providers can use it in prepareTokenRequest Removes: withCrossAppAccess middleware, xaaUtil.ts (597 lines), qs dependency Adds: conformance test scenario (auth/cross-app-access-complete-flow) - passes 9/9
1 parent bb49eac commit ec00df3

File tree

7 files changed

+385
-630
lines changed

7 files changed

+385
-630
lines changed

packages/client/src/client/auth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ export interface OAuthClientProvider {
188188
* }
189189
*/
190190
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;
191+
192+
/**
193+
* Saves the resource URL determined during the OAuth flow.
194+
*
195+
* Called by {@linkcode auth} after resource URL selection so providers can
196+
* use it in grant-specific logic (e.g., Cross-App Access token exchange).
197+
*/
198+
saveResourceUrl?(url: URL): void | Promise<void>;
191199
}
192200

193201
export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
@@ -422,6 +430,10 @@ async function authInternal(
422430

423431
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
424432

433+
if (resource) {
434+
await provider.saveResourceUrl?.(resource);
435+
}
436+
425437
const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, {
426438
fetchFn
427439
});

packages/client/src/client/authExtensions.ts

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* for common machine-to-machine authentication scenarios.
66
*/
77

8-
import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
8+
import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
99
import type { CryptoKey, JWK } from 'jose';
1010

1111
import type { AddClientAuthentication, OAuthClientProvider } from './auth.js';
@@ -396,3 +396,180 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
396396
return params;
397397
}
398398
}
399+
400+
/**
401+
* Context passed to the assertion callback in {@link CrossAppAccessProvider}.
402+
*/
403+
export interface CrossAppAccessAssertionContext {
404+
/** The MCP authorization server URL (use as `audience` in token exchange). */
405+
authorizationServerUrl: string;
406+
/** The MCP resource URL (use as `resource` in token exchange). */
407+
resourceUrl: string;
408+
/** Scope requested by the orchestrator. */
409+
scope?: string;
410+
/** Fetch function from the provider, if configured. */
411+
fetchFn?: FetchLike;
412+
}
413+
414+
/**
415+
* Options for creating a CrossAppAccessProvider.
416+
*/
417+
export interface CrossAppAccessProviderOptions {
418+
/**
419+
* Returns the JWT Authorization Grant (JAG) assertion.
420+
* Called each time tokens need to be obtained (initial auth and 401 retry).
421+
*
422+
* Use {@link requestJwtAuthorizationGrant} from `crossAppAccess.ts` for the
423+
* standard RFC 8693 token exchange flow, or implement custom logic.
424+
*/
425+
assertion: (context: CrossAppAccessAssertionContext) => Promise<string>;
426+
427+
/** MCP client ID for authentication with the MCP authorization server. */
428+
clientId: string;
429+
430+
/** MCP client secret. */
431+
clientSecret?: string;
432+
433+
/** Optional client name for metadata. */
434+
clientName?: string;
435+
436+
/** Optional scopes to request. */
437+
scope?: string[];
438+
439+
/** Optional fetch function passed through to the assertion callback. */
440+
fetchFn?: FetchLike;
441+
}
442+
443+
/**
444+
* OAuth provider for Cross-App Access using the Identity Assertion Authorization Grant.
445+
*
446+
* Implements a two-step OAuth flow:
447+
* 1. Obtains a JWT Authorization Grant (JAG) via the `assertion` callback
448+
* (typically RFC 8693 Token Exchange with an IDP)
449+
* 2. Exchanges the JAG for an access token at the MCP authorization server
450+
* via RFC 7523 JWT Bearer grant (handled by `withOAuth` infrastructure)
451+
*
452+
* Step 1 is delegated to the caller via the `assertion` callback. Step 2 is
453+
* executed by the SDK's standard token request machinery, providing token
454+
* caching, 401 retry, and refresh handling automatically.
455+
*
456+
* @example
457+
* ```typescript
458+
* import { CrossAppAccessProvider, requestJwtAuthorizationGrant } from '@modelcontextprotocol/client';
459+
*
460+
* const provider = new CrossAppAccessProvider({
461+
* assertion: async (ctx) => requestJwtAuthorizationGrant({
462+
* tokenEndpoint: 'https://idp.example.com/token',
463+
* audience: ctx.authorizationServerUrl,
464+
* resource: ctx.resourceUrl,
465+
* idToken: await getIdToken(),
466+
* clientId: 'my-idp-client',
467+
* clientSecret: 'my-idp-secret',
468+
* scope: ctx.scope,
469+
* fetchFn: ctx.fetchFn
470+
* }),
471+
* clientId: 'my-mcp-client',
472+
* clientSecret: 'my-mcp-secret',
473+
* scope: ['read', 'write']
474+
* });
475+
*
476+
* const transport = new StreamableHTTPClientTransport(serverUrl, {
477+
* authProvider: provider
478+
* });
479+
* ```
480+
*/
481+
export class CrossAppAccessProvider implements OAuthClientProvider {
482+
private _tokens?: OAuthTokens;
483+
private _clientInfo: OAuthClientInformation;
484+
private _clientMetadata: OAuthClientMetadata;
485+
private _options: CrossAppAccessProviderOptions;
486+
private _authServerUrl?: string | URL;
487+
private _resourceUrl?: URL;
488+
489+
constructor(options: CrossAppAccessProviderOptions) {
490+
this._options = options;
491+
this._clientInfo = {
492+
client_id: options.clientId,
493+
client_secret: options.clientSecret
494+
};
495+
this._clientMetadata = {
496+
client_name: options.clientName ?? 'cross-app-access-client',
497+
redirect_uris: [],
498+
grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'],
499+
token_endpoint_auth_method: options.clientSecret ? 'client_secret_basic' : 'none',
500+
scope: options.scope?.join(' ')
501+
};
502+
}
503+
504+
get redirectUrl(): undefined {
505+
return undefined;
506+
}
507+
508+
get clientMetadata(): OAuthClientMetadata {
509+
return this._clientMetadata;
510+
}
511+
512+
clientInformation(): OAuthClientInformation {
513+
return this._clientInfo;
514+
}
515+
516+
saveClientInformation(info: OAuthClientInformation): void {
517+
this._clientInfo = info;
518+
}
519+
520+
tokens(): OAuthTokens | undefined {
521+
return this._tokens;
522+
}
523+
524+
saveTokens(tokens: OAuthTokens): void {
525+
this._tokens = tokens;
526+
}
527+
528+
redirectToAuthorization(): void {
529+
throw new Error('redirectToAuthorization is not used for cross-app access flow');
530+
}
531+
532+
saveCodeVerifier(): void {
533+
// Not used for cross-app access
534+
}
535+
536+
codeVerifier(): string {
537+
throw new Error('codeVerifier is not used for cross-app access flow');
538+
}
539+
540+
saveAuthorizationServerUrl(url: string | URL): void {
541+
this._authServerUrl = url;
542+
}
543+
544+
authorizationServerUrl(): string | URL | undefined {
545+
return this._authServerUrl;
546+
}
547+
548+
saveResourceUrl(url: URL): void {
549+
this._resourceUrl = url;
550+
}
551+
552+
/**
553+
* Calls the assertion callback to get a JAG, then returns JWT Bearer
554+
* grant params for the MCP AS token request.
555+
*/
556+
async prepareTokenRequest(scope?: string): Promise<URLSearchParams> {
557+
const effectiveScope = scope ?? this._options.scope?.join(' ');
558+
559+
const assertion = await this._options.assertion({
560+
authorizationServerUrl: String(this._authServerUrl ?? ''),
561+
resourceUrl: this._resourceUrl?.href ?? '',
562+
scope: effectiveScope,
563+
fetchFn: this._options.fetchFn
564+
});
565+
566+
const params = new URLSearchParams({
567+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
568+
assertion
569+
});
570+
if (effectiveScope) {
571+
params.set('scope', effectiveScope);
572+
}
573+
return params;
574+
}
575+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Cross-App Access utilities for the Identity Assertion Authorization Grant flow.
3+
*
4+
* Provides standalone functions for RFC 8693 Token Exchange (ID token → JAG).
5+
* Used by {@link CrossAppAccessProvider} and available for direct use.
6+
*/
7+
8+
import type { FetchLike } from '@modelcontextprotocol/core';
9+
10+
import { discoverAuthorizationServerMetadata } from './auth.js';
11+
12+
/**
13+
* Options for requesting a JWT Authorization Grant from an Identity Provider.
14+
*/
15+
export interface RequestJwtAuthGrantOptions {
16+
/** The IDP's token endpoint URL. */
17+
tokenEndpoint: string;
18+
/** The MCP authorization server URL (used as the `audience` parameter). */
19+
audience: string;
20+
/** The MCP resource server URL (used as the `resource` parameter). */
21+
resource: string;
22+
/** The OIDC ID token to exchange. */
23+
idToken: string;
24+
/** Client ID for authentication with the IDP. */
25+
clientId: string;
26+
/** Client secret for authentication with the IDP. */
27+
clientSecret?: string;
28+
/** Optional scopes to request. */
29+
scope?: string;
30+
/** Optional fetch function for HTTP requests. */
31+
fetchFn?: FetchLike;
32+
}
33+
34+
/**
35+
* Requests a JWT Authorization Grant (JAG) from an Identity Provider via
36+
* RFC 8693 Token Exchange. Returns the JAG to be used as a JWT Bearer
37+
* assertion (RFC 7523) against the MCP authorization server.
38+
*/
39+
export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise<string> {
40+
const effectiveFetch = options.fetchFn ?? fetch;
41+
42+
const body = new URLSearchParams({
43+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
44+
requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
45+
subject_token: options.idToken,
46+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
47+
audience: options.audience,
48+
resource: options.resource,
49+
client_id: options.clientId,
50+
...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
51+
...(options.scope ? { scope: options.scope } : {})
52+
});
53+
54+
const response = await effectiveFetch(options.tokenEndpoint, {
55+
method: 'POST',
56+
headers: {
57+
'Content-Type': 'application/x-www-form-urlencoded',
58+
Accept: 'application/json'
59+
},
60+
body
61+
});
62+
63+
if (!response.ok) {
64+
const errorText = await response.text().catch(() => response.statusText);
65+
throw new Error(`JWT Authorization Grant request failed (${response.status}): ${errorText}`);
66+
}
67+
68+
const data = (await response.json()) as Record<string, unknown>;
69+
70+
if (typeof data.access_token !== 'string' || !data.access_token) {
71+
throw new Error('Token exchange response missing access_token');
72+
}
73+
if (data.issued_token_type !== 'urn:ietf:params:oauth:token-type:id-jag') {
74+
throw new Error(`Expected issued_token_type 'urn:ietf:params:oauth:token-type:id-jag', got '${data.issued_token_type}'`);
75+
}
76+
if (typeof data.token_type !== 'string' || data.token_type.toLowerCase() !== 'n_a') {
77+
throw new Error(`Expected token_type 'n_a', got '${data.token_type}'`);
78+
}
79+
80+
return data.access_token;
81+
}
82+
83+
/**
84+
* Options for discovering and requesting a JWT Authorization Grant.
85+
*/
86+
export interface DiscoverAndRequestJwtAuthGrantOptions {
87+
/** Identity Provider's base URL for OAuth/OIDC discovery. */
88+
idpUrl: string;
89+
/** IDP token endpoint URL. When provided, skips IDP metadata discovery. */
90+
idpTokenEndpoint?: string;
91+
/** The MCP authorization server URL (used as the `audience` parameter). */
92+
audience: string;
93+
/** The MCP resource server URL (used as the `resource` parameter). */
94+
resource: string;
95+
/** The OIDC ID token to exchange. */
96+
idToken: string;
97+
/** Client ID for authentication with the IDP. */
98+
clientId: string;
99+
/** Client secret for authentication with the IDP. */
100+
clientSecret?: string;
101+
/** Optional scopes to request. */
102+
scope?: string;
103+
/** Optional fetch function for HTTP requests. */
104+
fetchFn?: FetchLike;
105+
}
106+
107+
/**
108+
* Discovers the IDP's token endpoint via metadata, then requests a JAG.
109+
* Convenience wrapper over {@link requestJwtAuthorizationGrant}.
110+
*/
111+
export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise<string> {
112+
let tokenEndpoint = options.idpTokenEndpoint;
113+
114+
if (!tokenEndpoint) {
115+
try {
116+
const idpMetadata = await discoverAuthorizationServerMetadata(options.idpUrl, { fetchFn: options.fetchFn });
117+
tokenEndpoint = idpMetadata?.token_endpoint;
118+
} catch {
119+
// Discovery failed — fall back to idpUrl
120+
}
121+
}
122+
123+
return requestJwtAuthorizationGrant({
124+
tokenEndpoint: tokenEndpoint ?? options.idpUrl,
125+
audience: options.audience,
126+
resource: options.resource,
127+
idToken: options.idToken,
128+
clientId: options.clientId,
129+
clientSecret: options.clientSecret,
130+
scope: options.scope,
131+
fetchFn: options.fetchFn
132+
});
133+
}

packages/client/src/client/middleware.ts

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import type { FetchLike } from '@modelcontextprotocol/core';
22

33
import type { OAuthClientProvider } from './auth.js';
44
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
5-
import type { XAAOptions } from './xaaUtil.js';
6-
import { getAccessToken } from './xaaUtil.js';
75

86
/**
97
* Middleware function that wraps and enhances fetch functionality.
@@ -232,35 +230,6 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => {
232230
};
233231
};
234232

235-
/**
236-
* Creates a fetch wrapper that handles Cross App Access authentication automatically.
237-
*
238-
* This wrapper will:
239-
* - Add Authorization headers with access tokens
240-
*
241-
* @param options - XAA configuration options
242-
* @returns A fetch middleware function
243-
*/
244-
export const withCrossAppAccess = (options: XAAOptions): Middleware => {
245-
return wrappedFetchFunction => {
246-
let accessToken: string | undefined;
247-
248-
return async (url, init = {}): Promise<Response> => {
249-
if (!accessToken) {
250-
accessToken = await getAccessToken(options, wrappedFetchFunction);
251-
}
252-
253-
const headers = new Headers(init.headers);
254-
255-
headers.set('Authorization', `Bearer ${accessToken}`);
256-
257-
init.headers = headers;
258-
259-
return wrappedFetchFunction(url, init);
260-
};
261-
};
262-
};
263-
264233
/**
265234
* Composes multiple fetch middleware functions into a single middleware pipeline.
266235
* Middleware are applied in the order they appear, creating a chain of handlers.

0 commit comments

Comments
 (0)