Skip to content

Commit a3fe322

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

File tree

1 file changed

+247
-16
lines changed

1 file changed

+247
-16
lines changed

src/client/auth.ts

Lines changed: 247 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,218 @@ 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+
};
760+
}
761+
762+
throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${metadataEndpoint}`);
763+
}
764+
765+
return OAuthMetadataSchema.parse(await response.json());
766+
}
767+
768+
/**
769+
* Retrieves RFC 8414 OAuth 2.0 Authorization Server Metadata from the authorization server.
770+
*
771+
* @param authorizationServerUrl - The authorization server URL
772+
* @param options - Configuration options
773+
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
774+
* @param options.protocolVersion - MCP protocol version to use (required)
775+
* @returns Promise resolving to OAuth metadata, or undefined if discovery fails
776+
*/
777+
async function retrieveOAuthMetadataFromAuthorizationServer(
778+
authorizationServerUrl: string | URL,
779+
{
780+
fetchFn = fetch,
781+
protocolVersion,
782+
}: {
783+
fetchFn?: FetchLike;
784+
protocolVersion: string;
785+
}
786+
): Promise<OAuthMetadata | undefined> {
787+
const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl;
788+
789+
const hasPath = url.pathname !== '/';
790+
791+
const metadataEndpoint = new URL(
792+
buildWellKnownPath('oauth-authorization-server', hasPath ? url.pathname : ''),
793+
url.origin
794+
);
795+
796+
const response = await fetchWithCorsRetry(metadataEndpoint, getProtocolVersionHeader(protocolVersion), fetchFn);
797+
798+
if (!response) {
799+
throw new Error(`CORS error trying to load OAuth metadata from ${metadataEndpoint}`);
800+
}
801+
802+
if (!response.ok) {
803+
if (response.status === 404) {
804+
return undefined;
805+
}
806+
807+
throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${metadataEndpoint}`);
808+
}
809+
810+
return OAuthMetadataSchema.parse(await response.json());
811+
}
812+
813+
/**
814+
* Retrieves OpenID Connect Discovery 1.0 metadata from the authorization server.
815+
*
816+
* @param authorizationServerUrl - The authorization server URL
817+
* @param options - Configuration options
818+
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
819+
* @param options.protocolVersion - MCP protocol version to use (required)
820+
* @returns Promise resolving to OpenID provider metadata, or undefined if discovery fails
821+
*/
822+
async function retrieveOpenIdProviderMetadataFromAuthorizationServer(
823+
authorizationServerUrl: string | URL,
824+
{
825+
fetchFn = fetch,
826+
protocolVersion,
827+
}: {
828+
fetchFn?: FetchLike;
829+
protocolVersion: string;
830+
}
831+
): Promise<OpenIdProviderDiscoveryMetadata | undefined> {
832+
const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl;
833+
const hasPath = url.pathname !== '/';
834+
835+
const potentialMetadataEndpoints = hasPath
836+
? [
837+
// https://example.com/.well-known/openid-configuration/tenant1
838+
new URL(buildWellKnownPath('openid-configuration', url.pathname), url.origin),
839+
// https://example.com/tenant1/.well-known/openid-configuration
840+
new URL(buildWellKnownPath('openid-configuration', url.pathname, { prependPathname: true }), `${url.origin}`),
841+
]
842+
: [
843+
// https://example.com/.well-known/openid-configuration
844+
new URL(buildWellKnownPath('openid-configuration'), url.origin),
845+
];
846+
847+
for (const endpoint of potentialMetadataEndpoints) {
848+
const response = await fetchWithCorsRetry(endpoint, getProtocolVersionHeader(protocolVersion), fetchFn);
849+
850+
if (!response) {
851+
throw new Error(`CORS error trying to load OpenID provider metadata from ${endpoint}`);
852+
}
853+
854+
if (!response.ok) {
855+
if (response.status === 404) {
856+
continue;
857+
}
858+
859+
throw new Error(`HTTP ${response.status} trying to load OpenID provider metadata from ${endpoint}`);
860+
}
861+
862+
return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
863+
}
864+
865+
return undefined;
866+
}
867+
868+
function getProtocolVersionHeader(protocolVersion: string): Record<string, string> {
869+
return {
870+
'MCP-Protocol-Version': protocolVersion,
871+
};
872+
}
873+
643874
/**
644875
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
645876
*/
@@ -653,7 +884,7 @@ export async function startAuthorization(
653884
state,
654885
resource,
655886
}: {
656-
metadata?: OAuthMetadata;
887+
metadata?: AuthorizationServerMetadata;
657888
clientInformation: OAuthClientInformation;
658889
redirectUrl: string | URL;
659890
scope?: string;
@@ -746,7 +977,7 @@ export async function exchangeAuthorization(
746977
addClientAuthentication,
747978
fetchFn,
748979
}: {
749-
metadata?: OAuthMetadata;
980+
metadata?: AuthorizationServerMetadata;
750981
clientInformation: OAuthClientInformation;
751982
authorizationCode: string;
752983
codeVerifier: string;
@@ -831,7 +1062,7 @@ export async function refreshAuthorization(
8311062
addClientAuthentication,
8321063
fetchFn,
8331064
}: {
834-
metadata?: OAuthMetadata;
1065+
metadata?: AuthorizationServerMetadata;
8351066
clientInformation: OAuthClientInformation;
8361067
refreshToken: string;
8371068
resource?: URL;
@@ -902,7 +1133,7 @@ export async function registerClient(
9021133
clientMetadata,
9031134
fetchFn,
9041135
}: {
905-
metadata?: OAuthMetadata;
1136+
metadata?: AuthorizationServerMetadata;
9061137
clientMetadata: OAuthClientMetadata;
9071138
fetchFn?: FetchLike;
9081139
},

0 commit comments

Comments
 (0)