inspector/client/src/lib/auth.ts

301 lines
8.8 KiB
TypeScript

import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthClientInformationSchema,
OAuthClientInformation,
OAuthTokens,
OAuthTokensSchema,
OAuthClientMetadata,
OAuthMetadata,
OAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
import { generateOAuthState } from "@/utils/oauthUtils";
import { validateRedirectUrl } from "@/utils/urlValidation";
/**
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
* @param serverUrl - The MCP server URL
* @param resourceMetadata - Optional resource metadata containing preferred scopes
* @returns Promise resolving to space-separated scope string or undefined
*/
export const discoverScopes = async (
serverUrl: string,
resourceMetadata?: OAuthProtectedResourceMetadata,
): Promise<string | undefined> => {
try {
const metadata = await discoverAuthorizationServerMetadata(
new URL("/", serverUrl),
);
// Prefer resource metadata scopes, but fall back to OAuth metadata if empty
const resourceScopes = resourceMetadata?.scopes_supported;
const oauthScopes = metadata?.scopes_supported;
const scopesSupported =
resourceScopes && resourceScopes.length > 0
? resourceScopes
: oauthScopes;
return scopesSupported && scopesSupported.length > 0
? scopesSupported.join(" ")
: undefined;
} catch (error) {
console.debug("OAuth scope discovery failed:", error);
return undefined;
}
};
export const getClientInformationFromSessionStorage = async ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
};
export const saveClientInformationToSessionStorage = ({
serverUrl,
clientInformation,
isPreregistered,
}: {
serverUrl: string;
clientInformation: OAuthClientInformation;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
};
export const clearClientInformationFromSessionStorage = ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.removeItem(key);
};
export const getScopeFromSessionStorage = (
serverUrl: string,
): string | undefined => {
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
const value = sessionStorage.getItem(key);
return value || undefined;
};
export const saveScopeToSessionStorage = (
serverUrl: string,
scope: string | undefined,
) => {
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
if (scope) {
sessionStorage.setItem(key, scope);
} else {
sessionStorage.removeItem(key);
}
};
export const clearScopeFromSessionStorage = (serverUrl: string) => {
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
sessionStorage.removeItem(key);
};
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(protected serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
get scope(): string | undefined {
return getScopeFromSessionStorage(this.serverUrl);
}
get redirectUrl() {
return window.location.origin + "/oauth/callback";
}
get debugRedirectUrl() {
return window.location.origin + "/oauth/callback/debug";
}
get redirect_uris() {
// Always register both redirect URIs to support both normal and debug flows.
// Use the base URLs directly (not this.redirectUrl) to avoid the debug subclass
// override collapsing both URIs into one, which causes the normal flow's
// /authorize request to use an unregistered redirect_uri.
// See: https://github.com/modelcontextprotocol/inspector/issues/825
// See: https://github.com/modelcontextprotocol/inspector/issues/930
const callbackUrl = window.location.origin + "/oauth/callback";
const debugCallbackUrl = window.location.origin + "/oauth/callback/debug";
return [callbackUrl, debugCallbackUrl];
}
get clientMetadata(): OAuthClientMetadata {
const metadata: OAuthClientMetadata = {
redirect_uris: this.redirect_uris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector",
};
// Only include scope if it's defined and non-empty
// Per OAuth spec, omit the scope field entirely if no scopes are requested
if (this.scope) {
metadata.scope = this.scope;
}
return metadata;
}
state(): string | Promise<string> {
return generateOAuthState();
}
async clientInformation() {
// Try to get the preregistered client information from session storage first
const preregisteredClientInformation =
await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: true,
});
// If no preregistered client information is found, get the dynamically registered client information
return (
preregisteredClientInformation ??
(await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
}))
);
}
saveClientInformation(clientInformation: OAuthClientInformation) {
// Save the dynamically registered client information to session storage
saveClientInformationToSessionStorage({
serverUrl: this.serverUrl,
clientInformation,
isPreregistered: false,
});
}
async tokens() {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
const tokens = sessionStorage.getItem(key);
if (!tokens) {
return undefined;
}
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
}
saveTokens(tokens: OAuthTokens) {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
sessionStorage.setItem(key, JSON.stringify(tokens));
}
redirectToAuthorization(authorizationUrl: URL) {
// Validate the URL using the shared utility
validateRedirectUrl(authorizationUrl.href);
window.location.href = authorizationUrl.href;
}
saveCodeVerifier(codeVerifier: string) {
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
sessionStorage.setItem(key, codeVerifier);
}
codeVerifier() {
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
const verifier = sessionStorage.getItem(key);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
clear() {
clearClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
});
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
);
}
}
// Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to
// display in debug UI.
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
get redirectUrl(): string {
// We can use the debug redirect URL here because it was already registered
// in the parent class's clientMetadata along with the normal redirect URL
return this.debugRedirectUrl;
}
saveServerMetadata(metadata: OAuthMetadata) {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(metadata));
}
getServerMetadata(): OAuthMetadata | null {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
const metadata = sessionStorage.getItem(key);
if (!metadata) {
return null;
}
return JSON.parse(metadata);
}
clear() {
super.clear();
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
);
}
}