From 49d82e4ada7366101f9a063fed0ced5b23ce9a81 Mon Sep 17 00:00:00 2001 From: gpt-partners <124867543+gpt-partners@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:36:16 +0100 Subject: [PATCH] Add authenticate app proxies --- app/routes/chat.jsx | 228 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 175 insertions(+), 53 deletions(-) diff --git a/app/routes/chat.jsx b/app/routes/chat.jsx index d85ddc09..b562ef0d 100644 --- a/app/routes/chat.jsx +++ b/app/routes/chat.jsx @@ -3,12 +3,17 @@ * Handles chat interactions with Claude API and tools */ import MCPClient from "../mcp-client"; -import { saveMessage, getConversationHistory, storeCustomerAccountUrls, getCustomerAccountUrls as getCustomerAccountUrlsFromDb } from "../db.server"; +import { + saveMessage, + getConversationHistory, + storeCustomerAccountUrls, + getCustomerAccountUrls as getCustomerAccountUrlsFromDb, +} from "../db.server"; import AppConfig from "../services/config.server"; import { createSseStream } from "../services/streaming.server"; import { createClaudeService } from "../services/claude.server"; import { createToolService } from "../services/tool.server"; - +import crypto from "crypto"; /** * Rract Router loader function for handling GET requests @@ -18,24 +23,52 @@ export async function loader({ request }) { if (request.method === "OPTIONS") { return new Response(null, { status: 204, - headers: getCorsHeaders(request) + headers: getCorsHeaders(request), }); } const url = new URL(request.url); + // Authenticate app proxy requests + if (url.searchParams.has("signature")) { + const isValid = await authenticateAppProxy(url); + if (!isValid) { + return new Response(JSON.stringify({ error: "Invalid signature" }), { + status: 401, + headers: getCorsHeaders(request), + }); + } + } else { + return new Response(JSON.stringify({ error: "Missing signature" }), { + status: 401, + headers: getCorsHeaders(request), + }); + } + // Handle history fetch requests - matches /chat?history=true&conversation_id=XYZ - if (url.searchParams.has('history') && url.searchParams.has('conversation_id')) { - return handleHistoryRequest(request, url.searchParams.get('conversation_id')); + if ( + url.searchParams.has("history") && + url.searchParams.has("conversation_id") + ) { + return handleHistoryRequest( + request, + url.searchParams.get("conversation_id"), + ); } // Handle SSE requests - if (!url.searchParams.has('history') && request.headers.get("Accept") === "text/event-stream") { + if ( + !url.searchParams.has("history") && + request.headers.get("Accept") === "text/event-stream" + ) { return handleChatRequest(request); } // API-only: reject all other requests - return new Response(JSON.stringify({ error: AppConfig.errorMessages.apiUnsupported }), { status: 400, headers: getCorsHeaders(request) }); + return new Response( + JSON.stringify({ error: AppConfig.errorMessages.apiUnsupported }), + { status: 400, headers: getCorsHeaders(request) }, + ); } /** @@ -45,6 +78,71 @@ export async function action({ request }) { return handleChatRequest(request); } +/** + * Authenticate app proxy requests using HMAC signature verification + * @param {URL} url - The request URL with query parameters + * @returns {boolean} True if signature is valid, false otherwise + */ +async function authenticateAppProxy(url) { + try { + // Get the shared secret from environment variables + const sharedSecret = process.env.SHOPIFY_API_SECRET; + if (!sharedSecret) { + console.error("SHOPIFY_API_SECRET not configured"); + return false; + } + + // Extract all query parameters + const queryParams = {}; + for (const [key, value] of url.searchParams.entries()) { + if (queryParams[key]) { + // Handle multiple values for the same key + if (Array.isArray(queryParams[key])) { + queryParams[key].push(value); + } else { + queryParams[key] = [queryParams[key], value]; + } + } else { + queryParams[key] = value; + } + } + + // Remove and save the signature + const signature = queryParams.signature; + delete queryParams.signature; + + if (!signature) { + return false; + } + + // Sort and concatenate parameters + const sortedParams = Object.keys(queryParams) + .sort() + .map((key) => { + const value = Array.isArray(queryParams[key]) + ? queryParams[key].join(",") + : queryParams[key]; + return `${key}=${value}`; + }) + .join(""); + + // Calculate HMAC-SHA256 signature + const calculatedSignature = crypto + .createHmac("sha256", sharedSecret) + .update(sortedParams) + .digest("hex"); + + // Secure comparison to prevent timing attacks + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(calculatedSignature), + ); + } catch (error) { + console.error("Error authenticating app proxy:", error); + return false; + } +} + /** * Handle history fetch requests * @param {Request} request - The request object @@ -54,7 +152,9 @@ export async function action({ request }) { async function handleHistoryRequest(request, conversationId) { const messages = await getConversationHistory(conversationId); - return new Response(JSON.stringify({ messages }), { headers: getCorsHeaders(request) }); + return new Response(JSON.stringify({ messages }), { + headers: getCorsHeaders(request), + }); } /** @@ -72,7 +172,7 @@ async function handleChatRequest(request) { if (!userMessage) { return new Response( JSON.stringify({ error: AppConfig.errorMessages.missingMessage }), - { status: 400, headers: getSseHeaders(request) } + { status: 400, headers: getSseHeaders(request) }, ); } @@ -87,18 +187,18 @@ async function handleChatRequest(request) { userMessage, conversationId, promptType, - stream + stream, }); }); return new Response(responseStream, { - headers: getSseHeaders(request) + headers: getSseHeaders(request), }); } catch (error) { - console.error('Error in chat request handler:', error); + console.error("Error in chat request handler:", error); return new Response(JSON.stringify({ error: error.message }), { status: 500, - headers: getCorsHeaders(request) + headers: getCorsHeaders(request), }); } } @@ -117,7 +217,7 @@ async function handleChatSession({ userMessage, conversationId, promptType, - stream + stream, }) { // Initialize services const claudeService = createClaudeService(); @@ -126,7 +226,10 @@ async function handleChatSession({ // Initialize MCP client const shopId = request.headers.get("X-Shopify-Shop-Id"); const shopDomain = request.headers.get("Origin"); - const { mcpApiUrl } = await getCustomerAccountUrls(shopDomain, conversationId); + const { mcpApiUrl } = await getCustomerAccountUrls( + shopDomain, + conversationId, + ); const mcpClient = new MCPClient( shopDomain, @@ -137,19 +240,25 @@ async function handleChatSession({ try { // Send conversation ID to client - stream.sendMessage({ type: 'id', conversation_id: conversationId }); + stream.sendMessage({ type: "id", conversation_id: conversationId }); // Connect to MCP servers and get available tools - let storefrontMcpTools = [], customerMcpTools = []; + let storefrontMcpTools = [], + customerMcpTools = []; try { storefrontMcpTools = await mcpClient.connectToStorefrontServer(); customerMcpTools = await mcpClient.connectToCustomerServer(); console.log(`Connected to MCP with ${storefrontMcpTools.length} tools`); - console.log(`Connected to customer MCP with ${customerMcpTools.length} tools`); + console.log( + `Connected to customer MCP with ${customerMcpTools.length} tools`, + ); } catch (error) { - console.warn('Failed to connect to MCP servers, continuing without tools:', error.message); + console.warn( + "Failed to connect to MCP servers, continuing without tools:", + error.message, + ); } // Prepare conversation state @@ -157,13 +266,13 @@ async function handleChatSession({ let productsToDisplay = []; // Save user message to the database - await saveMessage(conversationId, 'user', userMessage); + await saveMessage(conversationId, "user", userMessage); // Fetch all messages from the database for this conversation const dbMessages = await getConversationHistory(conversationId); // Format messages for Claude API - conversationHistory = dbMessages.map(dbMessage => { + conversationHistory = dbMessages.map((dbMessage) => { let content; try { content = JSON.parse(dbMessage.content); @@ -172,26 +281,26 @@ async function handleChatSession({ } return { role: dbMessage.role, - content + content, }; }); // Execute the conversation stream - let finalMessage = { role: 'user', content: userMessage }; + let finalMessage = { role: "user", content: userMessage }; while (finalMessage.stop_reason !== "end_turn") { finalMessage = await claudeService.streamConversation( { messages: conversationHistory, promptType, - tools: mcpClient.tools + tools: mcpClient.tools, }, { // Handle text chunks onText: (textDelta) => { stream.sendMessage({ - type: 'chunk', - chunk: textDelta + type: "chunk", + chunk: textDelta, }); }, @@ -199,16 +308,19 @@ async function handleChatSession({ onMessage: (message) => { conversationHistory.push({ role: message.role, - content: message.content + content: message.content, }); - saveMessage(conversationId, message.role, JSON.stringify(message.content)) - .catch((error) => { - console.error("Error saving message to database:", error); - }); + saveMessage( + conversationId, + message.role, + JSON.stringify(message.content), + ).catch((error) => { + console.error("Error saving message to database:", error); + }); // Send a completion message - stream.sendMessage({ type: 'message_complete' }); + stream.sendMessage({ type: "message_complete" }); }, // Handle tool use requests @@ -220,12 +332,15 @@ async function handleChatSession({ const toolUseMessage = `Calling tool: ${toolName} with arguments: ${JSON.stringify(toolArgs)}`; stream.sendMessage({ - type: 'tool_use', - tool_use_message: toolUseMessage + type: "tool_use", + tool_use_message: toolUseMessage, }); // Call the tool - const toolUseResponse = await mcpClient.callTool(toolName, toolArgs); + const toolUseResponse = await mcpClient.callTool( + toolName, + toolArgs, + ); // Handle tool response based on success/error if (toolUseResponse.error) { @@ -235,7 +350,7 @@ async function handleChatSession({ toolUseId, conversationHistory, stream.sendMessage, - conversationId + conversationId, ); } else { await toolService.handleToolSuccess( @@ -244,35 +359,35 @@ async function handleChatSession({ toolUseId, conversationHistory, productsToDisplay, - conversationId + conversationId, ); } // Signal new message to client - stream.sendMessage({ type: 'new_message' }); + stream.sendMessage({ type: "new_message" }); }, // Handle content block completion onContentBlock: (contentBlock) => { - if (contentBlock.type === 'text') { + if (contentBlock.type === "text") { stream.sendMessage({ - type: 'content_block_complete', - content_block: contentBlock + type: "content_block_complete", + content_block: contentBlock, }); } - } - } + }, + }, ); } // Signal end of turn - stream.sendMessage({ type: 'end_turn' }); + stream.sendMessage({ type: "end_turn" }); // Send product results if available if (productsToDisplay.length > 0) { stream.sendMessage({ - type: 'product_results', - products: productsToDisplay + type: "product_results", + products: productsToDisplay, }); } } catch (error) { @@ -299,8 +414,12 @@ async function getCustomerAccountUrls(shopDomain, conversationId) { const { hostname } = new URL(shopDomain); const urls = await Promise.all([ - fetch(`https://${hostname}/.well-known/customer-account-api`).then(res => res.json()), - fetch(`https://${hostname}/.well-known/openid-configuration`).then(res => res.json()), + fetch(`https://${hostname}/.well-known/customer-account-api`).then( + (res) => res.json(), + ), + fetch(`https://${hostname}/.well-known/openid-configuration`).then( + (res) => res.json(), + ), ]).then(async ([mcpResponse, openidResponse]) => { const response = { mcpApiUrl: mcpResponse.mcp_api, @@ -332,14 +451,16 @@ async function getCustomerAccountUrls(shopDomain, conversationId) { */ function getCorsHeaders(request) { const origin = request.headers.get("Origin") || "*"; - const requestHeaders = request.headers.get("Access-Control-Request-Headers") || "Content-Type, Accept"; + const requestHeaders = + request.headers.get("Access-Control-Request-Headers") || + "Content-Type, Accept"; return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": requestHeaders, "Access-Control-Allow-Credentials": "true", - "Access-Control-Max-Age": "86400" // 24 hours + "Access-Control-Max-Age": "86400", // 24 hours }; } @@ -354,10 +475,11 @@ function getSseHeaders(request) { return { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", - "Connection": "keep-alive", + Connection: "keep-alive", "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET,OPTIONS,POST", - "Access-Control-Allow-Headers": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" + "Access-Control-Allow-Headers": + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", }; -} +} \ No newline at end of file