Skip to content

Commit 68d8bb1

Browse files
committed
fix(client): preserve Cross-App IdP issuer aliases
1 parent fcf958d commit 68d8bb1

8 files changed

Lines changed: 129 additions & 28 deletions

File tree

.changeset/sep-2468-iss-validation.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Add RFC 9207 `iss` parameter validation for authorization responses (SEP-2468).
88
authorization server metadata before the authorization code is sent to any token endpoint (mismatch rejects the response without processing any other response parameters); `null` asserts the caller inspected the authorization response and it carried no `iss`, enabling the RFC
99
9207 fail-closed rejection when the AS advertises `authorization_response_iss_parameter_supported: true`; `undefined` (omitted) skips RFC 9207 response validation, so existing `finishAuth(code)` callers that never see the authorization response are unaffected.
1010

11-
Discovery also now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a PRM-provided authorization server URL is rejected when its `issuer` does not match that URL, and the public
12-
`discoverAuthorizationServerMetadata()` helper throws on mismatches or invalid issuer identifiers. Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata issuer is ignored and refreshed.
13-
For legacy servers without protected resource metadata, metadata is still discovered at the MCP server origin; when that metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL for persisted discovery state and fallback endpoint
14-
construction.
11+
Discovery also now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a PRM-provided authorization server URL is rejected when its `issuer` does not match that URL, and the public `discoverAuthorizationServerMetadata()` helper
12+
throws on mismatches or invalid issuer identifiers unless called with `{ validateIssuer: false }` for intentional alias discovery. Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata
13+
issuer is ignored and refreshed. For legacy servers without protected resource metadata, metadata is still discovered at the MCP server origin; when that metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL for persisted
14+
discovery state and fallback endpoint construction.

docs/migration-SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,10 @@ members of the request/result/notification unions, the `tasks` capability key, `
515515

516516
`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead.
517517

518+
OAuth client discovery validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a protected-resource metadata authorization server URL must have a matching `issuer`; cached discovery state is also revalidated. The public
519+
`discoverAuthorizationServerMetadata()` helper throws for mismatched or invalid issuers unless called with `{ validateIssuer: false }`. Legacy no-PRM fallback still discovers metadata at the MCP server origin and adopts a distinct valid metadata `issuer` for saved discovery
520+
state.
521+
518522
### Server (Streamable HTTP transport)
519523

520524
No code changes required; these are wire-behavior notes:

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is
161161

162162
OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL after standard URL parsing/serialization and
163163
trailing slash normalization. Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata issuer is ignored and refreshed. The public `discoverAuthorizationServerMetadata()` helper throws when
164-
metadata has a mismatched or invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata.
164+
metadata has a mismatched or invalid issuer unless called with `{ validateIssuer: false }`. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata.
165165

166166
For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in
167167
discovery state and used for fallback endpoint construction.

packages/client/src/client/auth.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,19 +1366,17 @@ export interface DiscoverAuthorizationServerMetadataOptions {
13661366
fetchFn?: FetchLike;
13671367
/** MCP protocol version sent during metadata discovery. */
13681368
protocolVersion?: string;
1369-
}
1370-
1371-
interface DiscoverAuthorizationServerMetadataInternalOptions extends DiscoverAuthorizationServerMetadataOptions {
1369+
/**
1370+
* Whether to validate discovered metadata's issuer against the discovery URL.
1371+
* Defaults to true. Set to false only when discovery intentionally starts from
1372+
* an alias URL whose metadata may name a canonical issuer.
1373+
*/
13721374
validateIssuer?: boolean;
13731375
}
13741376

13751377
async function discoverAuthorizationServerMetadataInternal(
13761378
authorizationServerUrl: string | URL,
1377-
{
1378-
fetchFn = fetch,
1379-
protocolVersion = LATEST_PROTOCOL_VERSION,
1380-
validateIssuer = true
1381-
}: DiscoverAuthorizationServerMetadataInternalOptions = {}
1379+
{ fetchFn = fetch, protocolVersion = LATEST_PROTOCOL_VERSION, validateIssuer = true }: DiscoverAuthorizationServerMetadataOptions = {}
13821380
): Promise<AuthorizationServerMetadata | undefined> {
13831381
const headers = {
13841382
'MCP-Protocol-Version': protocolVersion,
@@ -1443,7 +1441,10 @@ async function discoverAuthorizationServerMetadataInternal(
14431441
* @param options - Configuration options
14441442
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
14451443
* @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION}
1444+
* @param options.validateIssuer - Whether to validate metadata's issuer against the discovery URL, defaults to true
14461445
* @returns Promise resolving to authorization server metadata, or undefined if discovery fails
1446+
* @throws {Error} If discovered metadata has an invalid issuer or the issuer does not match
1447+
* the discovery URL while issuer validation is enabled
14471448
*/
14481449
export async function discoverAuthorizationServerMetadata(
14491450
authorizationServerUrl: string | URL,
@@ -1538,13 +1539,18 @@ export async function discoverOAuthServerInfo(
15381539
});
15391540

15401541
if (!authorizationServerUrlFromResourceMetadata && authorizationServerMetadata) {
1541-
const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL');
1542-
const metadataIssuer = normalizeDiscoveredIssuerIdentifier(
1543-
authorizationServerMetadata.issuer,
1544-
'Authorization server metadata issuer'
1545-
);
1546-
if (metadataIssuer !== fallbackIssuer) {
1547-
authorizationServerUrl = authorizationServerMetadata.issuer;
1542+
try {
1543+
const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL');
1544+
const metadataIssuer = normalizeDiscoveredIssuerIdentifier(
1545+
authorizationServerMetadata.issuer,
1546+
'Authorization server metadata issuer'
1547+
);
1548+
if (metadataIssuer !== fallbackIssuer) {
1549+
authorizationServerUrl = authorizationServerMetadata.issuer;
1550+
}
1551+
} catch {
1552+
// Legacy no-PRM discovery intentionally disables issuer validation. Keep the
1553+
// fallback MCP origin when legacy metadata has an unparseable issuer value.
15481554
}
15491555
}
15501556

packages/client/src/client/crossAppAccess.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,8 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
203203
export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise<JwtAuthGrantResult> {
204204
const { idpUrl, fetchFn = fetch, ...restOptions } = options;
205205

206-
// Discover IdP's authorization server metadata
207-
const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn });
206+
// Enterprise IdP URLs are caller-configured and may be aliases for a canonical issuer.
207+
const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn, validateIssuer: false });
208208

209209
if (!metadata?.token_endpoint) {
210210
throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`);

packages/client/test/client/auth.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,23 @@ describe('OAuth Authorization', () => {
981981
);
982982
});
983983

984+
it('can opt out of metadata issuer validation for alias discovery', async () => {
985+
mockFetch.mockResolvedValueOnce({
986+
ok: true,
987+
status: 200,
988+
json: async () => ({
989+
...validOAuthMetadata,
990+
issuer: 'https://canonical-idp.example.com'
991+
})
992+
});
993+
994+
const metadata = await discoverAuthorizationServerMetadata('https://idp-alias.example.com', {
995+
validateIssuer: false
996+
});
997+
998+
expect(metadata?.issuer).toBe('https://canonical-idp.example.com');
999+
});
1000+
9841001
it('rejects OpenID metadata whose issuer does not match the authorization server URL', async () => {
9851002
mockFetch.mockResolvedValueOnce({
9861003
ok: false,
@@ -1242,6 +1259,42 @@ describe('OAuth Authorization', () => {
12421259
expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
12431260
});
12441261

1262+
it('keeps the legacy fallback MCP origin when unvalidated metadata has an invalid issuer', async () => {
1263+
const legacyAuthMetadata = {
1264+
...validAuthMetadata,
1265+
issuer: 'auth.example.com',
1266+
authorization_endpoint: 'https://resource.example.com/authorize',
1267+
token_endpoint: 'https://resource.example.com/token'
1268+
};
1269+
1270+
mockFetch.mockImplementation(url => {
1271+
const urlString = url.toString();
1272+
1273+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
1274+
return Promise.resolve({
1275+
ok: false,
1276+
status: 404
1277+
});
1278+
}
1279+
1280+
if (urlString.includes('/.well-known/oauth-authorization-server')) {
1281+
return Promise.resolve({
1282+
ok: true,
1283+
status: 200,
1284+
json: async () => legacyAuthMetadata
1285+
});
1286+
}
1287+
1288+
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
1289+
});
1290+
1291+
const result = await discoverOAuthServerInfo('https://resource.example.com');
1292+
1293+
expect(result.authorizationServerUrl).toBe('https://resource.example.com/');
1294+
expect(result.resourceMetadata).toBeUndefined();
1295+
expect(result.authorizationServerMetadata).toEqual(legacyAuthMetadata);
1296+
});
1297+
12451298
it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => {
12461299
const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource');
12471300

packages/client/test/client/crossAppAccess.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,44 @@ describe('crossAppAccess', () => {
265265
expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token');
266266
});
267267

268+
it('allows IdP discovery aliases whose metadata names a canonical issuer', async () => {
269+
const mockFetch = vi.fn<FetchLike>();
270+
271+
mockFetch.mockResolvedValueOnce({
272+
ok: true,
273+
json: async () => ({
274+
issuer: 'https://login.microsoftonline.com/tenant-guid/v2.0',
275+
authorization_endpoint: 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/authorize',
276+
token_endpoint: 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token',
277+
jwks_uri: 'https://login.microsoftonline.com/tenant-guid/discovery/v2.0/keys',
278+
response_types_supported: ['code'],
279+
grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange']
280+
})
281+
} as Response);
282+
283+
mockFetch.mockResolvedValueOnce({
284+
ok: true,
285+
json: async () => ({
286+
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
287+
access_token: 'jag-token',
288+
token_type: 'N_A'
289+
})
290+
} as Response);
291+
292+
const result = await discoverAndRequestJwtAuthGrant({
293+
idpUrl: 'https://login.example-corp.com',
294+
audience: 'https://auth.chat.example/',
295+
resource: 'https://mcp.chat.example/',
296+
idToken: 'id-token',
297+
clientId: 'client',
298+
fetchFn: mockFetch
299+
});
300+
301+
expect(result.jwtAuthGrant).toBe('jag-token');
302+
expect(String(mockFetch.mock.calls[0]![0])).toBe('https://login.example-corp.com/.well-known/oauth-authorization-server');
303+
expect(String(mockFetch.mock.calls[1]![0])).toBe('https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token');
304+
});
305+
268306
it('throws error when token endpoint is not discovered', async () => {
269307
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
270308
ok: true,
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
22
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
3-
'@modelcontextprotocol/client': '^2.0.0-alpha.2',
4-
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
5-
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
6-
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
7-
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2',
8-
'@modelcontextprotocol/core': '^2.0.0-alpha.0'
3+
'@modelcontextprotocol/client': '^2.0.0-alpha.3',
4+
'@modelcontextprotocol/server': '^2.0.0-alpha.3',
5+
'@modelcontextprotocol/node': '^2.0.0-alpha.3',
6+
'@modelcontextprotocol/express': '^2.0.0-alpha.3',
7+
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3',
8+
'@modelcontextprotocol/core': '^2.0.0-alpha.1'
99
};

0 commit comments

Comments
 (0)