Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/core/src/pages/api/fs/refresh-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { NextApiHandler } from 'next'

import discoveryConfig from 'discovery.config'
import fetch from 'isomorphic-unfetch'
import { normalizeSetCookieDomain } from 'src/utils/normalizeSetCookieForRequest'
import { sanitizeHost } from 'src/utils/utilities'

const VTEX_REFRESH_PATH = '/api/vtexid/refreshtoken/webstore'

function getSetCookieValuesFromResponse(response: Response): string[] {
const headers = response.headers as Headers & {
getSetCookie?: () => string[]
}
if (typeof headers.getSetCookie === 'function') {
return headers.getSetCookie()
}
const single = response.headers.get('set-cookie')
return single ? [single] : []
}

const handler: NextApiHandler = async (request, response) => {
if (request.method !== 'POST') {
response.status(405).end()
return
}

if (!discoveryConfig.experimental?.refreshToken) {
response.status(404).end()
return
}

const cookieHeader = request.headers.cookie
if (!cookieHeader) {
console.error('[fs/refresh-token] missing Cookie header')
response.status(401).json({ status: 'Error' })
return
}

const url = `${discoveryConfig.storeUrl}${VTEX_REFRESH_PATH}`

try {
const vtexRes = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: cookieHeader,
Host: `${sanitizeHost(discoveryConfig.storeUrl)}`,
},
body: JSON.stringify({}),
})

const text = await vtexRes.text()
let data: Record<string, unknown>
try {
data = text ? (JSON.parse(text) as Record<string, unknown>) : {}
} catch {
console.error('[fs/refresh-token] invalid JSON body', text.slice(0, 500))
response.status(500).json({ status: 'Error' })
return
}

const setCookies = getSetCookieValuesFromResponse(vtexRes).map((c) =>
normalizeSetCookieDomain({ request, setCookie: c })
)
if (setCookies.length > 0) {
response.setHeader('set-cookie', setCookies)
}

if (vtexRes.status !== 200) {
console.error(
'[fs/refresh-token] VTEX non-200',
vtexRes.status,
text.slice(0, 500)
)
response
.status(vtexRes.status === 401 ? 401 : 500)
.json(data ?? { status: 'Error' })
return
}

response.status(200).json(data)
} catch (err) {
console.error('[fs/refresh-token] proxy error', err)
response.status(500).end()
}
}

export default handler
132 changes: 25 additions & 107 deletions packages/core/src/pages/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,97 +8,16 @@ import { parse } from 'cookie'
import type { NextApiHandler, NextApiRequest } from 'next'

import discoveryConfig from 'discovery.config'
import { getJWTAutCookie, isExpired } from 'src/utils/getCookie'
import { getJWTAutCookie } from 'src/utils/getCookie'
import { normalizeSetCookieDomain } from 'src/utils/normalizeSetCookieForRequest'
import {
computeShouldRefreshToken,
describeShouldRefreshTokenReason,
} from 'src/utils/sessionValidateRefreshDecision'
import { execute } from '../../server'

const DEFAULT_MAX_AGE = 5 * 60 // 5 minutes
const DEFAULT_STALE_WHILE_REVALIDATE = 60 * 60 // 1 hour
const ALLOWED_HOST_SUFFIXES = ['localhost', '.vtex.app', '.localhost']

// Example: "Set-Cookie: key=value; Domain=example.com; Path=/"
const MATCH_DOMAIN_REGEXP = /(?:^|;\s*)(?:domain=)([^;]+)/i

/**
* Extracts hostname from the incoming request.
*/
const getRequestHostname = ({
request,
}: {
request: NextApiRequest
}): string | null => {
const hostHeader = request.headers.host?.trim()
if (!hostHeader) {
return null
}

try {
return new URL(`https://${hostHeader}`).hostname
} catch {
return null
}
}

/**
* Checks whether the cookie domain should be replaced by host.
*/
const shouldReplaceCookieDomain = ({
cookieDomain,
host,
}: {
cookieDomain: string
host: string
}) => {
const normalizedDomain = cookieDomain.replace(/^\./, '').toLowerCase()
const normalizedHost = host.toLowerCase()

return normalizedDomain !== normalizedHost
}

/**
* Determines if host is eligible for domain normalization.
*/
const isAllowedHost = ({
host,
allowList,
}: {
host: string
allowList: string[]
}) => {
const normalizedHost = host.toLowerCase()

return allowList.some((suffix) => normalizedHost.endsWith(suffix))
}

/**
* Ensure the cookie domain matches the current host so the browser can store it.
*/
const normalizeSetCookieDomain = ({
request,
setCookie,
}: {
request: NextApiRequest
setCookie: string
}) => {
const domainMatch = setCookie.match(MATCH_DOMAIN_REGEXP)
if (!domainMatch) {
return setCookie
}

const host = getRequestHostname({ request })
if (!host) {
return setCookie
}
const cookieDomain = domainMatch[1]

if (
!isAllowedHost({ host, allowList: ALLOWED_HOST_SUFFIXES }) ||
!shouldReplaceCookieDomain({ cookieDomain, host })
) {
return setCookie
}

return setCookie.replace(MATCH_DOMAIN_REGEXP, `; domain=${host}`)
}

const parseRequest = (request: NextApiRequest) => {
try {
Expand Down Expand Up @@ -169,29 +88,28 @@ const handler: NextApiHandler = async (request, response) => {
account: discoveryConfig.api.storeId,
})

const tokenExpired = Boolean(jwt && isExpired(Number(jwt?.exp)))

const refreshAfterExist = !!variables?.session?.refreshAfter

const refreshAfterExpired =
refreshAfterExist && isExpired(Number(variables.session.refreshAfter))
const refreshAfter = variables?.session?.refreshAfter as
| string
| null
| undefined

const tokenExistAndIsFirstRefreshTokenRequest =
!!jwt && !refreshAfterExist

// when token expired, browser clears the cookie, but we still have the refreshAfter in session and the refresh token cookie
const tokenNotExistAndRefreshAfterExistAndIsExpired =
!jwt && !!refreshAfterExist && refreshAfterExpired

const tokenExpiredAndRefreshAfterIsNullOrExpired =
tokenExpired && (!refreshAfterExist || refreshAfterExpired)

const shouldRefreshToken =
tokenExistAndIsFirstRefreshTokenRequest ||
tokenNotExistAndRefreshAfterExistAndIsExpired ||
tokenExpiredAndRefreshAfterIsNullOrExpired
const shouldRefreshToken = computeShouldRefreshToken({
jwt,
refreshAfter,
})

if (shouldRefreshToken) {
const reason = describeShouldRefreshTokenReason({
jwt,
refreshAfter,
})
console.warn('[ValidateSession] shouldRefreshToken', {
reason,
hasJwt: Boolean(jwt),
refreshAfter,
jwtExp: jwt?.exp,
jwtIat: jwt?.iat,
})
throw new UnauthorizedError(
'Unauthorized: Token expired. Please login again or refresh the page.'
)
Expand Down
72 changes: 56 additions & 16 deletions packages/core/src/sdk/account/refreshToken.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,86 @@
import discoveryConfig from 'discovery.config'
import fetch from 'isomorphic-unfetch'
import { sanitizeHost } from 'src/utils/utilities'

const REFRESH_TOKEN_URL = `${discoveryConfig.storeUrl}/api/vtexid/refreshtoken/webstore`

export interface RefreshTokenResponse {
status?: string
refreshAfter?: string
}

const PROXY_PATH = '/api/fs/refresh-token'

let inFlightRefresh: Promise<RefreshTokenResponse | undefined> | null = null

async function fetchWithRetry(
url: RequestInfo | URL,
init?: RequestInit,
init: RequestInit,
maxRetries = 3
): Promise<RefreshTokenResponse | undefined> {
let lastStatus = 0
let lastBody = ''

for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url, init)
if (res.status !== 200) continue
lastStatus = res.status
lastBody = await res.text()

const data = await res.json()
return data
} catch {}
if (res.status !== 200) {
console.error(
'[refreshTokenRequest] non-200 response',
lastStatus,
lastBody.slice(0, 500)
)
continue
}
Comment on lines +27 to +34
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Retrying on 404 may cause unnecessary delays when feature is disabled server-side.

If the server has experimental.refreshToken disabled (returns 404), the client will retry 3 times before giving up. Consider treating 404 as a non-retryable status to fail fast when there's a client/server config mismatch.

Suggested fix
       if (res.status !== 200) {
+        // 404 means feature disabled server-side - don't retry
+        if (res.status === 404) {
+          console.warn('[refreshTokenRequest] refresh-token endpoint not enabled')
+          return undefined
+        }
         console.error(
           '[refreshTokenRequest] non-200 response',
           lastStatus,
           lastBody.slice(0, 500)
         )
         continue
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (res.status !== 200) {
console.error(
'[refreshTokenRequest] non-200 response',
lastStatus,
lastBody.slice(0, 500)
)
continue
}
if (res.status !== 200) {
// 404 means feature disabled server-side - don't retry
if (res.status === 404) {
console.warn('[refreshTokenRequest] refresh-token endpoint not enabled')
return undefined
}
console.error(
'[refreshTokenRequest] non-200 response',
lastStatus,
lastBody.slice(0, 500)
)
continue
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/sdk/account/refreshToken.ts` around lines 27 - 34, The
retry loop in refreshTokenRequest currently treats any non-200 response the same
and continues retrying; change behavior so that if res.status === 404 you do not
retry but immediately fail fast (log or throw an error) to avoid unnecessary
delays when experimental.refreshToken is disabled. Locate the
refreshTokenRequest function and adjust the non-200 branch that checks
res.status to handle 404 as a non-retryable case (use the res.status value)
instead of executing the continue path, ensuring other status codes still follow
the existing retry logic.


try {
return JSON.parse(lastBody) as RefreshTokenResponse
} catch {
console.error(
'[refreshTokenRequest] invalid JSON',
lastBody.slice(0, 500)
)
}
} catch (err) {
console.error('[refreshTokenRequest] fetch error', err)
}
}

console.error(
'[refreshTokenRequest] exhausted retries',
lastStatus,
lastBody.slice(0, 500)
)
return undefined
}

export const refreshTokenRequest = async (): Promise<
RefreshTokenResponse | undefined
> => {
const headers: HeadersInit = {
'content-type': 'application/json',
Host: `${sanitizeHost(discoveryConfig.storeUrl)}`,
if (!discoveryConfig.experimental?.refreshToken) {
return undefined
}

if (inFlightRefresh) {
return inFlightRefresh
}

return await fetchWithRetry(REFRESH_TOKEN_URL, {
credentials: 'include',
headers,
body: JSON.stringify({}),
method: 'POST',
inFlightRefresh = fetchWithRetry(
PROXY_PATH,
{
credentials: 'include',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({}),
method: 'POST',
},
3
).finally(() => {
inFlightRefresh = null
})

return inFlightRefresh
}

export const isRefreshTokenSuccessful = (
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/sdk/account/useRefreshToken.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { useEffect, useState } from 'react'
import {
clearRefreshFailureCount,
incrementRefreshFailureCount,
MAX_REFRESH_RETRIES,
} from 'src/utils/refreshTokenRetry'
import { sessionStore } from '../session'
import { isRefreshTokenSuccessful, refreshTokenRequest } from './refreshToken'

Expand All @@ -18,6 +23,7 @@ export const useRefreshToken = (
const result = await refreshTokenRequest()

if (isRefreshTokenSuccessful(result)) {
clearRefreshFailureCount()
// Update session with new refreshAfter timestamp
const refreshAfter = String(
Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
Expand All @@ -37,6 +43,13 @@ export const useRefreshToken = (
url.searchParams.set('_refresh', Date.now().toString())
window.location.href = url.toString()
} else {
const failures = incrementRefreshFailureCount()
if (failures >= MAX_REFRESH_RETRIES) {
clearRefreshFailureCount()
sessionStore.set(sessionStore.readInitial())
window.location.href = '/login'
return
}
Comment on lines +46 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check all call sites of validateSession and how they might overlap with useRefreshToken
echo "=== validateSession call sites ==="
rg -n "validateSession\(" --type=ts -g '!*.test.ts' -g '!*.d.ts' -C2

echo ""
echo "=== useRefreshToken usage ==="
rg -n "useRefreshToken\(" --type=ts -g '!*.test.ts' -g '!*.d.ts' -C2

Repository: vtex/faststore

Length of output: 5456


🏁 Script executed:

cat -n packages/core/src/sdk/account/useRefreshToken.ts

Repository: vtex/faststore

Length of output: 3589


🏁 Script executed:

cat -n packages/core/src/sdk/session/index.ts

Repository: vtex/faststore

Length of output: 10416


Coordinate refresh token retry counting to prevent double-increments.

Both useRefreshToken (lines 46, 64) and validateSession (line 144 in session/index.ts) independently increment the same failure counter when handling 401 errors. If a single auth failure triggers both code paths—for example, when validateSession executes from useGeolocation or useRegion while the 403 page is simultaneously running useRefreshToken—the counter increments twice, causing premature logout.

Consider centralizing the retry logic to a single call site or adding timestamp-based deduplication to prevent double-counting within a short window.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/sdk/account/useRefreshToken.ts` around lines 46 - 52, The
failure counter is being incremented in both useRefreshToken and validateSession
causing double-counts; modify the retry logic so only one site updates the
counter (preferably centralize into incrementRefreshFailureCount) or make
incrementRefreshFailureCount idempotent by adding short-window deduplication:
store a lastFailureTimestamp and only increment if now - lastFailureTimestamp >
DEDUP_WINDOW_MS, and ensure clearRefreshFailureCount/reset timestamps are called
(clearRefreshFailureCount) when a refresh succeeds; update references in
useRefreshToken and validateSession to rely on the centralized increment (or the
timestamp-guarded increment) and keep MAX_REFRESH_RETRIES checks using the
single source of truth.

// If refresh token failed, set refreshAfter to now + 1 hour
sessionStore.set({
...currentSession,
Expand All @@ -48,6 +61,14 @@ export const useRefreshToken = (
} catch (error) {
console.error('Error during refresh token process:', error)

const failures = incrementRefreshFailureCount()
if (failures >= MAX_REFRESH_RETRIES) {
clearRefreshFailureCount()
sessionStore.set(sessionStore.readInitial())
window.location.href = '/login'
return
}

// Set refreshAfter to postpone future requests and redirect to login
sessionStore.set({
...currentSession,
Expand Down
Loading
Loading