Skip to content

feat: proxy VTEX refresh token and tighten ValidateSession refresh logic#3261

Open
renatomaurovtex wants to merge 1 commit intodevfrom
feat/session-refresh-token-24h-expiration
Open

feat: proxy VTEX refresh token and tighten ValidateSession refresh logic#3261
renatomaurovtex wants to merge 1 commit intodevfrom
feat/session-refresh-token-24h-expiration

Conversation

@renatomaurovtex
Copy link
Copy Markdown
Contributor

@renatomaurovtex renatomaurovtex commented Apr 7, 2026

  • Add POST /api/fs/refresh-token to forward cookies to VTEX Identity and return Set-Cookie headers to the browser
  • Route refreshTokenRequest through the same-origin proxy with retries, failure logging, and single in-flight deduplication
  • Extract ValidateSession shouldRefreshToken rules (60s grace, first-login iat window, jwt-present expired path) and log server-side reasons
  • Track consecutive refresh failures in sessionStorage; after 3 failures reset session and redirect to /login (403 flow and validateSession)
  • Add unit tests for refresh policy, getCookie isExpired, and refreshTokenRequest

Made-with: Cursor

What's the purpose of this pull request?

How it works?

How to test it?

Starters Deploy Preview

References

Checklist

You may erase this after checking them all 😉

PR Title and Commit Messages

  • PR title and commit messages follow the Conventional Commits specification
    • Available prefixes: feat, fix, chore, docs, style, refactor, ci and test

PR Description

  • Added a label according to the PR goal - breaking change, bug, contributing, performance, documentation..

Dependencies

  • Committed the pnpm-lock.yaml file when there were changes to the packages

Documentation

  • PR description
  • For documentation changes, ping @Mariana-Caetano to review and update (Or submit a doc request)

Summary by CodeRabbit

Release Notes

  • New Features

    • Token refresh now includes retry logic with automatic login redirect after maximum retry attempts
    • Concurrent token refresh requests are deduplicated to prevent redundant operations
    • Session validation now includes grace period for improved token expiration handling
  • Improvements

    • Enhanced session management with better error tracking and failure handling

- Add POST /api/fs/refresh-token to forward cookies to VTEX Identity and
  return Set-Cookie headers to the browser
- Route refreshTokenRequest through the same-origin proxy with retries,
  failure logging, and single in-flight deduplication
- Extract ValidateSession shouldRefreshToken rules (60s grace, first-login
  iat window, jwt-present expired path) and log server-side reasons
- Track consecutive refresh failures in sessionStorage; after 3 failures
  reset session and redirect to /login (403 flow and validateSession)
- Add unit tests for refresh policy, getCookie isExpired, and refreshTokenRequest

Made-with: Cursor
@renatomaurovtex renatomaurovtex requested a review from a team as a code owner April 7, 2026 12:30
@renatomaurovtex renatomaurovtex requested review from ommeirelles and renatamottam and removed request for a team April 7, 2026 12:30
@codesandbox-ci
Copy link
Copy Markdown

codesandbox-ci Bot commented Apr 7, 2026

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 7, 2026

Walkthrough

This PR introduces a new proxy endpoint for VTEX refresh-token requests, refactors session refresh logic into reusable decision utilities, implements retry tracking with failure limits to prevent infinite loops, and adds comprehensive test coverage for the new flows.

Changes

Cohort / File(s) Summary
Refresh-Token Proxy & Request Normalization
packages/core/src/pages/api/fs/refresh-token.ts, packages/core/src/utils/normalizeSetCookieForRequest.ts
New API route proxies VTEX refresh-token requests with Cookie forwarding and domain normalization; extracts and normalizes set-cookie headers based on request hostname and allowlist validation.
Refresh Decision Utilities
packages/core/src/utils/sessionValidateRefreshDecision.ts, packages/core/src/utils/refreshTokenRetry.ts
New utilities determine JWT/refresh-token eligibility, compute refresh decisions with grace-window logic, track consecutive refresh failures via sessionStorage, and enforce retry limits.
Session & SDK Refactoring
packages/core/src/pages/api/graphql.ts, packages/core/src/sdk/account/refreshToken.ts, packages/core/src/sdk/account/useRefreshToken.ts, packages/core/src/sdk/session/index.ts
Replaced inline refresh-token logic with new utility functions; added feature gating via discoveryConfig.experimental.refreshToken; integrated in-flight deduplication and structured error logging; added retry-tracking with max-failure redirect to login.
Test Coverage
packages/core/test/sdk/account/refreshToken.test.ts, packages/core/test/utils/getCookie.test.ts, packages/core/test/utils/sessionValidateRefreshDecision.test.ts
New test suites for proxy behavior (fetch, retry, deduplication), JWT expiration boundary conditions, and refresh-decision logic (grace windows, eligibility, reason codes).

Sequence Diagram

sequenceDiagram
    participant Client
    participant Proxy as /api/fs/refresh-token
    participant VTEX as VTEX API
    participant SessionStore
    participant SessionStorage as sessionStorage<br/>(Retry Counter)
    
    Client->>Proxy: POST (Cookie included)
    alt Feature Disabled
        Proxy-->>Client: 404
    else Invalid Request
        Proxy-->>Client: 401/400
    else Proxy Session Active
        Proxy->>VTEX: POST /api/vtexid/refreshtoken/webstore<br/>(forward Cookie, Host)
        VTEX-->>Proxy: Response with set-cookie
        Note over Proxy: Normalize Domain based on<br/>request hostname & allowlist
        Proxy-->>Client: 200 with normalized cookies
    else VTEX Request Fails
        alt Max Retries Exceeded
            Proxy->>SessionStorage: increment failure counter
            SessionStorage-->>SessionStore: trigger reset on max
            SessionStore-->>Client: redirect to /login
        else Retry Available
            Proxy->>SessionStorage: increment failure counter
            SessionStorage-->>Proxy: return new count
            Proxy-->>Client: 500
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • lemagnetic
  • lariciamota
  • hellofanny

Poem

🔄 A token walks into a proxy,
With cookies tucked and domain cozy,
It checks the clock with grace so fine,
Retries thrice before the line—
Then off to login, fresh and bright! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the two main changes: adding a VTEX refresh token proxy endpoint and refactoring ValidateSession refresh logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/session-refresh-token-24h-expiration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (14)
packages/core/test/sdk/account/refreshToken.test.ts (1)

32-36: Consider adding a test for disabled feature flag.

The refreshTokenRequest function returns undefined when discoveryConfig.experimental?.refreshToken is falsy. Adding a test for this case would ensure the feature gate works correctly.

Suggested test case
it('returns undefined when refreshToken feature is disabled', async () => {
  // You'd need to reset the mock or use jest.isolateModules
  jest.resetModules()
  jest.doMock('discovery.config', () => ({
    __esModule: true,
    default: { experimental: { refreshToken: false } },
  }))
  
  const { refreshTokenRequest } = await import('../../../src/sdk/account/refreshToken')
  const result = await refreshTokenRequest()
  expect(result).toBeUndefined()
  expect(mockedUnfetch).not.toHaveBeenCalled()
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/sdk/account/refreshToken.test.ts` around lines 32 - 36,
Add a unit test that verifies refreshTokenRequest returns undefined and does not
call mockedUnfetch when the feature flag is disabled: reset module state
(jest.resetModules or jest.isolateModules), mock the discovery config module to
export { experimental: { refreshToken: false } }, import refreshTokenRequest
from '../../../src/sdk/account/refreshToken', call refreshTokenRequest(), and
assert the result is undefined and mockedUnfetch was not called; reference the
refreshTokenRequest function and mockedUnfetch in the test to locate where to
add this case.
packages/core/src/sdk/account/refreshToken.ts (1)

28-32: Consider limiting logged response body content.

Logging lastBody.slice(0, 500) could inadvertently expose sensitive information from error responses. Consider logging only the status code and a generic message, or sanitizing the body content.

Also applies to: 39-42, 49-53

🤖 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 28 - 32, In
refreshTokenRequest, avoid logging raw response bodies (lastBody) to prevent
leaking sensitive data; update the console.error calls (the ones referencing
lastStatus and lastBody) to only log the status and a sanitized or generic
message (e.g., "non-200 response" plus lastStatus) or a redacted/truncated
fingerprint (like length or hash) instead of lastBody.slice(0,500); apply the
same change to the other error logging sites in this file (the similar
console.error blocks around the other occurrences) so only non-sensitive
metadata is logged.
packages/core/test/utils/sessionValidateRefreshDecision.test.ts (1)

20-40: Consider adding boundary test at exactly the grace threshold.

The tests check +1 and -1 around the grace boundary but not the exact boundary (nowSec + SESSION_REFRESH_EXPIRY_GRACE_SEC). Adding this edge case would verify the >= vs > comparison behavior.

Suggested addition
it('returns true when exp is exactly at the grace boundary', () => {
  expect(
    isExpiredForSessionRefresh(nowSec + SESSION_REFRESH_EXPIRY_GRACE_SEC)
  ).toBe(true) // or false, depending on intended behavior
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/utils/sessionValidateRefreshDecision.test.ts` around lines
20 - 40, Add a boundary test for isExpiredForSessionRefresh to cover the exact
grace threshold by asserting the result for exp = nowSec +
SESSION_REFRESH_EXPIRY_GRACE_SEC; update the test suite around
isExpiredForSessionRefresh to include a new it case that calls
isExpiredForSessionRefresh(nowSec + SESSION_REFRESH_EXPIRY_GRACE_SEC) and
expects the behavior consistent with intended comparison (>= vs >) so the
implementation's boundary behavior is verified.
packages/core/src/sdk/account/useRefreshToken.ts (1)

46-52: Duplicated retry-failure handling logic.

The retry counting and redirect logic at lines 46-52 is identical to lines 64-70. Extract this into a helper to reduce duplication and ensure consistent behavior.

Suggested extraction
+const handleRefreshFailure = (currentSession: Session) => {
+  const failures = incrementRefreshFailureCount()
+  if (failures >= MAX_REFRESH_RETRIES) {
+    clearRefreshFailureCount()
+    sessionStore.set(sessionStore.readInitial())
+    window.location.href = '/login'
+    return true // handled with redirect
+  }
+  sessionStore.set({
+    ...currentSession,
+    refreshAfter: String(Math.floor(Date.now() / 1000) + 1 * 60 * 60),
+  })
+  return false
+}

// Then in both branches:
} else {
-  const failures = incrementRefreshFailureCount()
-  if (failures >= MAX_REFRESH_RETRIES) {
-    clearRefreshFailureCount()
-    sessionStore.set(sessionStore.readInitial())
-    window.location.href = '/login'
-    return
-  }
-  sessionStore.set({
-    ...currentSession,
-    refreshAfter: String(Math.floor(Date.now() / 1000) + 1 * 60 * 60),
-  })
+  if (handleRefreshFailure(currentSession)) return
   setShouldShow403(true)
}

Also applies to: 64-70

🤖 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
retry/failure handling logic using incrementRefreshFailureCount(),
MAX_REFRESH_RETRIES, clearRefreshFailureCount(),
sessionStore.set(sessionStore.readInitial()) and window.location.href = '/login'
is duplicated; extract it into a single helper (e.g., handleRefreshFailure or
checkAndHandleRefreshFailure) that increments the failure count, checks against
MAX_REFRESH_RETRIES, clears the count, resets the session via
sessionStore.set(sessionStore.readInitial()), redirects to '/login' and returns
a boolean/void as needed, then replace both duplicated blocks in
useRefreshToken.ts with calls to that helper to ensure consistent behavior.
packages/core/src/utils/normalizeSetCookieForRequest.ts (1)

3-3: Consider adding a dot prefix to localhost for consistent suffix matching.

The suffix 'localhost' (without a leading dot) will match any hostname ending in localhost, including maliciouslocalhost.com. The .vtex.app and .localhost entries correctly require a subdomain separator, but plain localhost is permissive.

Suggested safer allowlist
-const ALLOWED_HOST_SUFFIXES = ['localhost', '.vtex.app', '.localhost']
+const ALLOWED_HOST_SUFFIXES = ['.localhost', '.vtex.app']
+const ALLOWED_EXACT_HOSTS = ['localhost']

Then update isAllowedHost to check exact matches as well:

const isAllowedHost = ({ host, allowList }: { host: string; allowList: string[] }) => {
  const normalizedHost = host.toLowerCase()
  return (
    ALLOWED_EXACT_HOSTS.includes(normalizedHost) ||
    allowList.some((suffix) => normalizedHost.endsWith(suffix))
  )
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/utils/normalizeSetCookieForRequest.ts` at line 3, Change
the permissive 'localhost' entry in ALLOWED_HOST_SUFFIXES to require a leading
dot (use '.localhost') and add an ALLOWED_EXACT_HOSTS array for exact host
matches; then update isAllowedHost to first check if the normalized host is in
ALLOWED_EXACT_HOSTS and fall back to allowList.some(suffix =>
normalizedHost.endsWith(suffix)). Reference ALLOWED_HOST_SUFFIXES and
isAllowedHost when making these updates so suffix checks require a dot and exact
matches like 'localhost' are handled safely.
packages/core/src/sdk/session/index.ts (1)

134-155: SSR-safe redirect handling, but duplicates the retry pattern from useRefreshToken.

The typeof window !== 'undefined' check on line 145 is necessary since validateSession can run server-side during SSR. However, this retry-failure handling block duplicates logic from useRefreshToken. Consider extracting to a shared helper that both can use.

Also, after window.location.href = '/login' at line 148, the code returns null but navigation is asynchronous—any code running after validateSession returns could still execute before the redirect completes. This is likely fine given the return null, but worth noting.

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

In `@packages/core/src/sdk/session/index.ts` around lines 134 - 155, The
retry-failure + SSR-safe redirect block in validateSession duplicates logic in
useRefreshToken; extract that behavior into a shared helper (e.g.,
handleRefreshFailure) that uses incrementRefreshFailureCount,
clearRefreshFailureCount and MAX_REFRESH_RETRIES, performs the typeof window !==
'undefined' guard, resets sessionStore via
sessionStore.set(sessionStore.readInitial()) and triggers window.location.href =
'/login' when threshold reached, then call this helper from validateSession and
useRefreshToken and remove the duplicated block; ensure the helper returns a
boolean or null marker so callers can early-return (as validateSession currently
returns null after redirect).
packages/core/src/utils/sessionValidateRefreshDecision.ts (4)

80-82: Redundant optional chaining on line 81.

jwt?.exp uses optional chaining, but jwt is already verified truthy in the outer Boolean(jwt && ...) condition. Minor inconsistency with line 46 which uses jwt.exp.

🧹 Consistency fix
   const tokenExpired = Boolean(
-    jwt && isExpiredForSessionRefresh(Number(jwt?.exp))
+    jwt && isExpiredForSessionRefresh(Number(jwt.exp))
   )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/utils/sessionValidateRefreshDecision.ts` around lines 80 -
82, Remove the redundant optional chaining in the token expiration check: where
tokenExpired is computed using Boolean(jwt &&
isExpiredForSessionRefresh(Number(jwt?.exp))), change jwt?.exp to jwt.exp so the
call to isExpiredForSessionRefresh uses jwt.exp (jwt is already checked truthy);
update the expression in the tokenExpired declaration to mirror the style used
elsewhere (e.g., line 46) and keep the call to
isExpiredForSessionRefresh(Number(jwt.exp)) intact.

55-64: tokenExpiredWithJwtPresent duplicates check already in tokenExpired.

tokenExpired is defined as Boolean(jwt && isExpiredForSessionRefresh(...)), so jwt presence is already baked in. Line 58's Boolean(jwt && tokenExpired) is equivalent to just tokenExpired.

🧹 Simplify
-  const tokenExpiredWithJwtPresent = Boolean(jwt && tokenExpired)
-
   return (
     tokenExistAndIsFirstRefreshTokenRequest ||
     tokenNotExistAndRefreshAfterExistAndIsExpired ||
-    tokenExpiredWithJwtPresent
+    tokenExpired
   )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/utils/sessionValidateRefreshDecision.ts` around lines 55 -
64, The variable tokenExpiredWithJwtPresent is redundant because tokenExpired
already includes the jwt presence check; remove or replace
tokenExpiredWithJwtPresent and use tokenExpired directly in the returned boolean
expression (update the return that currently references
tokenExpiredWithJwtPresent and eliminate the tokenExpiredWithJwtPresent
declaration) so the logic uses tokenExistAndIsFirstRefreshTokenRequest,
tokenNotExistAndRefreshAfterExistAndIsExpired, and tokenExpired only.

67-96: describeShouldRefreshTokenReason duplicates logic from computeShouldRefreshToken.

Both functions compute tokenExpired, refreshAfterExpired, and call isJwtEligibleForFirstRefreshTokenRequest. Consider refactoring to compute once and return both the boolean and reason together, or have describeShouldRefreshTokenReason call a shared internal helper.

This is a minor DRY concern—acceptable for now given the code is well-tested, but worth addressing if this logic grows.

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

In `@packages/core/src/utils/sessionValidateRefreshDecision.ts` around lines 67 -
96, describeShouldRefreshTokenReason duplicates computeShouldRefreshToken logic;
factor out the shared computation into a small internal helper and have both
functions use it. Create a helper (e.g., computeRefreshDecision or similar) that
accepts the same params and returns the boolean shouldRefresh plus the derived
values (refreshAfterExist, tokenExpired, refreshAfterExpired, and jwt), then
update computeShouldRefreshToken to call that helper and return shouldRefresh,
and update describeShouldRefreshTokenReason to call the helper and use its
derived flags and isJwtEligibleForFirstRefreshTokenRequest to pick and return
the correct reason string.

45-50: Number() on potentially undefined exp yields NaN.

If jwt.exp is undefined, Number(undefined) returns NaN, and isExpiredForSessionRefresh(NaN) will return true (since now + grace > NaN is false, but wait—actually NaN comparisons always return false). This means a JWT without exp would set tokenExpired = false, which may be the intended behavior but is implicit and could mask issues.

Consider explicit handling:

🛡️ Explicit undefined handling
   const tokenExpired = Boolean(
-    jwt && isExpiredForSessionRefresh(Number(jwt.exp))
+    jwt && jwt.exp != null && isExpiredForSessionRefresh(jwt.exp)
   )

   const refreshAfterExpired =
-    refreshAfterExist && isExpiredForSessionRefresh(Number(refreshAfter))
+    refreshAfterExist && isExpiredForSessionRefresh(Number(refreshAfter))

For refreshAfter, since it's a string, Number() is appropriate, but consider validating it's a valid numeric string if the source is untrusted.

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

In `@packages/core/src/utils/sessionValidateRefreshDecision.ts` around lines 45 -
50, The code currently calls Number(jwt.exp) which yields NaN when jwt.exp is
undefined; change the logic in tokenExpired to explicitly handle missing exp
(e.g., set tokenExpired = false when jwt?.exp is undefined) instead of passing
NaN into isExpiredForSessionRefresh, and for refreshAfter use Number only after
validating refreshAfterExist and that Number(refreshAfter) is a finite number
(use Number.isFinite or !Number.isNaN) before calling
isExpiredForSessionRefresh; update the expressions that reference tokenExpired,
jwt.exp, isExpiredForSessionRefresh, refreshAfterExist, and refreshAfter
accordingly.
packages/core/src/pages/api/fs/refresh-token.ts (4)

75-78: Nullish coalescing on data is redundant.

At line 77, data is guaranteed to be defined (either parsed JSON or {} from line 55). The ?? { status: 'Error' } fallback will never execute.

🧹 Minor cleanup
       response
         .status(vtexRes.status === 401 ? 401 : 500)
-        .json(data ?? { status: 'Error' })
+        .json(data)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/pages/api/fs/refresh-token.ts` around lines 75 - 78, The
JSON response uses a redundant nullish fallback for the variable `data` in the
response path (inside the handler that constructs `vtexRes`/`data` and calls
`response.status(...).json(...)`); remove the `?? { status: 'Error' }` and
simply call `.json(data)` since `data` is always defined (parsed JSON or `{}`
from earlier), leaving the status logic (`vtexRes.status === 401 ? 401 : 500`)
unchanged.

82-85: Catch block should distinguish abort errors if timeout is added.

Currently the catch logs all errors uniformly. If you add the AbortController timeout, consider checking for AbortError to log a more specific message (e.g., "VTEX request timed out").

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

In `@packages/core/src/pages/api/fs/refresh-token.ts` around lines 82 - 85, Update
the catch in the API handler in refresh-token.ts to detect abort/timeouts: when
catching errors from the proxied VTEX call (the catch for err in the handler),
check whether the error is an AbortError (e.g., err.name === 'AbortError' or
using AbortError type) and log a specific message like "VTEX request timed out"
and return an appropriate status (e.g., 504) instead of the generic 500;
otherwise keep the existing console.error and response.status(500).end()
behavior. Ensure this logic is applied where AbortController is used for the
timeout around the request.

41-50: Consider adding a request timeout.

The fetch call has no timeout. If VTEX Identity is slow or unresponsive, this request will hang until the serverless function times out (typically 10-30s depending on deployment config). Adding an AbortController with a reasonable timeout (e.g., 5-10s) would improve resilience.

⏱️ Suggested timeout implementation
+const VTEX_TIMEOUT_MS = 10_000
+
 const handler: NextApiHandler = async (request, response) => {
   // ... existing guards ...

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

   try {
+    const controller = new AbortController()
+    const timeoutId = setTimeout(() => controller.abort(), VTEX_TIMEOUT_MS)
+
     const vtexRes = await fetch(url, {
       method: 'POST',
       headers: {
         'content-type': 'application/json',
         cookie: cookieHeader,
         Host: `${sanitizeHost(discoveryConfig.storeUrl)}`,
       },
       body: JSON.stringify({}),
+      signal: controller.signal,
     })
+
+    clearTimeout(timeoutId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/pages/api/fs/refresh-token.ts` around lines 41 - 50, The
fetch call that assigns vtexRes lacks a timeout; wrap the request in an
AbortController (create controller, pass controller.signal into the fetch used
in refresh-token.ts) and start a timer (e.g., 5–10s) that calls
controller.abort() if elapsed, then clear the timer after fetch completes;
update the try/catch around the fetch to specifically handle an abort/timeout
error and surface a clear message while still using existing symbols like
vtexRes, cookieHeader, sanitizeHost and discoveryConfig.storeUrl; ensure the
controller timer is always cleared to avoid leaks.

54-60: Type assertion on JSON.parse result.

JSON.parse(text) as Record<string, unknown> is a type assertion. While acceptable here since the VTEX API contract is known, consider adding a minimal runtime check if the response structure matters for downstream logic.

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

In `@packages/core/src/pages/api/fs/refresh-token.ts` around lines 54 - 60, The
code currently asserts the result of JSON.parse into data without verifying its
shape; after parsing (JSON.parse(text)) validate that the result is a non-null
object (and if downstream logic expects specific keys, check those keys, e.g.,
presence of "refresh_token" or other required fields) and if the check fails log
a clear error and return the 500 response (use the same
response.status(...).json(...) flow). Update the block around JSON.parse(text)
and the data variable to perform this minimal runtime validation before
proceeding.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/sdk/account/refreshToken.ts`:
- Around line 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.

In `@packages/core/src/sdk/account/useRefreshToken.ts`:
- Around line 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.

---

Nitpick comments:
In `@packages/core/src/pages/api/fs/refresh-token.ts`:
- Around line 75-78: The JSON response uses a redundant nullish fallback for the
variable `data` in the response path (inside the handler that constructs
`vtexRes`/`data` and calls `response.status(...).json(...)`); remove the `?? {
status: 'Error' }` and simply call `.json(data)` since `data` is always defined
(parsed JSON or `{}` from earlier), leaving the status logic (`vtexRes.status
=== 401 ? 401 : 500`) unchanged.
- Around line 82-85: Update the catch in the API handler in refresh-token.ts to
detect abort/timeouts: when catching errors from the proxied VTEX call (the
catch for err in the handler), check whether the error is an AbortError (e.g.,
err.name === 'AbortError' or using AbortError type) and log a specific message
like "VTEX request timed out" and return an appropriate status (e.g., 504)
instead of the generic 500; otherwise keep the existing console.error and
response.status(500).end() behavior. Ensure this logic is applied where
AbortController is used for the timeout around the request.
- Around line 41-50: The fetch call that assigns vtexRes lacks a timeout; wrap
the request in an AbortController (create controller, pass controller.signal
into the fetch used in refresh-token.ts) and start a timer (e.g., 5–10s) that
calls controller.abort() if elapsed, then clear the timer after fetch completes;
update the try/catch around the fetch to specifically handle an abort/timeout
error and surface a clear message while still using existing symbols like
vtexRes, cookieHeader, sanitizeHost and discoveryConfig.storeUrl; ensure the
controller timer is always cleared to avoid leaks.
- Around line 54-60: The code currently asserts the result of JSON.parse into
data without verifying its shape; after parsing (JSON.parse(text)) validate that
the result is a non-null object (and if downstream logic expects specific keys,
check those keys, e.g., presence of "refresh_token" or other required fields)
and if the check fails log a clear error and return the 500 response (use the
same response.status(...).json(...) flow). Update the block around
JSON.parse(text) and the data variable to perform this minimal runtime
validation before proceeding.

In `@packages/core/src/sdk/account/refreshToken.ts`:
- Around line 28-32: In refreshTokenRequest, avoid logging raw response bodies
(lastBody) to prevent leaking sensitive data; update the console.error calls
(the ones referencing lastStatus and lastBody) to only log the status and a
sanitized or generic message (e.g., "non-200 response" plus lastStatus) or a
redacted/truncated fingerprint (like length or hash) instead of
lastBody.slice(0,500); apply the same change to the other error logging sites in
this file (the similar console.error blocks around the other occurrences) so
only non-sensitive metadata is logged.

In `@packages/core/src/sdk/account/useRefreshToken.ts`:
- Around line 46-52: The retry/failure handling logic using
incrementRefreshFailureCount(), MAX_REFRESH_RETRIES, clearRefreshFailureCount(),
sessionStore.set(sessionStore.readInitial()) and window.location.href = '/login'
is duplicated; extract it into a single helper (e.g., handleRefreshFailure or
checkAndHandleRefreshFailure) that increments the failure count, checks against
MAX_REFRESH_RETRIES, clears the count, resets the session via
sessionStore.set(sessionStore.readInitial()), redirects to '/login' and returns
a boolean/void as needed, then replace both duplicated blocks in
useRefreshToken.ts with calls to that helper to ensure consistent behavior.

In `@packages/core/src/sdk/session/index.ts`:
- Around line 134-155: The retry-failure + SSR-safe redirect block in
validateSession duplicates logic in useRefreshToken; extract that behavior into
a shared helper (e.g., handleRefreshFailure) that uses
incrementRefreshFailureCount, clearRefreshFailureCount and MAX_REFRESH_RETRIES,
performs the typeof window !== 'undefined' guard, resets sessionStore via
sessionStore.set(sessionStore.readInitial()) and triggers window.location.href =
'/login' when threshold reached, then call this helper from validateSession and
useRefreshToken and remove the duplicated block; ensure the helper returns a
boolean or null marker so callers can early-return (as validateSession currently
returns null after redirect).

In `@packages/core/src/utils/normalizeSetCookieForRequest.ts`:
- Line 3: Change the permissive 'localhost' entry in ALLOWED_HOST_SUFFIXES to
require a leading dot (use '.localhost') and add an ALLOWED_EXACT_HOSTS array
for exact host matches; then update isAllowedHost to first check if the
normalized host is in ALLOWED_EXACT_HOSTS and fall back to allowList.some(suffix
=> normalizedHost.endsWith(suffix)). Reference ALLOWED_HOST_SUFFIXES and
isAllowedHost when making these updates so suffix checks require a dot and exact
matches like 'localhost' are handled safely.

In `@packages/core/src/utils/sessionValidateRefreshDecision.ts`:
- Around line 80-82: Remove the redundant optional chaining in the token
expiration check: where tokenExpired is computed using Boolean(jwt &&
isExpiredForSessionRefresh(Number(jwt?.exp))), change jwt?.exp to jwt.exp so the
call to isExpiredForSessionRefresh uses jwt.exp (jwt is already checked truthy);
update the expression in the tokenExpired declaration to mirror the style used
elsewhere (e.g., line 46) and keep the call to
isExpiredForSessionRefresh(Number(jwt.exp)) intact.
- Around line 55-64: The variable tokenExpiredWithJwtPresent is redundant
because tokenExpired already includes the jwt presence check; remove or replace
tokenExpiredWithJwtPresent and use tokenExpired directly in the returned boolean
expression (update the return that currently references
tokenExpiredWithJwtPresent and eliminate the tokenExpiredWithJwtPresent
declaration) so the logic uses tokenExistAndIsFirstRefreshTokenRequest,
tokenNotExistAndRefreshAfterExistAndIsExpired, and tokenExpired only.
- Around line 67-96: describeShouldRefreshTokenReason duplicates
computeShouldRefreshToken logic; factor out the shared computation into a small
internal helper and have both functions use it. Create a helper (e.g.,
computeRefreshDecision or similar) that accepts the same params and returns the
boolean shouldRefresh plus the derived values (refreshAfterExist, tokenExpired,
refreshAfterExpired, and jwt), then update computeShouldRefreshToken to call
that helper and return shouldRefresh, and update
describeShouldRefreshTokenReason to call the helper and use its derived flags
and isJwtEligibleForFirstRefreshTokenRequest to pick and return the correct
reason string.
- Around line 45-50: The code currently calls Number(jwt.exp) which yields NaN
when jwt.exp is undefined; change the logic in tokenExpired to explicitly handle
missing exp (e.g., set tokenExpired = false when jwt?.exp is undefined) instead
of passing NaN into isExpiredForSessionRefresh, and for refreshAfter use Number
only after validating refreshAfterExist and that Number(refreshAfter) is a
finite number (use Number.isFinite or !Number.isNaN) before calling
isExpiredForSessionRefresh; update the expressions that reference tokenExpired,
jwt.exp, isExpiredForSessionRefresh, refreshAfterExist, and refreshAfter
accordingly.

In `@packages/core/test/sdk/account/refreshToken.test.ts`:
- Around line 32-36: Add a unit test that verifies refreshTokenRequest returns
undefined and does not call mockedUnfetch when the feature flag is disabled:
reset module state (jest.resetModules or jest.isolateModules), mock the
discovery config module to export { experimental: { refreshToken: false } },
import refreshTokenRequest from '../../../src/sdk/account/refreshToken', call
refreshTokenRequest(), and assert the result is undefined and mockedUnfetch was
not called; reference the refreshTokenRequest function and mockedUnfetch in the
test to locate where to add this case.

In `@packages/core/test/utils/sessionValidateRefreshDecision.test.ts`:
- Around line 20-40: Add a boundary test for isExpiredForSessionRefresh to cover
the exact grace threshold by asserting the result for exp = nowSec +
SESSION_REFRESH_EXPIRY_GRACE_SEC; update the test suite around
isExpiredForSessionRefresh to include a new it case that calls
isExpiredForSessionRefresh(nowSec + SESSION_REFRESH_EXPIRY_GRACE_SEC) and
expects the behavior consistent with intended comparison (>= vs >) so the
implementation's boundary behavior is verified.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 915dc805-3edf-47ac-9aa0-33681b076799

📥 Commits

Reviewing files that changed from the base of the PR and between 9495157 and a042795.

⛔ Files ignored due to path filters (1)
  • specs/session-refresh-token-24h-expiration.md is excluded by none and included by none
📒 Files selected for processing (11)
  • packages/core/src/pages/api/fs/refresh-token.ts
  • packages/core/src/pages/api/graphql.ts
  • packages/core/src/sdk/account/refreshToken.ts
  • packages/core/src/sdk/account/useRefreshToken.ts
  • packages/core/src/sdk/session/index.ts
  • packages/core/src/utils/normalizeSetCookieForRequest.ts
  • packages/core/src/utils/refreshTokenRetry.ts
  • packages/core/src/utils/sessionValidateRefreshDecision.ts
  • packages/core/test/sdk/account/refreshToken.test.ts
  • packages/core/test/utils/getCookie.test.ts
  • packages/core/test/utils/sessionValidateRefreshDecision.test.ts

Comment on lines +27 to +34
if (res.status !== 200) {
console.error(
'[refreshTokenRequest] non-200 response',
lastStatus,
lastBody.slice(0, 500)
)
continue
}
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.

Comment on lines +46 to +52
const failures = incrementRefreshFailureCount()
if (failures >= MAX_REFRESH_RETRIES) {
clearRefreshFailureCount()
sessionStore.set(sessionStore.readInitial())
window.location.href = '/login'
return
}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant