Skip to content

Commit 7b3b4c7

Browse files
committed
fix(client): preserve legacy issuer fallback during auth
1 parent 68d8bb1 commit 7b3b4c7

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

packages/client/src/client/auth.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,29 @@ async function authInternal(
810810
const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn });
811811
authorizationServerUrl = serverInfo.authorizationServerUrl;
812812
metadata = serverInfo.authorizationServerMetadata;
813-
validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl);
813+
try {
814+
validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl);
815+
} catch (error) {
816+
if (serverInfo.resourceMetadata?.authorization_servers?.length) {
817+
throw error;
818+
}
819+
if (!metadata) {
820+
throw error;
821+
}
822+
823+
let legacyIssuerIsMalformed = false;
824+
try {
825+
normalizeDiscoveredIssuerIdentifier(metadata.issuer, 'Authorization server metadata issuer');
826+
} catch {
827+
// Legacy no-PRM discovery intentionally disables issuer validation. Keep the
828+
// fallback MCP origin when legacy metadata has an unparseable issuer value.
829+
legacyIssuerIsMalformed = true;
830+
}
831+
832+
if (!legacyIssuerIsMalformed) {
833+
throw error;
834+
}
835+
}
814836
resourceMetadata = serverInfo.resourceMetadata;
815837

816838
// Persist discovery state for future use

packages/client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type {
1111
AuthProvider,
1212
AuthResult,
1313
ClientAuthMethod,
14+
DiscoverAuthorizationServerMetadataOptions,
1415
OAuthClientProvider,
1516
OAuthDiscoveryState,
1617
OAuthServerInfo

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2645,6 +2645,84 @@ describe('OAuth Authorization', () => {
26452645
expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize');
26462646
});
26472647

2648+
it('keeps the fallback MCP origin when legacy no-PRM auth metadata has an invalid issuer', async () => {
2649+
const saveDiscoveryState = vi.fn();
2650+
const saveAuthorizationServerUrl = vi.fn();
2651+
const provider: OAuthClientProvider = {
2652+
...mockProvider,
2653+
clientInformation: vi.fn().mockResolvedValue(undefined),
2654+
tokens: vi.fn().mockResolvedValue(undefined),
2655+
saveClientInformation: vi.fn(),
2656+
saveCodeVerifier: vi.fn(),
2657+
redirectToAuthorization: vi.fn(),
2658+
saveDiscoveryState,
2659+
saveAuthorizationServerUrl
2660+
};
2661+
2662+
mockFetch.mockImplementation(url => {
2663+
const urlString = url.toString();
2664+
2665+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2666+
return Promise.resolve({
2667+
ok: false,
2668+
status: 404
2669+
});
2670+
}
2671+
2672+
if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') {
2673+
return Promise.resolve({
2674+
ok: true,
2675+
status: 200,
2676+
json: async () => ({
2677+
issuer: 'auth.example.com',
2678+
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
2679+
token_endpoint: 'https://auth.example.com/oauth/token',
2680+
registration_endpoint: 'https://auth.example.com/oauth/register',
2681+
response_types_supported: ['code'],
2682+
code_challenge_methods_supported: ['S256']
2683+
})
2684+
});
2685+
}
2686+
2687+
if (urlString === 'https://auth.example.com/oauth/register') {
2688+
return Promise.resolve({
2689+
ok: true,
2690+
status: 200,
2691+
json: async () => ({
2692+
client_id: 'test-client-id',
2693+
client_secret: 'test-client-secret',
2694+
client_id_issued_at: 1_612_137_600,
2695+
client_secret_expires_at: 1_612_224_000,
2696+
redirect_uris: ['http://localhost:3000/callback'],
2697+
client_name: 'Test Client'
2698+
})
2699+
});
2700+
}
2701+
2702+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
2703+
});
2704+
2705+
const result = await auth(provider, {
2706+
serverUrl: 'https://resource.example.com'
2707+
});
2708+
2709+
expect(result).toBe('REDIRECT');
2710+
expect(saveDiscoveryState).toHaveBeenCalledWith(
2711+
expect.objectContaining({
2712+
authorizationServerUrl: 'https://resource.example.com/',
2713+
resourceMetadata: undefined,
2714+
authorizationServerMetadata: expect.objectContaining({
2715+
issuer: 'auth.example.com'
2716+
})
2717+
})
2718+
);
2719+
expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://resource.example.com/');
2720+
2721+
const redirectCall = (provider.redirectToAuthorization as Mock).mock.calls[0]!;
2722+
const authUrl: URL = redirectCall[0];
2723+
expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize');
2724+
});
2725+
26482726
it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => {
26492727
// Setup: First call to protected resource metadata fails (404)
26502728
// When no authorization_servers are found in protected resource metadata,

0 commit comments

Comments
 (0)