Browse Source

fix(shoprenter): prevent multiple concurrent token refresh requests

- Add in-memory token cache with refresh promise deduplication
- Add JWT token decoder to extract expiration from token payload
- Check token validity from embedded exp claim (most accurate)
- Concurrent calls now wait for ongoing refresh instead of making new requests
- Fixed logic order: check cache/DB token first, only refresh if expired

This fixes the issue where multiple "Sending request to oauth.app.shoprenter.net"
logs appeared simultaneously during scheduled sync.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fszontagh 4 months ago
parent
commit
e01ccea414
1 changed files with 190 additions and 102 deletions
  1. 190 102
      supabase/functions/_shared/shoprenter-client.ts

+ 190 - 102
supabase/functions/_shared/shoprenter-client.ts

@@ -8,6 +8,63 @@ export interface ShopRenterTokens {
   shopname: string
 }
 
+// In-memory token cache to prevent multiple concurrent token refresh requests
+// Key: storeId, Value: { token, expiresAt, refreshPromise }
+const tokenCache = new Map<string, {
+  token: string
+  expiresAt: number
+  refreshPromise?: Promise<string>
+}>()
+
+/**
+ * Decode ShopRenter access token to extract expiration time.
+ * ShopRenter tokens are base64-encoded JSON containing exp (expiration timestamp).
+ * Format: base64({ "exp": 1234567890, "scope": "...", ... })
+ */
+function decodeTokenExpiration(token: string): number | null {
+  try {
+    // ShopRenter tokens may be plain base64 or JWT-like (with dots)
+    let payload = token
+
+    // If it looks like a JWT (has dots), extract the payload part
+    if (token.includes('.')) {
+      const parts = token.split('.')
+      if (parts.length >= 2) {
+        payload = parts[1]
+      }
+    }
+
+    // Decode base64 (handle URL-safe base64)
+    const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
+    const decoded = atob(base64)
+    const data = JSON.parse(decoded)
+
+    if (data.exp && typeof data.exp === 'number') {
+      // exp is Unix timestamp in seconds, convert to milliseconds
+      return data.exp * 1000
+    }
+
+    return null
+  } catch (error) {
+    // Token might not be decodable, that's ok
+    console.log('[ShopRenter] Could not decode token expiration:', error)
+    return null
+  }
+}
+
+/**
+ * Check if a token is still valid based on its embedded expiration.
+ * Returns true if token is valid for at least the buffer time.
+ */
+function isTokenValid(token: string, bufferMs: number = 5 * 60 * 1000): boolean {
+  const expiresAt = decodeTokenExpiration(token)
+  if (!expiresAt) {
+    // Can't decode, assume invalid to be safe
+    return false
+  }
+  return expiresAt - Date.now() > bufferMs
+}
+
 /**
  * Makes an HTTP/1.0 request using raw TCP/TLS connection
  * This is necessary because ShopRenter API requires HTTP/1.0
@@ -341,7 +398,27 @@ export async function fetchShopSettingsDirect(
 }
 
 // Get valid access token (with automatic refresh or client_credentials)
+// Uses in-memory caching to prevent multiple concurrent token refresh requests
 export async function getValidAccessToken(storeId: string): Promise<string> {
+  const now = Date.now()
+  const bufferTime = 5 * 60 * 1000 // 5 minutes buffer before expiry
+
+  // Check in-memory cache first (prevents concurrent refresh requests)
+  const cached = tokenCache.get(storeId)
+  if (cached) {
+    // If there's an ongoing refresh, wait for it
+    if (cached.refreshPromise) {
+      console.log('[ShopRenter] Token refresh already in progress, waiting...')
+      return cached.refreshPromise
+    }
+
+    // If cached token is still valid (with buffer), return it immediately
+    if (cached.expiresAt - now > bufferTime) {
+      console.log('[ShopRenter] Using cached valid access_token')
+      return cached.token
+    }
+  }
+
   const supabaseUrl = Deno.env.get('SUPABASE_URL')!
   const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
   const supabase = createClient(supabaseUrl, supabaseServiceKey)
@@ -358,7 +435,44 @@ export async function getValidAccessToken(storeId: string): Promise<string> {
     throw new Error('ShopRenter store not found')
   }
 
-  // Get client credentials - prioritize database, fallback to environment variables
+  // Check if existing token from DB is still valid
+  if (store.access_token) {
+    // First, try to validate using the embedded expiration in the token itself
+    const tokenExpiresAt = decodeTokenExpiration(store.access_token)
+
+    if (tokenExpiresAt) {
+      // Token has embedded expiration - use it (most accurate)
+      if (tokenExpiresAt - now > bufferTime) {
+        console.log('[ShopRenter] Using existing valid access_token (validated from token), expires at:', new Date(tokenExpiresAt).toISOString())
+
+        // Update cache with actual token expiration
+        tokenCache.set(storeId, {
+          token: store.access_token,
+          expiresAt: tokenExpiresAt
+        })
+
+        return store.access_token
+      }
+      console.log('[ShopRenter] Token expired based on embedded exp claim')
+    } else if (store.token_expires_at) {
+      // Fall back to database expiration time
+      const dbExpiryTime = new Date(store.token_expires_at).getTime()
+
+      if (dbExpiryTime - now > bufferTime) {
+        console.log('[ShopRenter] Using existing valid access_token from DB, expires at:', store.token_expires_at)
+
+        // Update cache
+        tokenCache.set(storeId, {
+          token: store.access_token,
+          expiresAt: dbExpiryTime
+        })
+
+        return store.access_token
+      }
+    }
+  }
+
+  // Token needs refresh - get client credentials
   let clientId = store.api_key
   let clientSecret = store.api_secret
 
@@ -377,7 +491,7 @@ export async function getValidAccessToken(storeId: string): Promise<string> {
     clientSecret = store.alt_data?.client_secret
   }
 
-  // Final fallback: use global credentials from environment (for testing or when store has NULL credentials)
+  // Final fallback: use global credentials from environment
   if (!clientId || !clientSecret) {
     console.log('[ShopRenter] No client credentials in database, using global credentials from environment')
     clientId = Deno.env.get('SHOPRENTER_APP_CLIENT_ID')
@@ -389,74 +503,29 @@ export async function getValidAccessToken(storeId: string): Promise<string> {
     throw new Error('ShopRenter client credentials not found in database or environment. Please reconnect the store or configure SHOPRENTER_APP_CLIENT_ID and SHOPRENTER_APP_CLIENT_SECRET.')
   }
 
-  // If we have client credentials, use client_credentials flow to get a fresh token
-  if (clientId && clientSecret) {
-    console.log('[ShopRenter] Using client_credentials flow to obtain access token')
+  // Create a refresh promise to prevent concurrent refresh attempts
+  const refreshPromise = (async (): Promise<string> => {
+    console.log('[ShopRenter] Token expired or expiring soon, refreshing...')
 
     try {
-      const tokenData = await getTokenWithClientCredentials(store.store_name, clientId, clientSecret)
-
-      const expiresAt = new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString()
-
-      // Update store with the new access token
-      // IMPORTANT: Only update access_token, refresh_token, and token_expires_at
-      // NEVER update api_key or api_secret (preserve them for manual testing or per-store credentials)
-      // Always backup client credentials to alt_data for recovery
-      await supabase
-        .from('stores')
-        .update({
-          access_token: tokenData.access_token,
-          refresh_token: tokenData.refresh_token || null,
-          token_expires_at: expiresAt,
-          alt_data: {
-            ...(store.alt_data || {}),
-            client_id: clientId,
-            client_secret: clientSecret,
-            last_token_refresh: new Date().toISOString()
-          }
-        })
-        .eq('id', storeId)
-
-      console.log('[ShopRenter] Access token obtained and stored successfully, expires at:', expiresAt)
-      return tokenData.access_token
-    } catch (error) {
-      console.error('[ShopRenter] Failed to get token via client_credentials:', error)
-      // Continue to try other methods below
-    }
-  }
-
-  // Check if we have an existing access token
-  if (store.access_token) {
-    const expiresAt = store.token_expires_at
-    if (expiresAt) {
-      const expiryTime = new Date(expiresAt).getTime()
-      const now = Date.now()
-      const bufferTime = 5 * 60 * 1000 // 5 minutes
-
-      // Token is still valid
-      if (expiryTime - now > bufferTime) {
-        console.log('[ShopRenter] Using existing valid access_token, expires at:', expiresAt)
-        return store.access_token
-      }
-
-      // Token needs refresh
-      console.log('[ShopRenter] Token expired or expiring soon, refreshing...')
-      if (store.refresh_token && clientId && clientSecret) {
+      // Try refresh_token flow first if available
+      if (store.refresh_token) {
         try {
-          const newTokenData = await refreshAccessToken(store.store_name, store.refresh_token, clientId, clientSecret)
+          const newTokenData = await refreshAccessToken(store.store_name, store.refresh_token, clientId!, clientSecret!)
 
-          const newExpiresAt = new Date(Date.now() + (newTokenData.expires_in * 1000)).toISOString()
+          // Prefer embedded token expiration, fall back to expires_in from response
+          const tokenExp = decodeTokenExpiration(newTokenData.access_token)
+          const newExpiresAt = tokenExp
+            ? new Date(tokenExp)
+            : new Date(Date.now() + (newTokenData.expires_in * 1000))
 
           // Update store with new tokens
-          // IMPORTANT: Only update access_token, refresh_token, and token_expires_at
-          // NEVER update api_key or api_secret (preserve them)
-          // Always backup client credentials to alt_data for recovery
           await supabase
             .from('stores')
             .update({
               access_token: newTokenData.access_token,
               refresh_token: newTokenData.refresh_token || store.refresh_token,
-              token_expires_at: newExpiresAt,
+              token_expires_at: newExpiresAt.toISOString(),
               alt_data: {
                 ...(store.alt_data || {}),
                 client_id: clientId,
@@ -466,57 +535,76 @@ export async function getValidAccessToken(storeId: string): Promise<string> {
             })
             .eq('id', storeId)
 
-          console.log('[ShopRenter] Token refreshed successfully, expires at:', newExpiresAt)
+          // Update cache
+          tokenCache.set(storeId, {
+            token: newTokenData.access_token,
+            expiresAt: newExpiresAt.getTime()
+          })
+
+          console.log('[ShopRenter] Token refreshed successfully via refresh_token, expires at:', newExpiresAt.toISOString())
           return newTokenData.access_token
         } catch (refreshError) {
-          console.error('[ShopRenter] Token refresh failed:', refreshError)
-
-          // If refresh fails and we have client credentials, try client_credentials flow as fallback
-          if (clientId && clientSecret) {
-            console.log('[ShopRenter] Attempting fallback to client_credentials flow')
-            const tokenData = await getTokenWithClientCredentials(
-              store.store_name,
-              clientId,
-              clientSecret
-            )
-
-            const expiresAt = new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString()
-
-            // Update store with new tokens
-            // IMPORTANT: Only update access_token, refresh_token, and token_expires_at
-            // NEVER update api_key or api_secret (preserve them)
-            // Always backup client credentials to alt_data for recovery
-            await supabase
-              .from('stores')
-              .update({
-                access_token: tokenData.access_token,
-                refresh_token: tokenData.refresh_token || null,
-                token_expires_at: expiresAt,
-                alt_data: {
-                  ...(store.alt_data || {}),
-                  client_id: clientId,
-                  client_secret: clientSecret,
-                  last_token_refresh: new Date().toISOString()
-                }
-              })
-              .eq('id', storeId)
-
-            console.log('[ShopRenter] Access token obtained via client_credentials fallback, expires at:', expiresAt)
-            return tokenData.access_token
+          console.error('[ShopRenter] Token refresh via refresh_token failed:', refreshError)
+          // Fall through to client_credentials
+        }
+      }
+
+      // Use client_credentials flow
+      console.log('[ShopRenter] Using client_credentials flow to obtain access token')
+      const tokenData = await getTokenWithClientCredentials(store.store_name, clientId!, clientSecret!)
+
+      // Prefer embedded token expiration, fall back to expires_in from response
+      const tokenExp = decodeTokenExpiration(tokenData.access_token)
+      const expiresAt = tokenExp
+        ? new Date(tokenExp)
+        : new Date(Date.now() + (tokenData.expires_in * 1000))
+
+      // Update store with the new access token
+      await supabase
+        .from('stores')
+        .update({
+          access_token: tokenData.access_token,
+          refresh_token: tokenData.refresh_token || null,
+          token_expires_at: expiresAt.toISOString(),
+          alt_data: {
+            ...(store.alt_data || {}),
+            client_id: clientId,
+            client_secret: clientSecret,
+            last_token_refresh: new Date().toISOString()
           }
+        })
+        .eq('id', storeId)
 
-          // No fallback available, re-throw the error
-          throw refreshError
-        }
+      // Update cache
+      tokenCache.set(storeId, {
+        token: tokenData.access_token,
+        expiresAt: expiresAt.getTime()
+      })
+
+      console.log('[ShopRenter] Access token obtained via client_credentials, expires at:', expiresAt.toISOString())
+      return tokenData.access_token
+    } finally {
+      // Clear the refresh promise from cache so future calls can refresh again if needed
+      const cachedEntry = tokenCache.get(storeId)
+      if (cachedEntry) {
+        delete cachedEntry.refreshPromise
       }
     }
-
-    // No expiration info, just return the token
-    console.log('[ShopRenter] No expiration info found, using access_token as-is')
-    return store.access_token
+  })()
+
+  // Store the refresh promise in cache so concurrent calls wait for it
+  const existingCache = tokenCache.get(storeId)
+  if (existingCache) {
+    existingCache.refreshPromise = refreshPromise
+  } else {
+    tokenCache.set(storeId, {
+      token: '',
+      expiresAt: 0,
+      refreshPromise
+    })
   }
 
-  throw new Error('No access token found for ShopRenter store')
+  return refreshPromise
 }
 
 // Get access token using client_credentials grant