Gemini 2.5 refactor
This commit is contained in:
parent
6e83fa8955
commit
414e9e41fb
3 changed files with 1406 additions and 1051 deletions
278
src/auth/browser-provider.ts
Normal file
278
src/auth/browser-provider.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { StoredState } from './types.js';
|
||||
|
||||
/**
|
||||
* Browser-compatible OAuth client provider for MCP using localStorage.
|
||||
* Handles storing client information, tokens, and managing the popup/redirect flow.
|
||||
*/
|
||||
export class BrowserOAuthClientProvider implements OAuthClientProvider {
|
||||
private storageKeyPrefix: string;
|
||||
private serverUrlHash: string;
|
||||
private clientName: string;
|
||||
private clientUri: string;
|
||||
private callbackUrl: string;
|
||||
|
||||
/**
|
||||
* Creates an instance of BrowserOAuthClientProvider.
|
||||
* @param serverUrl The base URL of the MCP server requiring authentication.
|
||||
* @param options Configuration options for the provider.
|
||||
*/
|
||||
constructor(
|
||||
readonly serverUrl: string,
|
||||
options: {
|
||||
storageKeyPrefix?: string;
|
||||
clientName?: string;
|
||||
clientUri?: string;
|
||||
callbackUrl?: string;
|
||||
} = {},
|
||||
) {
|
||||
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth';
|
||||
// Hash the server URL to create unique storage keys per server
|
||||
this.serverUrlHash = this.hashString(serverUrl);
|
||||
this.clientName = options.clientName || 'MCP Browser Client';
|
||||
this.clientUri = options.clientUri || window.location.origin;
|
||||
// Default callback URL is /oauth/callback on the current origin
|
||||
this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The redirect URL used for the OAuth flow.
|
||||
*/
|
||||
get redirectUrl(): string {
|
||||
return this.callbackUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about this client, sent during dynamic client registration (if supported by the server).
|
||||
*/
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: 'none', // Public client
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
client_name: this.clientName,
|
||||
client_uri: this.clientUri,
|
||||
// scope: 'openid profile email mcp', // Example scopes, adjust as needed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all localStorage items associated with this specific server URL.
|
||||
* @returns The number of items removed from storage.
|
||||
*/
|
||||
clearStorage(): number {
|
||||
const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}`;
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
// Find keys directly related to this server instance
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(prefix)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check any persisted OAuth state keys
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(`${this.storageKeyPrefix}:state_`)) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item) {
|
||||
const state = JSON.parse(item) as Partial<StoredState>;
|
||||
// If state belongs to this server, mark for removal
|
||||
if (state.serverUrlHash === this.serverUrlHash) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Error parsing state key ${key}:`, e);
|
||||
// Optionally remove malformed keys
|
||||
// keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all identified keys
|
||||
const uniqueKeysToRemove = [...new Set(keysToRemove)]; // Ensure uniqueness
|
||||
uniqueKeysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
|
||||
return uniqueKeysToRemove.length;
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
// Simple, non-cryptographic hash function suitable for creating unique keys
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
// Use absolute value and convert to hex for a cleaner key
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
private getKey(key: string): string {
|
||||
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves client information (like client_id) from storage.
|
||||
* @returns The stored client information, or undefined if not found/invalid.
|
||||
*/
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
const key = this.getKey('client_info');
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return undefined;
|
||||
|
||||
try {
|
||||
// TODO: Add validation using a schema (e.g., Zod)
|
||||
return JSON.parse(data) as OAuthClientInformation;
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Failed to parse client information from storage:`, e);
|
||||
localStorage.removeItem(key); // Clean up invalid data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves client information to storage.
|
||||
* @param clientInformation The client information to save.
|
||||
*/
|
||||
async saveClientInformation(clientInformation: OAuthClientInformation): Promise<void> {
|
||||
const key = this.getKey('client_info');
|
||||
localStorage.setItem(key, JSON.stringify(clientInformation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves OAuth tokens (access token, refresh token) from storage.
|
||||
* @returns The stored tokens, or undefined if not found/invalid.
|
||||
*/
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
const key = this.getKey('tokens');
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return undefined;
|
||||
|
||||
try {
|
||||
// TODO: Add validation using a schema (e.g., Zod)
|
||||
const tokens = JSON.parse(data) as OAuthTokens;
|
||||
// Optional: Check token expiry here if 'expires_at' is stored
|
||||
return tokens;
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Failed to parse tokens from storage:`, e);
|
||||
localStorage.removeItem(key); // Clean up invalid data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves OAuth tokens to storage.
|
||||
* @param tokens The tokens to save.
|
||||
*/
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const key = this.getKey('tokens');
|
||||
// Optional: Calculate and store expiry timestamp:
|
||||
// const storedTokens = { ...tokens, expires_at: Date.now() + (tokens.expires_in || 0) * 1000 };
|
||||
localStorage.setItem(key, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the OAuth authorization flow by opening a popup window.
|
||||
* Stores necessary state in localStorage for the callback handler.
|
||||
* @param authorizationUrl The fully constructed authorization URL (including client_id, redirect_uri, scope, code_challenge, etc.).
|
||||
* @param metadata The discovered OAuth metadata of the server.
|
||||
* @param options Configuration for the popup window.
|
||||
* @returns An object indicating success, whether the popup was potentially blocked, and the URL used.
|
||||
*/
|
||||
async redirectToAuthorization(
|
||||
authorizationUrl: URL,
|
||||
metadata: OAuthMetadata,
|
||||
options?: {
|
||||
popupFeatures?: string;
|
||||
},
|
||||
): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> {
|
||||
// Generate a unique state parameter for this authorization request
|
||||
const state = crypto.randomUUID(); // Use crypto.randomUUID for better randomness
|
||||
const stateKey = `${this.storageKeyPrefix}:state_${state}`;
|
||||
|
||||
// Store context needed by the callback handler, associated with the state param
|
||||
localStorage.setItem(
|
||||
stateKey,
|
||||
JSON.stringify({
|
||||
authorizationUrl: authorizationUrl.origin, // Store origin for exchangeAuthorization
|
||||
metadata,
|
||||
serverUrlHash: this.serverUrlHash, // Link state back to this server instance
|
||||
expiry: Date.now() + 1000 * 60 * 10, // State expires in 10 minutes
|
||||
} as StoredState),
|
||||
);
|
||||
authorizationUrl.searchParams.set('state', state);
|
||||
|
||||
const authUrlString = authorizationUrl.toString();
|
||||
const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes';
|
||||
|
||||
// Persist the exact auth URL in case the popup fails and manual navigation is needed
|
||||
localStorage.setItem(this.getKey('auth_url'), authUrlString);
|
||||
|
||||
try {
|
||||
// Attempt to open the authorization URL in a new window
|
||||
const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures);
|
||||
|
||||
// Check if the popup window handle was obtained and if it's not immediately closed
|
||||
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
|
||||
// This is the most common indicator of a popup blocker
|
||||
console.warn(`[${this.storageKeyPrefix}] Popup likely blocked by browser.`);
|
||||
return { success: false, popupBlocked: true, url: authUrlString };
|
||||
}
|
||||
|
||||
// Additional check: try accessing a property that might throw if blocked cross-origin
|
||||
// Note: This might not always work due to browser security policies evolution
|
||||
try {
|
||||
// Accessing location.href will likely throw a cross-origin error immediately
|
||||
// if the popup is loading the auth server's domain, which is expected.
|
||||
// What we *really* want to detect is if the popup *failed to open at all*.
|
||||
// The initial check (!popup || popup.closed) is usually sufficient.
|
||||
// Focusing the popup can be a sign it opened successfully.
|
||||
popup.focus();
|
||||
} catch (e) {
|
||||
// If accessing the popup threw an error immediately, it might indicate a deeper issue,
|
||||
// but often it's just a standard cross-origin security restriction.
|
||||
// The initial check is more reliable for detecting simple blocking.
|
||||
console.debug(`[${this.storageKeyPrefix}] Accessing popup properties caused potential cross-origin error (normal):`, e);
|
||||
}
|
||||
|
||||
// If we reached here, the popup handle was obtained and wasn't immediately closed.
|
||||
return { success: true, url: authUrlString };
|
||||
} catch (e) {
|
||||
// Catch any unexpected errors during window.open
|
||||
console.error(`[${this.storageKeyPrefix}] Error opening popup window:`, e);
|
||||
return { success: false, popupBlocked: true, url: authUrlString }; // Assume blocked on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the PKCE code verifier to storage.
|
||||
* @param codeVerifier The code verifier string.
|
||||
*/
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
const key = this.getKey('code_verifier');
|
||||
localStorage.setItem(key, codeVerifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the PKCE code verifier from storage.
|
||||
* @returns The stored code verifier.
|
||||
* @throws If the code verifier is not found in storage.
|
||||
*/
|
||||
async codeVerifier(): Promise<string> {
|
||||
const key = this.getKey('code_verifier');
|
||||
const verifier = localStorage.getItem(key);
|
||||
if (!verifier) {
|
||||
throw new Error(`[${this.storageKeyPrefix}] Code verifier not found in storage for key ${key}. Auth flow likely corrupted or timed out.`);
|
||||
}
|
||||
// Optionally remove the verifier after retrieving it, as it's single-use
|
||||
// localStorage.removeItem(key);
|
||||
return verifier;
|
||||
}
|
||||
}
|
||||
179
src/auth/callback.ts
Normal file
179
src/auth/callback.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { OAuthClientInformation, OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { StoredState } from './types.js';
|
||||
|
||||
/**
|
||||
* Handles the OAuth callback. This function should be invoked on your callback page
|
||||
* (e.g., /oauth/callback) with the URL query parameters.
|
||||
*
|
||||
* It validates the state parameter, retrieves stored context from localStorage,
|
||||
* exchanges the authorization code for tokens using the stored code verifier,
|
||||
* saves the tokens, and notifies the opener window.
|
||||
*
|
||||
* @param query The URL query parameters (e.g., from `window.location.search`).
|
||||
* @param options Configuration options.
|
||||
* @param options.storageKeyPrefix The prefix used for localStorage keys (must match the one used by `BrowserOAuthClientProvider`). Defaults to "mcp:auth".
|
||||
* @returns An object indicating success or failure with an error message.
|
||||
*/
|
||||
export async function onMcpAuthorization(
|
||||
query: Record<string, string>,
|
||||
{
|
||||
storageKeyPrefix = 'mcp:auth',
|
||||
}: {
|
||||
storageKeyPrefix?: string;
|
||||
} = {},
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const logPrefix = `[${storageKeyPrefix}-callback]`;
|
||||
console.log(`${logPrefix} Handling OAuth callback with query:`, query);
|
||||
|
||||
try {
|
||||
const code = query.code;
|
||||
const state = query.state;
|
||||
const error = query.error;
|
||||
const errorDescription = query.error_description;
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error} - ${errorDescription || 'No description provided.'}`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('Authorization code not found in callback query parameters.');
|
||||
}
|
||||
if (!state) {
|
||||
throw new Error('State parameter not found in callback query parameters.');
|
||||
}
|
||||
|
||||
// Retrieve the stored state using the state parameter from the query
|
||||
const stateKey = `${storageKeyPrefix}:state_${state}`;
|
||||
const storedStateJSON = localStorage.getItem(stateKey);
|
||||
// Remove the state key immediately after retrieval (it's single-use)
|
||||
localStorage.removeItem(stateKey);
|
||||
|
||||
if (!storedStateJSON) {
|
||||
throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`);
|
||||
}
|
||||
|
||||
let storedState: StoredState;
|
||||
try {
|
||||
storedState = JSON.parse(storedStateJSON);
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse stored OAuth state.');
|
||||
}
|
||||
|
||||
// Validate expiry
|
||||
if (storedState.expiry < Date.now()) {
|
||||
throw new Error('OAuth state has expired. Please try initiating authentication again.');
|
||||
}
|
||||
|
||||
const { authorizationUrl, serverUrlHash, metadata } = storedState;
|
||||
console.log(`${logPrefix} Found valid state for server hash: ${serverUrlHash}`);
|
||||
|
||||
// Construct keys to retrieve client info and code verifier for this server instance
|
||||
const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`;
|
||||
const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`;
|
||||
const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`;
|
||||
const authUrlKey = `${storageKeyPrefix}_${serverUrlHash}_auth_url`; // Key for the potentially stored manual auth URL
|
||||
|
||||
const clientInfoStr = localStorage.getItem(clientInfoKey);
|
||||
const codeVerifier = localStorage.getItem(codeVerifierKey);
|
||||
// Remove the code verifier after retrieving it
|
||||
localStorage.removeItem(codeVerifierKey);
|
||||
|
||||
if (!clientInfoStr) {
|
||||
throw new Error(`Client information not found in storage (key: ${clientInfoKey}).`);
|
||||
}
|
||||
if (!codeVerifier) {
|
||||
throw new Error(`Code verifier not found in storage (key: ${codeVerifierKey}). Auth flow may be incomplete or timed out.`);
|
||||
}
|
||||
|
||||
let clientInfo: OAuthClientInformation;
|
||||
try {
|
||||
clientInfo = JSON.parse(clientInfoStr);
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse stored client information.');
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Exchanging authorization code for token...`);
|
||||
|
||||
// Exchange the code for tokens
|
||||
const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
|
||||
// Use token_endpoint from metadata
|
||||
metadata,
|
||||
clientInformation: clientInfo,
|
||||
authorizationCode: code,
|
||||
codeVerifier,
|
||||
// redirectUrl is typically required by the token endpoint for verification
|
||||
redirectUrl: new URL('/oauth/callback', window.location.origin).toString(), // TODO: Make this configurable or get from provider instance?
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Token exchange successful. Saving tokens...`);
|
||||
|
||||
// Save the obtained tokens
|
||||
localStorage.setItem(tokensKey, JSON.stringify(tokens));
|
||||
|
||||
// Clean up the persisted manual auth URL if it exists
|
||||
localStorage.removeItem(authUrlKey);
|
||||
|
||||
console.log(`${logPrefix} Tokens saved. Notifying opener window.`);
|
||||
|
||||
// Notify the original window (opener) that authentication succeeded
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: 'mcp_auth_callback',
|
||||
success: true,
|
||||
// Optionally pass serverUrlHash if multiple hooks might be listening
|
||||
serverUrlHash: serverUrlHash,
|
||||
},
|
||||
window.location.origin, // Target origin must match the opener's origin
|
||||
);
|
||||
// Close the popup window
|
||||
window.close();
|
||||
} else {
|
||||
// If there's no opener, this might be a full page redirect flow.
|
||||
// Redirect back to the app's main page or a specific post-auth page.
|
||||
console.warn(`${logPrefix} No opener window detected. Assuming redirect flow. Redirecting to root.`);
|
||||
window.location.href = '/'; // Adjust as needed
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error(`${logPrefix} Error during OAuth callback handling:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Attempt to notify the opener window about the failure
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: 'mcp_auth_callback',
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
// Optionally pass serverUrlHash
|
||||
// serverUrlHash: storedState?.serverUrlHash // Might not be available if state parsing failed
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
|
||||
// Display error in the callback window itself for better debugging
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h1>Authentication Error</h1>
|
||||
<p style="color: red; background-color: #ffebeb; border: 1px solid red; padding: 10px; border-radius: 4px;">
|
||||
${errorMessage}
|
||||
</p>
|
||||
<p>You can close this window.</p>
|
||||
<pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${
|
||||
err instanceof Error ? err.stack : ''
|
||||
}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (displayError) {
|
||||
// Fallback if document.body is not available or writable
|
||||
console.error(`${logPrefix} Could not display error in callback window:`, displayError);
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
12
src/auth/types.ts
Normal file
12
src/auth/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
|
||||
/**
|
||||
* Internal type for storing OAuth state in localStorage during the popup flow.
|
||||
* @internal
|
||||
*/
|
||||
export type StoredState = {
|
||||
authorizationUrl: string;
|
||||
metadata: OAuthMetadata;
|
||||
serverUrlHash: string; // To associate state with a specific server URL
|
||||
expiry: number; // Timestamp when the state expires
|
||||
};
|
||||
Loading…
Reference in a new issue