diff --git a/packages/core/src/pages/api/fs/refresh-token.ts b/packages/core/src/pages/api/fs/refresh-token.ts new file mode 100644 index 0000000000..f14cfaca49 --- /dev/null +++ b/packages/core/src/pages/api/fs/refresh-token.ts @@ -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 + try { + data = text ? (JSON.parse(text) as Record) : {} + } 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 diff --git a/packages/core/src/pages/api/graphql.ts b/packages/core/src/pages/api/graphql.ts index 17f0fa5cdb..1319a6b94f 100644 --- a/packages/core/src/pages/api/graphql.ts +++ b/packages/core/src/pages/api/graphql.ts @@ -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 { @@ -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.' ) diff --git a/packages/core/src/sdk/account/refreshToken.ts b/packages/core/src/sdk/account/refreshToken.ts index 8ba92c4b32..6d2fb4f1ea 100644 --- a/packages/core/src/sdk/account/refreshToken.ts +++ b/packages/core/src/sdk/account/refreshToken.ts @@ -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 | null = null + async function fetchWithRetry( url: RequestInfo | URL, - init?: RequestInit, + init: RequestInit, maxRetries = 3 ): Promise { + 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 + } + + 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 = ( diff --git a/packages/core/src/sdk/account/useRefreshToken.ts b/packages/core/src/sdk/account/useRefreshToken.ts index 2f94c9c3ed..5934a9c7d5 100644 --- a/packages/core/src/sdk/account/useRefreshToken.ts +++ b/packages/core/src/sdk/account/useRefreshToken.ts @@ -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' @@ -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) @@ -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 + } // If refresh token failed, set refreshAfter to now + 1 hour sessionStore.set({ ...currentSession, @@ -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, diff --git a/packages/core/src/sdk/session/index.ts b/packages/core/src/sdk/session/index.ts index 52942887c7..c49ef8aa9c 100644 --- a/packages/core/src/sdk/session/index.ts +++ b/packages/core/src/sdk/session/index.ts @@ -9,6 +9,11 @@ import type { } from '@generated/graphql' import deepEqual from 'fast-deep-equal' import storeConfig from '../../../discovery.config' +import { + clearRefreshFailureCount, + incrementRefreshFailureCount, + MAX_REFRESH_RETRIES, +} from 'src/utils/refreshTokenRetry' import { isRefreshTokenSuccessful, refreshTokenRequest, @@ -126,6 +131,7 @@ export const validateSession = async (session: Session) => { const result = await refreshTokenRequest() if (isRefreshTokenSuccessful(result)) { + clearRefreshFailureCount() const refreshAfter = String( Math.floor(new Date(result?.refreshAfter).getTime() / 1000) ) @@ -135,8 +141,13 @@ export const validateSession = async (session: Session) => { refreshAfter, }) } else { - // If the refresh token fails 3x, set the refreshAfter to now + 1 hour - // so that we can postpone refreshToken request and continue the ValidateSession request + const failures = incrementRefreshFailureCount() + if (failures >= MAX_REFRESH_RETRIES && typeof window !== 'undefined') { + clearRefreshFailureCount() + sessionStore.set(sessionStore.readInitial()) + window.location.href = '/login' + return null + } sessionStore.set({ ...session, refreshAfter: String(Math.floor(Date.now() / 1000) + 1 * 60 * 60), // now + 1 hour diff --git a/packages/core/src/utils/normalizeSetCookieForRequest.ts b/packages/core/src/utils/normalizeSetCookieForRequest.ts new file mode 100644 index 0000000000..003799bfc1 --- /dev/null +++ b/packages/core/src/utils/normalizeSetCookieForRequest.ts @@ -0,0 +1,88 @@ +import type { NextApiRequest } from 'next' + +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. + */ +export 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. + */ +export 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}`) +} diff --git a/packages/core/src/utils/refreshTokenRetry.ts b/packages/core/src/utils/refreshTokenRetry.ts new file mode 100644 index 0000000000..088f93e55c --- /dev/null +++ b/packages/core/src/utils/refreshTokenRetry.ts @@ -0,0 +1,23 @@ +export const REFRESH_RETRY_KEY = 'faststore_refresh_retry_count' +export const MAX_REFRESH_RETRIES = 3 + +export function clearRefreshFailureCount(): void { + try { + sessionStorage.removeItem(REFRESH_RETRY_KEY) + } catch { + // ignore + } +} + +/** + * @returns the new consecutive failure count (0 if storage is unavailable) + */ +export function incrementRefreshFailureCount(): number { + try { + const next = Number(sessionStorage.getItem(REFRESH_RETRY_KEY) || '0') + 1 + sessionStorage.setItem(REFRESH_RETRY_KEY, String(next)) + return next + } catch { + return 0 + } +} diff --git a/packages/core/src/utils/sessionValidateRefreshDecision.ts b/packages/core/src/utils/sessionValidateRefreshDecision.ts new file mode 100644 index 0000000000..ffb9de26ca --- /dev/null +++ b/packages/core/src/utils/sessionValidateRefreshDecision.ts @@ -0,0 +1,96 @@ +const MILLISECONDS_PER_SECOND = 1000 +const FIRST_REFRESH_IAT_WINDOW_SEC = 5 * 60 +/** Grace before treating a Unix `exp` as expired (mitigates client/server clock skew). */ +export const SESSION_REFRESH_EXPIRY_GRACE_SEC = 60 + +export type JwtPayload = { + exp?: number + iat?: number +} | null + +/** + * Whether a Unix timestamp (seconds) should be treated as expired for session refresh decisions. + * Uses a grace window so refresh triggers slightly before strict wall-clock expiry. + */ +export function isExpiredForSessionRefresh(exp: number): boolean { + const now = Math.floor(Date.now() / MILLISECONDS_PER_SECOND) + return now + SESSION_REFRESH_EXPIRY_GRACE_SEC > exp +} + +/** + * True only for a short window after login when `refreshAfter` is not yet in session storage. + */ +export function isJwtEligibleForFirstRefreshTokenRequest( + jwt: JwtPayload, + refreshAfterExist: boolean +): boolean { + if (!jwt || refreshAfterExist) { + return false + } + const iat = jwt.iat + if (iat == null) { + return false + } + const now = Math.floor(Date.now() / MILLISECONDS_PER_SECOND) + return now - iat < FIRST_REFRESH_IAT_WINDOW_SEC +} + +export function computeShouldRefreshToken(params: { + jwt: JwtPayload + refreshAfter: string | null | undefined +}): boolean { + const { jwt, refreshAfter } = params + const refreshAfterExist = !!refreshAfter + + const tokenExpired = Boolean( + jwt && isExpiredForSessionRefresh(Number(jwt.exp)) + ) + + const refreshAfterExpired = + refreshAfterExist && isExpiredForSessionRefresh(Number(refreshAfter)) + + const tokenExistAndIsFirstRefreshTokenRequest = + isJwtEligibleForFirstRefreshTokenRequest(jwt, refreshAfterExist) + + const tokenNotExistAndRefreshAfterExistAndIsExpired = + !jwt && !!refreshAfterExist && refreshAfterExpired + + const tokenExpiredWithJwtPresent = Boolean(jwt && tokenExpired) + + return ( + tokenExistAndIsFirstRefreshTokenRequest || + tokenNotExistAndRefreshAfterExistAndIsExpired || + tokenExpiredWithJwtPresent + ) +} + +export function describeShouldRefreshTokenReason(params: { + jwt: JwtPayload + refreshAfter: string | null | undefined +}): + | 'first_refresh_window' + | 'jwt_missing_refresh_after_expired' + | 'jwt_expired' + | null { + if (!computeShouldRefreshToken(params)) { + return null + } + const { jwt, refreshAfter } = params + const refreshAfterExist = !!refreshAfter + const tokenExpired = Boolean( + jwt && isExpiredForSessionRefresh(Number(jwt?.exp)) + ) + const refreshAfterExpired = + refreshAfterExist && isExpiredForSessionRefresh(Number(refreshAfter)) + + if (isJwtEligibleForFirstRefreshTokenRequest(jwt, refreshAfterExist)) { + return 'first_refresh_window' + } + if (!jwt && !!refreshAfterExist && refreshAfterExpired) { + return 'jwt_missing_refresh_after_expired' + } + if (jwt && tokenExpired) { + return 'jwt_expired' + } + return null +} diff --git a/packages/core/test/sdk/account/refreshToken.test.ts b/packages/core/test/sdk/account/refreshToken.test.ts new file mode 100644 index 0000000000..ec53541a11 --- /dev/null +++ b/packages/core/test/sdk/account/refreshToken.test.ts @@ -0,0 +1,95 @@ +/** + * @jest-environment jsdom + */ + +jest.mock('discovery.config', () => ({ + __esModule: true, + default: { + experimental: { refreshToken: true }, + }, +})) + +jest.mock('isomorphic-unfetch', () => ({ + __esModule: true, + default: jest.fn(), +})) + +import unfetch from 'isomorphic-unfetch' +import { + isRefreshTokenSuccessful, + refreshTokenRequest, +} from '../../../src/sdk/account/refreshToken' + +const mockedUnfetch = jest.mocked(unfetch) + +function mockFetchResult(status: number, body: string) { + return { + status, + text: async () => body, + } +} + +describe('refreshTokenRequest', () => { + beforeEach(() => { + mockedUnfetch.mockReset() + }) + + it('calls same-origin proxy with POST and JSON body', async () => { + mockedUnfetch.mockResolvedValue( + mockFetchResult( + 200, + JSON.stringify({ + status: 'Success', + refreshAfter: '2026-04-08T00:00:00Z', + }) + ) as Awaited> + ) + + const result = await refreshTokenRequest() + + expect(mockedUnfetch).toHaveBeenCalledWith( + '/api/fs/refresh-token', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({}), + }) + ) + expect(result?.status?.toLowerCase()).toBe('success') + expect(isRefreshTokenSuccessful(result)).toBe(true) + }) + + it('returns undefined after retries when proxy keeps failing', async () => { + mockedUnfetch.mockResolvedValue( + mockFetchResult(401, 'Unauthorized') as Awaited< + ReturnType + > + ) + + const result = await refreshTokenRequest() + expect(result).toBeUndefined() + expect(mockedUnfetch).toHaveBeenCalledTimes(3) + }) + + it('deduplicates concurrent calls', async () => { + let resolveFetch: (v: Awaited>) => void + const deferred = new Promise>>((r) => { + resolveFetch = r + }) + + mockedUnfetch.mockReturnValue(deferred) + + const a = refreshTokenRequest() + const b = refreshTokenRequest() + + resolveFetch!( + mockFetchResult(200, JSON.stringify({ status: 'Success' })) as Awaited< + ReturnType + > + ) + + await Promise.all([a, b]) + + expect(mockedUnfetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/core/test/utils/getCookie.test.ts b/packages/core/test/utils/getCookie.test.ts new file mode 100644 index 0000000000..32c93929e6 --- /dev/null +++ b/packages/core/test/utils/getCookie.test.ts @@ -0,0 +1,28 @@ +import { isExpired } from '../../src/utils/getCookie' + +describe('getCookie utils', () => { + describe('isExpired', () => { + const nowSec = 1_800_000_000 + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date(nowSec * 1000)) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('returns false when exp is in the future', () => { + expect(isExpired(nowSec + 1)).toBe(false) + }) + + it('returns false when exp equals now', () => { + expect(isExpired(nowSec)).toBe(false) + }) + + it('returns true when exp is strictly before now', () => { + expect(isExpired(nowSec - 1)).toBe(true) + }) + }) +}) diff --git a/packages/core/test/utils/sessionValidateRefreshDecision.test.ts b/packages/core/test/utils/sessionValidateRefreshDecision.test.ts new file mode 100644 index 0000000000..ae073b1bd0 --- /dev/null +++ b/packages/core/test/utils/sessionValidateRefreshDecision.test.ts @@ -0,0 +1,130 @@ +import { + SESSION_REFRESH_EXPIRY_GRACE_SEC, + computeShouldRefreshToken, + isExpiredForSessionRefresh, + isJwtEligibleForFirstRefreshTokenRequest, +} from '../../src/utils/sessionValidateRefreshDecision' + +describe('sessionValidateRefreshDecision', () => { + const nowSec = 1_700_000_000 + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date(nowSec * 1000)) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('isExpiredForSessionRefresh', () => { + it('returns false when exp is more than grace seconds ahead', () => { + expect( + isExpiredForSessionRefresh( + nowSec + SESSION_REFRESH_EXPIRY_GRACE_SEC + 1 + ) + ).toBe(false) + }) + + it('returns true when exp is inside the grace window (strictly before now+grace)', () => { + expect( + isExpiredForSessionRefresh( + nowSec + SESSION_REFRESH_EXPIRY_GRACE_SEC - 1 + ) + ).toBe(true) + }) + + it('returns true when exp is in the past', () => { + expect(isExpiredForSessionRefresh(nowSec - 1)).toBe(true) + }) + }) + + describe('isJwtEligibleForFirstRefreshTokenRequest', () => { + it('is false when refreshAfter exists', () => { + expect( + isJwtEligibleForFirstRefreshTokenRequest( + { iat: nowSec - 60, exp: nowSec + 3600 }, + true + ) + ).toBe(false) + }) + + it('is false when jwt is null', () => { + expect(isJwtEligibleForFirstRefreshTokenRequest(null, false)).toBe(false) + }) + + it('is false when iat is older than 5 minutes', () => { + expect( + isJwtEligibleForFirstRefreshTokenRequest( + { iat: nowSec - 6 * 60, exp: nowSec + 3600 }, + false + ) + ).toBe(false) + }) + + it('is true when iat is within 5 minutes and no refreshAfter', () => { + expect( + isJwtEligibleForFirstRefreshTokenRequest( + { iat: nowSec - 60, exp: nowSec + 3600 }, + false + ) + ).toBe(true) + }) + }) + + describe('computeShouldRefreshToken', () => { + it('is true for first-login window (jwt, no refreshAfter, fresh iat)', () => { + expect( + computeShouldRefreshToken({ + jwt: { iat: nowSec - 30, exp: nowSec + 10_000 }, + refreshAfter: null, + }) + ).toBe(true) + }) + + it('is false when jwt exists, no refreshAfter, but iat is stale', () => { + expect( + computeShouldRefreshToken({ + jwt: { iat: nowSec - 400, exp: nowSec + 10_000 }, + refreshAfter: null, + }) + ).toBe(false) + }) + + it('is true when jwt is missing, refreshAfter exists and is expired', () => { + expect( + computeShouldRefreshToken({ + jwt: null, + refreshAfter: String(nowSec - 100), + }) + ).toBe(true) + }) + + it('is false when jwt is missing, refreshAfter exists and is still valid', () => { + expect( + computeShouldRefreshToken({ + jwt: null, + refreshAfter: String(nowSec + 10_000), + }) + ).toBe(false) + }) + + it('is true when jwt exists and access token exp is expired (regardless of refreshAfter validity)', () => { + expect( + computeShouldRefreshToken({ + jwt: { iat: nowSec - 10_000, exp: nowSec - 100 }, + refreshAfter: String(nowSec + 50_000), + }) + ).toBe(true) + }) + + it('is false when jwt valid and refreshAfter valid', () => { + expect( + computeShouldRefreshToken({ + jwt: { iat: nowSec - 10_000, exp: nowSec + 10_000 }, + refreshAfter: String(nowSec + 50_000), + }) + ).toBe(false) + }) + }) +}) diff --git a/specs/session-refresh-token-24h-expiration.md b/specs/session-refresh-token-24h-expiration.md new file mode 100644 index 0000000000..9c6e4a444f --- /dev/null +++ b/specs/session-refresh-token-24h-expiration.md @@ -0,0 +1,267 @@ +# Session Expiration After 24h Despite 7-Day Refresh Token Configuration + +> **Status**: Done +> **Created**: 2026-04-07 + +## 1. Business Context + +### Problem Statement + +On stores with the `experimental.refreshToken` feature enabled (e.g. `b2bfaststoredev`), authenticated sessions expire and force re-authentication after approximately 24 hours, even when VTEX Identity is configured with a 7-day refresh token TTL. + +This affects both B2B and B2C customers who expect to remain logged in for the configured refresh token duration. Re-authentication interrupts purchase flows, account management actions, and any session-sensitive operation — particularly harmful in B2B contexts where login involves organization-level credential flows. + +### Goals + +- Authenticated sessions persist for the full duration of the refresh token TTL configured in VTEX Identity (e.g. 7 days) without requiring re-authentication. +- Token refresh happens transparently, without user-visible interruptions. +- Session expiration (when it legitimately should occur) results in a clear redirect to login, not a looping 403 page. + +### User Stories + +#### US-1: Persistent session across JWT rotations + +- **Story**: As a logged-in customer, I want my session to remain active as long as my refresh token is valid, so that I do not need to log in again every 24 hours. +- **Acceptance Criteria**: + - **Given** a user logged in to a store with `experimental.refreshToken: true` and a 7-day refresh token TTL configured in VTEX Identity, **when** 24 hours pass and the access JWT (`VtexIdclientAutCookie`) expires, **then** the session is transparently renewed via the refresh token and the user remains authenticated. + - **Given** a user navigates to `/pvt/account/*` after the JWT has expired but within the refresh token window, **when** the server-side `validateUser` detects an expired JWT, **then** the user is redirected to the 403 intermediate page, a refresh is attempted client-side, and on success they are sent back to the original page without re-authentication. + - **Given** a user with an expired JWT AND an expired refresh token, **when** any protected page is accessed, **then** the user is redirected to `/login`. + +#### US-2: Correct refreshAfter propagation + +- **Story**: As the system, I want the `refreshAfter` timestamp to reflect the true VTEX Identity refresh window, so that the client triggers refreshes at the right time. +- **Acceptance Criteria**: + - **Given** a successful call to `/api/vtexid/refreshtoken/webstore`, **when** the response contains a `refreshAfter` timestamp, **then** it is stored in the session as a Unix timestamp in seconds and correctly compared by `isExpired()` on subsequent requests. + - **Given** a failed refresh token call, **when** the system sets `refreshAfter = now + 1h` as a fallback, **then** a retry is performed after 1 hour, and if the refresh token is still valid in VTEX Identity, the retry succeeds. + +### Key Scenarios + +| Scenario | Pre-conditions | Steps | Expected Result | +|---|---|---|---| +| Happy path: JWT expires, refresh succeeds | User logged in, `refreshToken: true`, `refreshAfter` set to a future timestamp, refresh token cookie valid | 24h pass; user navigates to any page | `refreshTokenRequest()` is called; new JWT issued; `refreshAfter` updated; user stays logged in | +| Error case: refresh token cookie missing | User's refresh token cookie was never set or was cleared | `refreshTokenRequest()` is called | Returns `undefined` or non-success; session is set with `refreshAfter = now + 1h`; after 1h, retry is made | +| Error case: refresh token itself expired | 7+ days since last login | User navigates to `/pvt/account` | `validateUser` returns `{ isValid: false, needsRefresh: true }`; refresh is attempted but fails; user is redirected to login | +| Edge case: session localStorage cleared | User clears browser data mid-session | User navigates to any page | `refreshAfter = null`; `tokenExistAndIsFirstRefreshTokenRequest` fires if JWT still present; OR if JWT absent, SDK-level 401 triggers refresh | +| Edge case: first page load after login | User just logged in, `refreshAfter = null` | First `ValidateSession` call | `tokenExistAndIsFirstRefreshTokenRequest = true`; 401 thrown; client-side refresh; `refreshAfter` set from VTEX response | +| Edge case: storeUrl differs from page origin | Store served via `.vtex.app` but `storeUrl` points to `*.fast.store` | `refreshTokenRequest()` called | Cookie domain mismatch; refresh fails silently; session loses auth after 24h | + +### Functional Requirements + +1. The session MUST remain valid for the configured refresh token TTL without user-initiated re-authentication. +2. The `refreshAfter` timestamp stored in the session MUST accurately reflect when the next token refresh is needed. +3. Token refresh MUST use the correct origin so that VTEX Identity session cookies are included (same-origin or explicit CORS with credentials). +4. When refresh fails legitimately (expired refresh token), the system MUST redirect to `/login` without looping through 403. +5. The `shouldRefreshToken` logic in `graphql.ts` MUST correctly identify when a refresh is needed without generating false positives on every new session start. + +### Non-Functional Requirements + +- Refresh token calls must complete within 3 seconds (3 retries are already in place). +- Session renewal must produce no user-visible flash or interruption for the happy path. +- Failures must be logged server-side for observability. + +### Out of Scope + +- Changes to VTEX Identity configuration (refresh token TTL, access token TTL). +- OAuth/SSO flows unrelated to `VtexIdclientAutCookie`. +- B2B-specific organization session handling (separate from the auth token itself). + +--- + +## 2. Arch Decisions + +### Proposed Solution + +The investigation points to four compounding issues that collectively cause the 24-hour re-authentication: + +1. **`tokenExistAndIsFirstRefreshTokenRequest` is an unreliable heuristic.** The condition `!!jwt && !refreshAfterExist` fires on every new browser tab, incognito session, or localStorage clear — not just the very first login. This can cause premature token refresh calls with a valid but un-stored `refreshAfter`. + +2. **`refreshAfterExpired` silently succeeds the wrong branch when `refreshAfter` is derived from the JWT's `exp`.** If VTEX Identity's `/api/vtexid/refreshtoken/webstore` returns `refreshAfter` matching the JWT's `exp` (24h), then `refreshAfterExpired = true` always coincides with `tokenExpired = true`, causing `tokenExpiredAndRefreshAfterIsNullOrExpired = true` and triggering a new refresh. This cycle is correct — but only if the refresh TOKEN cookie is reliably present. + +3. **The refresh token cookie may not be included in `refreshTokenRequest()` if `storeUrl` differs from the page's origin.** The `credentials: 'include'` flag only sends cookies for requests to the same origin. If `discoveryConfig.storeUrl` resolves to a different domain than the page the user is currently on (e.g., `*.fast.store` vs `*.vtex.app`), cookies are silently excluded by the browser's same-origin policy. + +4. **Failed refreshes in the SDK's `validateSession` handler do not surface a user-visible error or redirect.** The user sees stale session data and may appear logged out with no clear action to take. + +The fix involves: +- Routing `refreshTokenRequest` through the Next.js API layer (`/api/fs/...`) to avoid cross-origin cookie issues, or adding explicit CORS handling. +- Adding server-side logging for refresh token failures. +- Reviewing `tokenExistAndIsFirstRefreshTokenRequest` to only fire on known-fresh sessions (e.g., detect absence of both JWT and `refreshAfter` together). +- Ensuring failed refresh flows eventually redirect to `/login` rather than looping on the 403 page. + +### Architecture Overview + +```mermaid +sequenceDiagram + participant Browser + participant NextJS as Next.js API (/api/graphql) + participant SDK as @faststore/core session SDK + participant VTEX as VTEX Identity + + Note over Browser,VTEX: Normal 24h cycle + + Browser->>NextJS: ValidateSession (refreshAfter in session) + NextJS->>NextJS: isExpired(refreshAfter) = true? + alt refreshAfter expired or missing + NextJS-->>Browser: 401 Unauthorized + Browser->>SDK: error.status === 401 + SDK->>VTEX: POST /api/vtexid/refreshtoken/webstore + VTEX-->>SDK: { status: 'Success', refreshAfter: '' } + SDK->>Browser: sessionStore.set({ refreshAfter }) + Browser->>NextJS: Retry ValidateSession + else refreshAfter still valid + NextJS->>VTEX: validate() via vtexid client + VTEX-->>NextJS: authStatus: 'success' + NextJS-->>Browser: Session data + end + + Note over Browser,VTEX: Bug path — cross-origin cookie miss + + SDK->>VTEX: POST storeUrl/api/vtexid/refreshtoken/webstore + Note right of SDK: Cookie NOT sent if storeUrl ≠ page origin + VTEX-->>SDK: 401 / empty response + SDK->>Browser: refreshAfter = now + 1h (fallback) + Note over Browser: User shown as logged out after 24h +``` + +### Alternatives Considered + +| Alternative | Pros | Cons | Verdict | +|---|---|---|---| +| Proxy refresh token call through `/api/fs/refresh-token` (Next.js API route) | Avoids CORS/cookie origin issues entirely; server-side call carries correct cookies | Adds a server-side hop; requires a new API route | **Accepted** — most robust fix for the origin mismatch problem | +| Redirect refresh token URL to `/api/vtexid/...` via Next.js rewrites | Zero new code, transparent to the client | Requires rewrite config per store; doesn't fix cookie scoping | Rejected — too fragile | +| Store refresh token in httpOnly cookie managed by Next.js | Fully controls cookie lifetime and domain | Major architectural change; requires VTEX Identity to support opaque tokens | Rejected — out of scope | +| Increase `refreshAfter` fallback from 1h to 7d | Simple change | Masks the real problem; refresh would never be retried on failure | Rejected | + +### Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|---|---|---|---| +| Proxy API route introduces latency | Low | Low | Benchmark; add keep-alive headers | +| VTEX Identity refresh token cookie is `httpOnly` and therefore not accessible for `credentials: 'include'` same-origin calls either | High | Medium | Verify cookie attributes in VTEX Identity; use server-side proxy if needed | +| `refreshAfterExpired` false-negative due to clock skew between client and server | Low | Low | Add a 60-second grace period before declaring expiry (`now + 60 > exp`) | +| Looping 403 page if refresh fails repeatedly | Medium | Medium | Add a max-retry counter; redirect to login after N failures | + +### Key Decisions + +#### Decision 1: Proxy `refreshTokenRequest` through a Next.js API route + +- **Status**: Accepted +- **Context**: Client-side `fetch` with `credentials: 'include'` will not include cookies if the target URL is cross-origin. For stores where `storeUrl` does not match the serving domain (e.g., `*.vtex.app` preview environments), this silently excludes the VTEX refresh token cookie, causing the refresh to fail. +- **Decision**: Add `/api/fs/refresh-token` as a Next.js API route that forwards the refresh token request server-side (where cookies are carried in the `Cookie` header from the incoming request). The existing `refreshTokenRequest` client function is updated to call this proxy. +- **Consequences**: The refresh token cookie must be forwarded correctly by the proxy. The Next.js route must set `credentials`/`cookie` forwarding from `context.req.headers`. + +#### Decision 2: Add a max-retry / redirect-to-login safeguard on the 403 page + +- **Status**: Accepted +- **Context**: When the refresh token is genuinely expired, the current code sets `refreshAfter = now + 1h` and shows a 403 error page. On the next navigation or retry, the same cycle repeats. There is no path to `/login` from a failed refresh. +- **Decision**: After N consecutive failed refreshes (tracked via `sessionStorage`), clear the session and redirect to `/login`. +- **Consequences**: Requires a retry counter in `sessionStorage`; must be cleared on successful refresh. + +#### Decision 3: Narrow `tokenExistAndIsFirstRefreshTokenRequest` to genuine first-login scenarios + +- **Status**: Accepted +- **Context**: The condition `!!jwt && !refreshAfterExist` fires any time `refreshAfter` is absent from the session, including after localStorage is cleared or in a new tab. This creates unnecessary refresh calls and can cause a 401 loop. +- **Decision**: Rename/refine the condition to only trigger when there is no `refreshAfter` AND the JWT is freshly issued (e.g., `jwt.iat` is within the last 5 minutes). Alternatively, fall through to the normal VTEX Identity validate path and let the 401 from VTEX trigger the SDK-level refresh. +- **Consequences**: Reduces false-positive refreshes; requires parsing `jwt.iat` which is already available via `parseJwt`. + +### Implementation Plan + +**Phase 1 — Observability (no behavior change)** +1. Add server-side logging in the `if (shouldRefreshToken)` branch of `graphql.ts` and in `refreshTokenRequest` to capture why refresh calls fail (HTTP status, response body). +2. Deploy to `b2bfaststoredev` and reproduce the 24h scenario to capture logs. + +**Phase 2 — Core fix** +3. Create `/api/fs/refresh-token` Next.js API route that proxies the call to VTEX Identity server-side. +4. Update `refreshTokenRequest` to call this proxy instead of the VTEX URL directly. +5. Narrow `tokenExistAndIsFirstRefreshTokenRequest` to avoid false-positive triggers. + +**Phase 3 — Safeguard** +6. Add `sessionStorage`-based retry counter in `useRefreshToken`. +7. After 3 consecutive failed refreshes, clear session and redirect to `/login`. + +**Phase 4 — Validation** +8. Write unit tests for `isExpired`, `refreshAfterExpired`, and `shouldRefreshToken` conditions. +9. Write integration tests: simulate JWT expiry and assert that `refreshTokenRequest` is called and `refreshAfter` updated. +10. Manually validate on `b2bfaststoredev` that session persists beyond 24h. + +--- + +## 3. Technical Contract + +### Data Models + +**`RefreshTokenResponse`** (packages/core/src/sdk/account/refreshToken.ts) + +```ts +export interface RefreshTokenResponse { + status?: string // 'Success' | 'Expired' | error string + refreshAfter?: string // ISO 8601 date string — e.g. '2026-04-14T09:00:00Z' +} +``` + +**Session `refreshAfter` field** (packages/sdk/src/session/index.ts) + +```ts +refreshAfter: string | null // Unix timestamp in seconds (stored as string); null = no refresh ever performed +``` + +**`shouldRefreshToken` decision matrix** (packages/core/src/pages/api/graphql.ts) + +| jwt exists | refreshAfter exists | refreshAfterExpired | tokenExpired | shouldRefreshToken | +|---|---|---|---|---| +| true | false | — | — | **true** (first-login, narrow to jwt.iat < 5 min) | +| false | true | true | — | **true** (jwt gone, window expired) | +| true | any | any | true | **true** (jwt expired, window closed) | +| false | true | false | — | false (jwt renewing; let VTEX 401 propagate) | +| true | true | false | false | false (all valid) | + +### Interfaces + +**New API route: `POST /api/fs/refresh-token`** + +```ts +// Request: no body required (cookies forwarded from incoming request) +// Response: +interface RefreshTokenApiResponse { + status?: string + refreshAfter?: string +} +// Error: 401 | 500 +``` + +**Updated `refreshTokenRequest`** + +```ts +// Before: calls discoveryConfig.storeUrl/api/vtexid/refreshtoken/webstore directly +// After: calls /api/fs/refresh-token (Next.js proxy) +export const refreshTokenRequest = async (): Promise +``` + +**`useRefreshToken` hook additions** + +```ts +// New: retry counter tracked in sessionStorage +const REFRESH_RETRY_KEY = 'faststore_refresh_retry_count' +const MAX_REFRESH_RETRIES = 3 + +// Behavior: +// On success → clear retry counter +// On failure → increment counter; if count >= MAX_REFRESH_RETRIES → redirect to login +``` + +### Integration Points + +| System | Direction | Purpose | +|---|---|---| +| VTEX Identity `/api/vtexid/refreshtoken/webstore` | Outbound (from Next.js server) | Obtain new JWT using refresh token cookie | +| VTEX Identity `/vtexid/validate()` | Outbound (from Next.js server) | Validate existing JWT on each request | +| `fs::session` (localStorage) | Read/Write (browser) | Persist `refreshAfter` across page navigations | +| `faststore_refresh_retry_count` (sessionStorage) | Read/Write (browser) | Track consecutive refresh failures to trigger login redirect | +| `/pvt/account/403` | Internal redirect | Intermediate page for client-side refresh token flow | + +### Invariants & Constraints + +1. `refreshAfter` stored in session is always a Unix timestamp in seconds (not milliseconds, not ISO string). +2. A refresh token call MUST only be attempted when `experimental.refreshToken: true` is set in `discovery.config`. +3. The proxy API route MUST forward the incoming request cookies verbatim to VTEX Identity; it MUST NOT add or modify authentication headers. +4. After a successful refresh, `refreshAfter` in the session MUST be updated before any redirect or navigation. +5. The number of in-flight `refreshTokenRequest` calls at any moment MUST be at most 1 (no concurrent refresh storms).