|
|
@@ -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
|