156 lines
5 KiB
TypeScript
156 lines
5 KiB
TypeScript
/**
|
|
* v2-shaped OAuth orchestrator for standard MCP resource authorization.
|
|
*
|
|
* **Upgrade path:** On `@modelcontextprotocol/client` v2, replace this module
|
|
* with `export { auth as mcpAuth, type AuthOptions as McpAuthOptions } from
|
|
* "@modelcontextprotocol/client"` and delete `authorizeWithoutRefresh`.
|
|
*
|
|
* Until then, `mcpAuth` delegates to SDK v1 `auth()` except when
|
|
* `forceReauthorization: true` — then it runs discovery + `startAuthorization`
|
|
* (the same path v2 `auth()` takes when refresh cannot widen scope).
|
|
*/
|
|
|
|
import {
|
|
auth as sdkAuth,
|
|
discoverOAuthServerInfo,
|
|
isHttpsUrl,
|
|
registerClient,
|
|
selectResourceURL,
|
|
startAuthorization,
|
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
|
|
export type McpAuthResult = "AUTHORIZED" | "REDIRECT";
|
|
|
|
/**
|
|
* Options aligned with v2 SDK `AuthOptions`. v2-only fields are accepted now
|
|
* and forwarded on upgrade; v1 passthrough ignores unsupported fields.
|
|
*/
|
|
export interface McpAuthOptions {
|
|
serverUrl: string | URL;
|
|
authorizationCode?: string;
|
|
/** RFC 9207 callback `iss` — forwarded on v2 upgrade; ignored by v1 SDK `auth()`. */
|
|
iss?: string;
|
|
scope?: string;
|
|
resourceMetadataUrl?: URL;
|
|
fetchFn?: FetchLike;
|
|
/** v2 SEP-2468 — ignored until SDK v2 upgrade. */
|
|
skipIssuerMetadataValidation?: boolean;
|
|
/**
|
|
* Skip refresh and start an authorization-code flow. Required for step-up
|
|
* when the union scope exceeds the current token grant (RFC 6749 §6).
|
|
*/
|
|
forceReauthorization?: boolean;
|
|
}
|
|
|
|
export async function mcpAuth(
|
|
provider: OAuthClientProvider,
|
|
options: McpAuthOptions,
|
|
): Promise<McpAuthResult> {
|
|
if (options.forceReauthorization) {
|
|
if (options.authorizationCode !== undefined) {
|
|
throw new Error(
|
|
"forceReauthorization cannot be combined with authorizationCode",
|
|
);
|
|
}
|
|
return authorizeWithoutRefresh(provider, options);
|
|
}
|
|
|
|
return sdkAuth(provider, {
|
|
serverUrl: options.serverUrl,
|
|
authorizationCode: options.authorizationCode,
|
|
scope: options.scope,
|
|
resourceMetadataUrl: options.resourceMetadataUrl,
|
|
fetchFn: options.fetchFn,
|
|
});
|
|
}
|
|
|
|
async function authorizeWithoutRefresh(
|
|
provider: OAuthClientProvider,
|
|
options: McpAuthOptions,
|
|
): Promise<"REDIRECT"> {
|
|
const { serverUrl, scope, resourceMetadataUrl, fetchFn } = options;
|
|
|
|
const serverInfo = await discoverOAuthServerInfo(serverUrl, {
|
|
resourceMetadataUrl,
|
|
fetchFn,
|
|
});
|
|
const {
|
|
authorizationServerUrl,
|
|
authorizationServerMetadata: metadata,
|
|
resourceMetadata,
|
|
} = serverInfo;
|
|
|
|
await provider.saveDiscoveryState?.({
|
|
authorizationServerUrl: String(authorizationServerUrl),
|
|
resourceMetadataUrl: resourceMetadataUrl?.toString(),
|
|
resourceMetadata,
|
|
authorizationServerMetadata: metadata,
|
|
});
|
|
|
|
const resource = await selectResourceURL(
|
|
serverUrl,
|
|
provider,
|
|
resourceMetadata,
|
|
);
|
|
|
|
const resolvedScope =
|
|
scope ||
|
|
resourceMetadata?.scopes_supported?.join(" ") ||
|
|
provider.clientMetadata.scope;
|
|
|
|
let clientInformation = await Promise.resolve(provider.clientInformation());
|
|
if (!clientInformation) {
|
|
const supportsUrlBasedClientId =
|
|
metadata?.client_id_metadata_document_supported === true;
|
|
const clientMetadataUrl = provider.clientMetadataUrl;
|
|
if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) {
|
|
throw new InvalidClientMetadataError(
|
|
`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`,
|
|
);
|
|
}
|
|
const shouldUseUrlBasedClientId =
|
|
supportsUrlBasedClientId && clientMetadataUrl;
|
|
if (shouldUseUrlBasedClientId) {
|
|
clientInformation = { client_id: clientMetadataUrl };
|
|
await provider.saveClientInformation?.(clientInformation);
|
|
} else {
|
|
if (!provider.saveClientInformation) {
|
|
throw new Error(
|
|
"OAuth client information must be saveable for dynamic registration",
|
|
);
|
|
}
|
|
const fullInformation = await registerClient(authorizationServerUrl, {
|
|
metadata,
|
|
clientMetadata: provider.clientMetadata,
|
|
scope: resolvedScope,
|
|
fetchFn,
|
|
});
|
|
await provider.saveClientInformation(fullInformation);
|
|
clientInformation = fullInformation;
|
|
}
|
|
}
|
|
|
|
const state = provider.state ? await provider.state() : undefined;
|
|
const redirectUrl = provider.redirectUrl;
|
|
if (!redirectUrl) {
|
|
throw new Error("redirectUrl is required for authorization_code flow");
|
|
}
|
|
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
|
authorizationServerUrl,
|
|
{
|
|
metadata,
|
|
clientInformation,
|
|
state,
|
|
redirectUrl,
|
|
scope: resolvedScope,
|
|
resource,
|
|
},
|
|
);
|
|
|
|
await provider.saveCodeVerifier(codeVerifier);
|
|
await provider.redirectToAuthorization(authorizationUrl);
|
|
return "REDIRECT";
|
|
}
|