Skip to content

Commit 5375f46

Browse files
committed
feat: apply draft spec
1 parent 3bb5f3e commit 5375f46

File tree

1 file changed

+248
-16
lines changed

1 file changed

+248
-16
lines changed

src/client/auth.ts

Lines changed: 248 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
OAuthMetadata,
88
OAuthClientInformationFull,
99
OAuthProtectedResourceMetadata,
10-
OAuthErrorResponseSchema
10+
OAuthErrorResponseSchema,
11+
OpenIdProviderDiscoveryMetadata,
12+
AuthorizationServerMetadata,
13+
OpenIdProviderDiscoveryMetadataSchema
1114
} from "../shared/auth.js";
1215
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
1316
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
@@ -108,7 +111,7 @@ export interface OAuthClientProvider {
108111
* @param url - The token endpoint URL being called
109112
* @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods
110113
*/
111-
addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata): void | Promise<void>;
114+
addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata): void | Promise<void>;
112115

113116
/**
114117
* If defined, overrides the selection and validation of the
@@ -319,7 +322,7 @@ async function authInternal(
319322
): Promise<AuthResult> {
320323

321324
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
322-
let authorizationServerUrl = serverUrl;
325+
let authorizationServerUrl: string | URL | undefined;
323326
try {
324327
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
325328
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
@@ -331,9 +334,17 @@ async function authInternal(
331334

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

334-
const metadata = await discoverOAuthMetadata(serverUrl, {
335-
authorizationServerUrl
336-
}, fetchFn);
337+
const metadata = await discoverAuthorizationServerMetadata(serverUrl, authorizationServerUrl, {
338+
fetchFn,
339+
});
340+
341+
/**
342+
* If we don't get a valid authorization server metadata from protected resource metadata,
343+
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server.
344+
*/
345+
if (!authorizationServerUrl) {
346+
authorizationServerUrl = serverUrl;
347+
}
337348

338349
// Handle client registration if needed
339350
let clientInformation = await Promise.resolve(provider.clientInformation());
@@ -524,15 +535,21 @@ async function fetchWithCorsRetry(
524535
}
525536

526537
/**
527-
* Constructs the well-known path for OAuth metadata discovery
538+
* Constructs the well-known path for auth-related metadata discovery
528539
*/
529-
function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string {
530-
let wellKnownPath = `/.well-known/${wellKnownPrefix}${pathname}`;
540+
function buildWellKnownPath(
541+
wellKnownPrefix: 'oauth-authorization-server' | 'oauth-protected-resource' | 'openid-configuration',
542+
pathname: string = '',
543+
options: { prependPathname?: boolean } = {}
544+
): string {
545+
// Strip trailing slash from pathname to avoid double slashes
531546
if (pathname.endsWith('/')) {
532-
// Strip trailing slash from pathname to avoid double slashes
533-
wellKnownPath = wellKnownPath.slice(0, -1);
547+
pathname = pathname.slice(0, -1);
534548
}
535-
return wellKnownPath;
549+
550+
return options.prependPathname
551+
? `${pathname}/.well-known/${wellKnownPrefix}`
552+
: `/.well-known/${wellKnownPrefix}${pathname}`;
536553
}
537554

538555
/**
@@ -594,6 +611,8 @@ async function discoverMetadataWithFallback(
594611
*
595612
* If the server returns a 404 for the well-known endpoint, this function will
596613
* return `undefined`. Any other errors will be thrown as exceptions.
614+
*
615+
* @deprecated This function is deprecated in favor of `discoverAuthorizationServerMetadata`.
597616
*/
598617
export async function discoverOAuthMetadata(
599618
issuer: string | URL,
@@ -640,6 +659,219 @@ export async function discoverOAuthMetadata(
640659
return OAuthMetadataSchema.parse(await response.json());
641660
}
642661

662+
/**
663+
* Discovers authorization server metadata with support for RFC 8414 OAuth 2.0 Authorization Server Metadata
664+
* and OpenID Connect Discovery 1.0 specifications.
665+
*
666+
* This function implements a fallback strategy for authorization server discovery:
667+
* 1. If `authorizationServerUrl` is provided, attempts RFC 8414 OAuth metadata discovery first
668+
* 2. If OAuth discovery fails, falls back to OpenID Connect Discovery
669+
* 3. If `authorizationServerUrl` is not provided, uses legacy MCP specification behavior
670+
*
671+
* @param serverUrl - The MCP Server URL, used for legacy specification support where the MCP server
672+
* acts as both the resource server and authorization server
673+
* @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's
674+
* protected resource metadata. If this parameter is `undefined`,
675+
* it indicates that protected resource metadata was not successfully
676+
* retrieved, triggering legacy fallback behavior
677+
* @param options - Configuration options
678+
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
679+
* @param options.protocolVersion - MCP protocol version to use, defaults to LATEST_PROTOCOL_VERSION
680+
* @returns Promise resolving to authorization server metadata, or undefined if discovery fails
681+
*/
682+
export async function discoverAuthorizationServerMetadata(
683+
serverUrl: string | URL,
684+
authorizationServerUrl?: string | URL,
685+
{
686+
fetchFn = fetch,
687+
protocolVersion = LATEST_PROTOCOL_VERSION,
688+
}: {
689+
fetchFn?: FetchLike;
690+
protocolVersion?: string;
691+
} = {}
692+
): Promise<AuthorizationServerMetadata | undefined> {
693+
if (!authorizationServerUrl) {
694+
// Legacy support: MCP servers act as the Auth server.
695+
return retrieveOAuthMetadataFromMcpServer(serverUrl, {
696+
fetchFn,
697+
protocolVersion,
698+
});
699+
}
700+
701+
const oauthMetadata = await retrieveOAuthMetadataFromAuthorizationServer(authorizationServerUrl, {
702+
fetchFn,
703+
protocolVersion,
704+
});
705+
706+
if (oauthMetadata) {
707+
return oauthMetadata;
708+
}
709+
710+
return retrieveOpenIdProviderMetadataFromAuthorizationServer(authorizationServerUrl, {
711+
fetchFn,
712+
protocolVersion,
713+
});
714+
}
715+
716+
/**
717+
* Legacy implementation where the MCP server acts as the Auth server.
718+
* According to MCP spec version 2025-03-26.
719+
*
720+
* @param serverUrl - The MCP Server URL
721+
* @param options - Configuration options
722+
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
723+
* @param options.protocolVersion - MCP protocol version to use (required)
724+
* @returns Promise resolving to OAuth metadata
725+
*/
726+
async function retrieveOAuthMetadataFromMcpServer(
727+
serverUrl: string | URL,
728+
{
729+
fetchFn = fetch,
730+
protocolVersion,
731+
}: {
732+
fetchFn?: FetchLike;
733+
protocolVersion: string;
734+
}
735+
): Promise<OAuthMetadata> {
736+
const serverOrigin = typeof serverUrl === 'string' ? new URL(serverUrl).origin : serverUrl.origin;
737+
738+
const metadataEndpoint = new URL(buildWellKnownPath('oauth-authorization-server'), serverOrigin);
739+
740+
const response = await fetchWithCorsRetry(metadataEndpoint, getProtocolVersionHeader(protocolVersion), fetchFn);
741+
742+
if (!response) {
743+
throw new Error(`CORS error trying to load OAuth metadata from ${metadataEndpoint}`);
744+
}
745+
746+
if (!response.ok) {
747+
if (response.status === 404) {
748+
/**
749+
* The MCP server does not implement OAuth 2.0 Authorization Server Metadata
750+
*
751+
* Return fallback OAuth 2.0 Authorization Server Metadata
752+
*/
753+
return {
754+
issuer: serverOrigin,
755+
authorization_endpoint: new URL('/authorize', serverOrigin).href,
756+
token_endpoint: new URL('/token', serverOrigin).href,
757+
registration_endpoint: new URL('/register', serverOrigin).href,
758+
response_types_supported: ['code'],
759+
code_challenge_methods_supported: ['S256'],
760+
};
761+
}
762+
763+
throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${metadataEndpoint}`);
764+
}
765+
766+
return OAuthMetadataSchema.parse(await response.json());
767+
}
768+
769+
/**
770+
* Retrieves RFC 8414 OAuth 2.0 Authorization Server Metadata from the authorization server.
771+
*
772+
* @param authorizationServerUrl - The authorization server URL
773+
* @param options - Configuration options
774+
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
775+
* @param options.protocolVersion - MCP protocol version to use (required)
776+
* @returns Promise resolving to OAuth metadata, or undefined if discovery fails
777+
*/
778+
async function retrieveOAuthMetadataFromAuthorizationServer(
779+
authorizationServerUrl: string | URL,
780+
{
781+
fetchFn = fetch,
782+
protocolVersion,
783+
}: {
784+
fetchFn?: FetchLike;
785+
protocolVersion: string;
786+
}
787+
): Promise<OAuthMetadata | undefined> {
788+
const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl;
789+
790+
const hasPath = url.pathname !== '/';
791+
792+
const metadataEndpoint = new URL(
793+
buildWellKnownPath('oauth-authorization-server', hasPath ? url.pathname : ''),
794+
url.origin
795+
);
796+
797+
const response = await fetchWithCorsRetry(metadataEndpoint, getProtocolVersionHeader(protocolVersion), fetchFn);
798+
799+
if (!response) {
800+
throw new Error(`CORS error trying to load OAuth metadata from ${metadataEndpoint}`);
801+
}
802+
803+
if (!response.ok) {
804+
if (response.status === 404) {
805+
return undefined;
806+
}
807+
808+
throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${metadataEndpoint}`);
809+
}
810+
811+
return OAuthMetadataSchema.parse(await response.json());
812+
}
813+
814+
/**
815+
* Retrieves OpenID Connect Discovery 1.0 metadata from the authorization server.
816+
*
817+
* @param authorizationServerUrl - The authorization server URL
818+
* @param options - Configuration options
819+
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
820+
* @param options.protocolVersion - MCP protocol version to use (required)
821+
* @returns Promise resolving to OpenID provider metadata, or undefined if discovery fails
822+
*/
823+
async function retrieveOpenIdProviderMetadataFromAuthorizationServer(
824+
authorizationServerUrl: string | URL,
825+
{
826+
fetchFn = fetch,
827+
protocolVersion,
828+
}: {
829+
fetchFn?: FetchLike;
830+
protocolVersion: string;
831+
}
832+
): Promise<OpenIdProviderDiscoveryMetadata | undefined> {
833+
const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl;
834+
const hasPath = url.pathname !== '/';
835+
836+
const potentialMetadataEndpoints = hasPath
837+
? [
838+
// https://example.com/.well-known/openid-configuration/tenant1
839+
new URL(buildWellKnownPath('openid-configuration', url.pathname), url.origin),
840+
// https://example.com/tenant1/.well-known/openid-configuration
841+
new URL(buildWellKnownPath('openid-configuration', url.pathname, { prependPathname: true }), `${url.origin}`),
842+
]
843+
: [
844+
// https://example.com/.well-known/openid-configuration
845+
new URL(buildWellKnownPath('openid-configuration'), url.origin),
846+
];
847+
848+
for (const endpoint of potentialMetadataEndpoints) {
849+
const response = await fetchWithCorsRetry(endpoint, getProtocolVersionHeader(protocolVersion), fetchFn);
850+
851+
if (!response) {
852+
throw new Error(`CORS error trying to load OpenID provider metadata from ${endpoint}`);
853+
}
854+
855+
if (!response.ok) {
856+
if (response.status === 404) {
857+
continue;
858+
}
859+
860+
throw new Error(`HTTP ${response.status} trying to load OpenID provider metadata from ${endpoint}`);
861+
}
862+
863+
return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
864+
}
865+
866+
return undefined;
867+
}
868+
869+
function getProtocolVersionHeader(protocolVersion: string): Record<string, string> {
870+
return {
871+
'MCP-Protocol-Version': protocolVersion,
872+
};
873+
}
874+
643875
/**
644876
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
645877
*/
@@ -653,7 +885,7 @@ export async function startAuthorization(
653885
state,
654886
resource,
655887
}: {
656-
metadata?: OAuthMetadata;
888+
metadata?: AuthorizationServerMetadata;
657889
clientInformation: OAuthClientInformation;
658890
redirectUrl: string | URL;
659891
scope?: string;
@@ -746,7 +978,7 @@ export async function exchangeAuthorization(
746978
addClientAuthentication,
747979
fetchFn,
748980
}: {
749-
metadata?: OAuthMetadata;
981+
metadata?: AuthorizationServerMetadata;
750982
clientInformation: OAuthClientInformation;
751983
authorizationCode: string;
752984
codeVerifier: string;
@@ -831,7 +1063,7 @@ export async function refreshAuthorization(
8311063
addClientAuthentication,
8321064
fetchFn,
8331065
}: {
834-
metadata?: OAuthMetadata;
1066+
metadata?: AuthorizationServerMetadata;
8351067
clientInformation: OAuthClientInformation;
8361068
refreshToken: string;
8371069
resource?: URL;
@@ -902,7 +1134,7 @@ export async function registerClient(
9021134
clientMetadata,
9031135
fetchFn,
9041136
}: {
905-
metadata?: OAuthMetadata;
1137+
metadata?: AuthorizationServerMetadata;
9061138
clientMetadata: OAuthClientMetadata;
9071139
fetchFn?: FetchLike;
9081140
},

0 commit comments

Comments
 (0)