|
@@ -32,41 +32,41 @@ const corsHeaders = {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Validate HMAC signature from ShopRenter
|
|
// 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) {
|
|
if (!clientSecret) {
|
|
|
console.error('[ShopRenter] Client secret is empty or undefined')
|
|
console.error('[ShopRenter] Client secret is empty or undefined')
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Parse the query string to get the hmac value
|
|
|
|
|
+ const params = new URLSearchParams(rawQueryString)
|
|
|
const hmacValue = params.get('hmac')
|
|
const hmacValue = params.get('hmac')
|
|
|
if (!hmacValue) {
|
|
if (!hmacValue) {
|
|
|
console.error('[ShopRenter] HMAC missing from request')
|
|
console.error('[ShopRenter] HMAC missing from request')
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Get only code, shopname, timestamp as per the ShopRenter documentation example
|
|
|
|
|
- // The documentation shows: code=0907a61c0c8d55e99db179b68161bc00&shopname=example×tamp=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}×tamp=${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=...×tamp=... (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 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)
|
|
// 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 - received hmac: ${hmacValue}`)
|
|
|
console.log(`[ShopRenter] HMAC validation - calculated hmac (with secret): ${calculatedHmacWithSecret}`)
|
|
console.log(`[ShopRenter] HMAC validation - calculated hmac (with secret): ${calculatedHmacWithSecret}`)
|
|
|
- console.log(`[ShopRenter] HMAC validation - calculated hmac (with id): ${calculatedHmacWithId}`)
|
|
|
|
|
|
|
|
|
|
// Compare HMACs
|
|
// Compare HMACs
|
|
|
const resultWithSecret = calculatedHmacWithSecret === hmacValue
|
|
const resultWithSecret = calculatedHmacWithSecret === hmacValue
|
|
@@ -76,6 +76,10 @@ async function validateHMAC(params: URLSearchParams, clientSecret: string, clien
|
|
|
return true
|
|
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
|
|
const resultWithId = calculatedHmacWithId === hmacValue
|
|
|
console.log(`[ShopRenter] HMAC validation result (with id): ${resultWithId}`)
|
|
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(
|
|
return new Response(
|
|
|
JSON.stringify({ error: 'HMAC validation failed' }),
|
|
JSON.stringify({ error: 'HMAC validation failed' }),
|
|
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
{ 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(
|
|
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' } }
|
|
{ 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({
|
|
.insert({
|
|
|
- installation_id: installationId,
|
|
|
|
|
|
|
+ nonce,
|
|
|
|
|
+ platform: 'shoprenter',
|
|
|
shopname,
|
|
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(
|
|
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' } }
|
|
{ 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, {
|
|
return new Response(null, {
|
|
|
status: 302,
|
|
status: 302,
|