import type { ClientInfo, AuthRequest } from '@cloudflare/workers-oauth-provider' /** * Configuration for the approval dialog */ export interface ApprovalDialogOptions { /** * Client information to display in the approval dialog */ client: ClientInfo | null /** * Server information to display in the approval dialog */ server: { name: string logo?: string description?: string } /** * Arbitrary state data to pass through the approval flow * Will be encoded in the form and returned when approval is complete */ state: Record /** * Name of the cookie to use for storing approvals * @default "mcp_approved_clients" */ cookieName?: string /** * Secret used to sign cookies for verification * Can be a string or Uint8Array * @default Built-in Uint8Array key */ cookieSecret?: string | Uint8Array /** * Cookie domain * @default current domain */ cookieDomain?: string /** * Cookie path * @default "/" */ cookiePath?: string /** * Cookie max age in seconds * @default 30 days */ cookieMaxAge?: number } /** * Renders an approval dialog for OAuth authorization * The dialog displays information about the client and server * and includes a form to submit approval * * @param request - The HTTP request * @param options - Configuration for the approval dialog * @returns A Response containing the HTML approval dialog */ export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response { const { server, state } = options; const encodedState = btoa(JSON.stringify(state)); const serverName = sanitizeHtml(server.name); const mcpLogoUrl = 'https://raw.githubusercontent.com/thoughtspot/mcp-server/refs/heads/main/static/MCP%20Server%20Logo.svg'; const thoughtspotLogoUrl = 'https://avatars.githubusercontent.com/u/8906680?s=200&v=4'; const htmlContent = ` ${serverName} | Authorization Request
ThoughtSpot MCP Server wants access
to your ThoughtSpot instance
ThoughtSpot MCP Server will be able to:
  • Read all ThoughtSpot data you have access to
  • Read all ThoughtSpot content you have access to
  • Send data to the client you are connecting to
`; return new Response(htmlContent, { headers: { 'Content-Type': 'text/html; charset=utf-8', }, }); } /** * Decodes a base64-encoded state string back into an object */ function decodeState(encodedState: string): T { try { const decoded = atob(encodedState); return JSON.parse(decoded) as T; } catch (e) { console.error('Error decoding state:', e); throw new Error('Invalid state format'); } } /** * Result of parsing the approval form submission. */ export interface ParsedApprovalResult { /** The original state object passed through the form. */ state: any /** The instance URL extracted from the form. */ instanceUrl: string } /** * Validates and sanitizes a URL to ensure it's a valid ThoughtSpot instance URL * @param url - The URL to validate and sanitize * @returns The sanitized URL * @throws Error if the URL is invalid */ export function validateAndSanitizeUrl(url: string): string { try { // Remove any whitespace const trimmedUrl = url.trim(); // Add https:// if no protocol is specified const urlWithProtocol = trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://') ? trimmedUrl : `https://${trimmedUrl}`; const parsedUrl = new URL(urlWithProtocol); // Remove trailing slashes and normalize the URL const sanitizedUrl = parsedUrl.origin; return sanitizedUrl; } catch (e) { if (e instanceof Error) { throw new Error(`Invalid URL: ${e.message}`); } throw new Error('Invalid URL format'); } } /** * Parses the form submission from the approval dialog, extracts the state, * and generates Set-Cookie headers to mark the client as approved. * * @param request - The incoming POST Request object containing the form data. * @returns A promise resolving to an object containing the parsed state and necessary headers. * @throws If the request method is not POST, form data is invalid, or state is missing. */ export async function parseRedirectApproval(request: Request): Promise { if (request.method !== 'POST') { throw new Error('Invalid request method. Expected POST.') } let state: any let clientId: string | undefined let instanceUrl: string | undefined try { const formData = await request.formData() const encodedState = formData.get('state') const rawInstanceUrl = formData.get('instanceUrl') as string; if (typeof encodedState !== 'string' || !encodedState) { throw new Error("Missing or invalid 'state' in form data.") } state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState) clientId = state?.oauthReqInfo?.clientId if (!clientId) { throw new Error('Could not extract clientId from state object.') } if (!rawInstanceUrl) { throw new Error('Missing instance URL') } // Validate and sanitize the instance URL instanceUrl = validateAndSanitizeUrl(rawInstanceUrl); } catch (e) { console.error('Error processing form submission:', e) throw new Error(`Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`) } return { state, instanceUrl } } /** * Sanitizes HTML content to prevent XSS attacks * @param unsafe - The unsafe string that might contain HTML * @returns A safe string with HTML special characters escaped */ function sanitizeHtml(unsafe: string): string { return unsafe.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') }