feat(mcp): Add ODIC fallback to OAuth metadata look up (#6863)

Co-authored-by: cornmander <shikhman@google.com>
This commit is contained in:
Amy He
2025-08-26 09:06:26 -07:00
committed by GitHub
parent 51bb624d45
commit c33a0da1df
2 changed files with 146 additions and 62 deletions

View File

@@ -142,6 +142,74 @@ describe('OAuthUtils', () => {
}); });
}); });
describe('discoverAuthorizationServerMetadata', () => {
const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
scopes_supported: ['read', 'write'],
};
it('should handle URLs without path components correctly', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthServerMetadata),
});
const result = await OAuthUtils.discoverAuthorizationServerMetadata(
'https://auth.example.com/',
);
expect(result).toEqual(mockAuthServerMetadata);
expect(mockFetch).nthCalledWith(
1,
'https://auth.example.com/.well-known/oauth-authorization-server',
);
expect(mockFetch).nthCalledWith(
2,
'https://auth.example.com/.well-known/openid-configuration',
);
});
it('should handle URLs with path components correctly', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
})
.mockResolvedValueOnce({
ok: false,
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthServerMetadata),
});
const result = await OAuthUtils.discoverAuthorizationServerMetadata(
'https://auth.example.com/mcp',
);
expect(result).toEqual(mockAuthServerMetadata);
expect(mockFetch).nthCalledWith(
1,
'https://auth.example.com/.well-known/oauth-authorization-server/mcp',
);
expect(mockFetch).nthCalledWith(
2,
'https://auth.example.com/.well-known/openid-configuration/mcp',
);
expect(mockFetch).nthCalledWith(
3,
'https://auth.example.com/mcp/.well-known/openid-configuration',
);
});
});
describe('metadataToOAuthConfig', () => { describe('metadataToOAuthConfig', () => {
it('should convert metadata to OAuth config', () => { it('should convert metadata to OAuth config', () => {
const metadata: OAuthAuthorizationServerMetadata = { const metadata: OAuthAuthorizationServerMetadata = {

View File

@@ -140,6 +140,76 @@ export class OAuthUtils {
}; };
} }
/**
* Discover Oauth Authorization server metadata given an Auth server URL, by
* trying the standard well-known endpoints.
*
* @param authServerUrl The authorization server URL
* @returns The authorization server metadata or null if not found
*/
static async discoverAuthorizationServerMetadata(
authServerUrl: string,
): Promise<OAuthAuthorizationServerMetadata | null> {
const authServerUrlObj = new URL(authServerUrl);
const base = `${authServerUrlObj.protocol}//${authServerUrlObj.host}`;
const endpointsToTry: string[] = [];
// With issuer URLs with path components, try the following well-known
// endpoints in order:
if (authServerUrlObj.pathname !== '/') {
// 1. OAuth 2.0 Authorization Server Metadata with path insertion
endpointsToTry.push(
new URL(
`/.well-known/oauth-authorization-server${authServerUrlObj.pathname}`,
base,
).toString(),
);
// 2. OpenID Connect Discovery 1.0 with path insertion
endpointsToTry.push(
new URL(
`/.well-known/openid-configuration${authServerUrlObj.pathname}`,
base,
).toString(),
);
// 3. OpenID Connect Discovery 1.0 with path appending
endpointsToTry.push(
new URL(
`${authServerUrlObj.pathname}/.well-known/openid-configuration`,
base,
).toString(),
);
}
// With issuer URLs without path components, and those that failed previous
// discoveries, try the following well-known endpoints in order:
// 1. OAuth 2.0 Authorization Server Metadata
endpointsToTry.push(
new URL('/.well-known/oauth-authorization-server', base).toString(),
);
// 2. OpenID Connect Discovery 1.0
endpointsToTry.push(
new URL('/.well-known/openid-configuration', base).toString(),
);
for (const endpoint of endpointsToTry) {
const authServerMetadata =
await this.fetchAuthorizationServerMetadata(endpoint);
if (authServerMetadata) {
return authServerMetadata;
}
}
console.debug(
`Metadata discovery failed for authorization server ${authServerUrl}`,
);
return null;
}
/** /**
* Discover OAuth configuration using the standard well-known endpoints. * Discover OAuth configuration using the standard well-known endpoints.
* *
@@ -172,33 +242,8 @@ export class OAuthUtils {
if (resourceMetadata?.authorization_servers?.length) { if (resourceMetadata?.authorization_servers?.length) {
// Use the first authorization server // Use the first authorization server
const authServerUrl = resourceMetadata.authorization_servers[0]; const authServerUrl = resourceMetadata.authorization_servers[0];
const authServerMetadata =
// The authorization server URL may include a path (e.g., https://github.com/login/oauth) await this.discoverAuthorizationServerMetadata(authServerUrl);
// We need to preserve this path when constructing the metadata URL
const authServerUrlObj = new URL(authServerUrl);
const authServerPath =
authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname;
// Try with the authorization server's path first
let authServerMetadataUrl = new URL(
`/.well-known/oauth-authorization-server${authServerPath}`,
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString();
let authServerMetadata = await this.fetchAuthorizationServerMetadata(
authServerMetadataUrl,
);
// If that fails, try root as fallback
if (!authServerMetadata && authServerPath) {
authServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server',
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString();
authServerMetadata = await this.fetchAuthorizationServerMetadata(
authServerMetadataUrl,
);
}
if (authServerMetadata) { if (authServerMetadata) {
const config = this.metadataToOAuthConfig(authServerMetadata); const config = this.metadataToOAuthConfig(authServerMetadata);
@@ -212,13 +257,10 @@ export class OAuthUtils {
} }
} }
// Fallback: try /.well-known/oauth-authorization-server at the base URL // Fallback: try well-known endpoints at the base URL
console.debug( console.debug(`Trying OAuth discovery fallback at ${serverUrl}`);
`Trying OAuth discovery fallback at ${wellKnownUrls.authorizationServer}`, const authServerMetadata =
); await this.discoverAuthorizationServerMetadata(serverUrl);
const authServerMetadata = await this.fetchAuthorizationServerMetadata(
wellKnownUrls.authorizationServer,
);
if (authServerMetadata) { if (authServerMetadata) {
const config = this.metadataToOAuthConfig(authServerMetadata); const config = this.metadataToOAuthConfig(authServerMetadata);
@@ -277,34 +319,8 @@ export class OAuthUtils {
} }
const authServerUrl = resourceMetadata.authorization_servers[0]; const authServerUrl = resourceMetadata.authorization_servers[0];
const authServerMetadata =
// The authorization server URL may include a path (e.g., https://github.com/login/oauth) await this.discoverAuthorizationServerMetadata(authServerUrl);
// We need to preserve this path when constructing the metadata URL
const authServerUrlObj = new URL(authServerUrl);
const authServerPath =
authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname;
// Build auth server metadata URL with the authorization server's path
const authServerMetadataUrl = new URL(
`/.well-known/oauth-authorization-server${authServerPath}`,
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString();
let authServerMetadata = await this.fetchAuthorizationServerMetadata(
authServerMetadataUrl,
);
// If that fails and we have a path, also try the root path as a fallback
if (!authServerMetadata && authServerPath) {
const rootAuthServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server',
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString();
authServerMetadata = await this.fetchAuthorizationServerMetadata(
rootAuthServerMetadataUrl,
);
}
if (authServerMetadata) { if (authServerMetadata) {
return this.metadataToOAuthConfig(authServerMetadata); return this.metadataToOAuthConfig(authServerMetadata);