Sfoglia il codice sorgente

fix: preserve original parameter order in ShopRenter HMAC validation #96

- Changed HMAC calculation to use original query parameter order
  (shopname=...&code=...&timestamp=...) instead of alphabetically sorted
- Removed token exchange from callback - per docs, this should only
  redirect to app_url after HMAC validation
- Token exchange happens later when EntryPoint is called
Claude 5 mesi fa
parent
commit
8c38a9ed2a
1 ha cambiato i file con 48 aggiunte e 88 eliminazioni
  1. 48 88
      supabase/functions/oauth-shoprenter-callback/index.ts

+ 48 - 88
supabase/functions/oauth-shoprenter-callback/index.ts

@@ -32,41 +32,41 @@ const corsHeaders = {
 }
 
 // Validate HMAC signature from ShopRenter
-// Per ShopRenter documentation, HMAC is calculated from code, shopname, timestamp only
-async function validateHMAC(params: URLSearchParams, clientSecret: string, clientId: string): Promise<boolean> {
+// Per ShopRenter documentation, HMAC is calculated from the query string without HMAC parameter
+// IMPORTANT: We must preserve the original order of parameters as received in the URL
+async function validateHMAC(rawQueryString: string, clientSecret: string, clientId: string): Promise<boolean> {
   if (!clientSecret) {
     console.error('[ShopRenter] Client secret is empty or undefined')
     return false
   }
 
+  // Parse the query string to get the hmac value
+  const params = new URLSearchParams(rawQueryString)
   const hmacValue = params.get('hmac')
   if (!hmacValue) {
     console.error('[ShopRenter] HMAC missing from request')
     return false
   }
 
-  // Get only code, shopname, timestamp as per the ShopRenter documentation example
-  // The documentation shows: code=0907a61c0c8d55e99db179b68161bc00&shopname=example&timestamp=1337178173
-  const code = params.get('code') || ''
-  const shopname = params.get('shopname') || ''
-  const timestamp = params.get('timestamp') || ''
-
-  // Build params string with only these 3 params, sorted alphabetically
-  const sortedParams = `code=${code}&shopname=${shopname}&timestamp=${timestamp}`
+  // Build the params string by preserving the original order from the URL
+  // and excluding the hmac and app_url parameters
+  // ShopRenter calculates HMAC using: shopname=...&code=...&timestamp=... (original order)
+  const paramsWithoutHmac = rawQueryString
+    .split('&')
+    .filter(param => {
+      const key = param.split('=')[0]
+      return key !== 'hmac' && key !== 'app_url'
+    })
+    .join('&')
 
-  console.log(`[ShopRenter] HMAC validation - sorted params: ${sortedParams}`)
+  console.log(`[ShopRenter] HMAC validation - params (original order): ${paramsWithoutHmac}`)
   console.log(`[ShopRenter] HMAC validation - client secret length: ${clientSecret.length}`)
-  console.log(`[ShopRenter] HMAC validation - client id length: ${clientId.length}`)
 
   // Calculate HMAC using Deno's native crypto.subtle API (SHA-256)
-  const calculatedHmacWithSecret = await calculateHmacSha256(clientSecret, sortedParams)
-
-  // Also try with Client ID (in case of documentation confusion)
-  const calculatedHmacWithId = await calculateHmacSha256(clientId, sortedParams)
+  const calculatedHmacWithSecret = await calculateHmacSha256(clientSecret, paramsWithoutHmac)
 
   console.log(`[ShopRenter] HMAC validation - received hmac: ${hmacValue}`)
   console.log(`[ShopRenter] HMAC validation - calculated hmac (with secret): ${calculatedHmacWithSecret}`)
-  console.log(`[ShopRenter] HMAC validation - calculated hmac (with id): ${calculatedHmacWithId}`)
 
   // Compare HMACs
   const resultWithSecret = calculatedHmacWithSecret === hmacValue
@@ -76,6 +76,10 @@ async function validateHMAC(params: URLSearchParams, clientSecret: string, clien
     return true
   }
 
+  // Fallback: try with Client ID (in case of documentation confusion)
+  const calculatedHmacWithId = await calculateHmacSha256(clientId, paramsWithoutHmac)
+  console.log(`[ShopRenter] HMAC validation - calculated hmac (with id): ${calculatedHmacWithId}`)
+
   const resultWithId = calculatedHmacWithId === hmacValue
   console.log(`[ShopRenter] HMAC validation result (with id): ${resultWithId}`)
 
@@ -198,101 +202,57 @@ serve(wrapHandler('oauth-shoprenter-callback', async (req) => {
       )
     }
 
-    // Validate HMAC using decoded parameter values (per ShopRenter docs)
-    if (!(await validateHMAC(url.searchParams, shoprenterClientSecret, shoprenterClientId))) {
+    // Validate HMAC using the raw query string to preserve parameter order
+    const rawQueryString = url.search.substring(1) // Remove leading '?'
+    if (!(await validateHMAC(rawQueryString, shoprenterClientSecret, shoprenterClientId))) {
       return new Response(
         JSON.stringify({ error: 'HMAC validation failed' }),
         { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
       )
     }
 
-    // Exchange code for token
-    const currentFunctionUrl = `${url.protocol}//${url.host}${url.pathname}`
-    const tokenData = await exchangeCodeForToken(
-      shopname,
-      code,
-      shoprenterClientId,
-      shoprenterClientSecret,
-      currentFunctionUrl
-    )
+    // Per ShopRenter documentation:
+    // After validating HMAC, we must redirect to app_url
+    // The token exchange happens later when ShopRenter calls our EntryPoint
+    // See: https://doc.shoprenter.hu/development/app-development/01_getting_started.html
 
-    // Create Supabase client with service role key for admin operations
-    const supabase = createClient(supabaseUrl, supabaseServiceKey)
-
-    // Retrieve oauth_state to get phone_number_id and user_id
-    const { data: oauthState, error: stateError } = await supabase
-      .from('oauth_states')
-      .select('*')
-      .eq('platform', 'shoprenter')
-      .eq('shopname', shopname)
-      .order('created_at', { ascending: false })
-      .limit(1)
-      .maybeSingle()
-
-    if (stateError) {
-      console.error('[ShopRenter] Error retrieving oauth state:', stateError)
-    }
-
-    const phoneNumberId = oauthState?.phone_number_id
-
-    if (!phoneNumberId) {
-      console.error('[ShopRenter] No phone number ID found in oauth state')
+    if (!app_url) {
+      console.error('[ShopRenter] No app_url provided in callback')
       return new Response(
-        JSON.stringify({ error: 'Phone number selection missing. Please try connecting again.' }),
+        JSON.stringify({ error: 'Missing app_url parameter' }),
         { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
       )
     }
 
-    // Verify phone number is still available
-    const { data: phoneNumber, error: phoneError } = await supabase
-      .from('phone_numbers')
-      .select('id, is_available')
-      .eq('id', phoneNumberId)
-      .single()
+    // Store the shopname and code in oauth_nonces for later use during EntryPoint
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
 
-    if (phoneError || !phoneNumber || !phoneNumber.is_available) {
-      console.error('[ShopRenter] Phone number no longer available:', phoneNumberId)
-      return new Response(
-        JSON.stringify({ error: 'Selected phone number is no longer available. Please try again.' }),
-        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
-      )
-    }
+    // Generate a unique nonce to pass to ShopRenter
+    const nonce = crypto.randomUUID()
 
-    // Store installation in pending state with phone_number_id
-    const installationId = crypto.randomUUID()
-    const { error: installError } = await supabase
-      .from('pending_shoprenter_installs')
+    const { error: nonceError } = await supabase
+      .from('oauth_nonces')
       .insert({
-        installation_id: installationId,
+        nonce,
+        platform: 'shoprenter',
         shopname,
-        access_token: tokenData.access_token,
-        refresh_token: tokenData.refresh_token,
-        token_type: tokenData.token_type || 'Bearer',
-        expires_in: tokenData.expires_in || 3600,
-        scopes: tokenData.scope ? tokenData.scope.split(' ') : [],
-        phone_number_id: phoneNumberId,
-        expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString()
+        app_url,
+        expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() // 15 minutes
       })
 
-    if (installError) {
-      console.error('[ShopRenter] Error storing installation:', installError)
+    if (nonceError) {
+      console.error('[ShopRenter] Error storing nonce:', nonceError)
       return new Response(
-        JSON.stringify({ error: 'Failed to store installation' }),
+        JSON.stringify({ error: 'Failed to process installation' }),
         { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
       )
     }
 
-    // Clean up oauth_state if found
-    if (oauthState) {
-      await supabase.from('oauth_states').delete().eq('id', oauthState.id)
-    }
-
-    // Redirect to app_url or frontend with installation_id
-    const redirectUrl = app_url
-      ? `${app_url}?sr_install=${installationId}`
-      : `${frontendUrl}/integrations?sr_install=${installationId}`
+    // Redirect to app_url as per ShopRenter documentation
+    // We can add our nonce as a query parameter for tracking
+    const redirectUrl = `${app_url}?sr_nonce=${nonce}`
 
-    console.log(`[ShopRenter] Redirecting to: ${redirectUrl}`)
+    console.log(`[ShopRenter] HMAC validated for ${shopname}, redirecting to app_url: ${redirectUrl}`)
 
     return new Response(null, {
       status: 302,