@faststore/core 4.10.1-dev.3 → 4.10.1-dev.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +272 -233
- package/CHANGELOG.md +11 -0
- package/package.json +6 -6
- package/src/pages/api/graphql.ts +97 -57
- package/src/pages/pvt/account/403.tsx +5 -4
- package/src/sdk/account/useRefreshToken.ts +5 -6
- package/src/sdk/session/index.ts +42 -30
- package/src/utils/isLocalHost.ts +2 -16
- package/src/utils/shouldBypassRefreshToken.ts +63 -0
- package/test/pages/api/graphql.test.ts +175 -0
- package/test/pages/pvt/account/403.test.ts +67 -0
- package/test/sdk/account/useRefreshToken.browser.test.ts +69 -0
- package/test/sdk/session/validateSession.browser.test.ts +108 -5
- package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
- package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
- package/test/utils/isLocalHost.browser.test.ts +0 -37
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [4.10.1-dev.5](https://github.com/vtex/faststore/compare/v4.10.1-dev.4...v4.10.1-dev.5) (2026-10-28)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **core:** bypass refresh-token flow on WebOps preview hosts ([0c8db36](https://github.com/vtex/faststore/commit/0c8db363b2f62ff054e3f3524cd15951b3aafe91))
|
|
11
|
+
- **core:** detect preview hosts from x-forwarded-host on the server ([ec5d8a7](https://github.com/vtex/faststore/commit/ec5d8a7c70eca06c90153fe7cb5b58aa192bfb46))
|
|
12
|
+
|
|
13
|
+
## [4.10.1-dev.4](https://github.com/vtex/faststore/compare/v4.10.1-dev.3...v4.10.1-dev.4) (2026-10-28)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @faststore/core
|
|
16
|
+
|
|
6
17
|
## [4.10.1-dev.3](https://github.com/vtex/faststore/compare/v4.10.1-dev.2...v4.10.1-dev.3) (2026-10-27)
|
|
7
18
|
|
|
8
19
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faststore/core",
|
|
3
|
-
"version": "4.10.1-dev.
|
|
3
|
+
"version": "4.10.1-dev.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -71,11 +71,11 @@
|
|
|
71
71
|
"style-loader": "^3.3.1",
|
|
72
72
|
"swr": "^2.2.5",
|
|
73
73
|
"use-sync-external-store": "^1.6.0",
|
|
74
|
-
"@faststore/
|
|
75
|
-
"@faststore/
|
|
76
|
-
"@faststore/
|
|
77
|
-
"@faststore/ui": "4.10.1-dev.
|
|
78
|
-
"@faststore/
|
|
74
|
+
"@faststore/api": "4.10.1-dev.5",
|
|
75
|
+
"@faststore/diagnostics": "4.10.1-dev.5",
|
|
76
|
+
"@faststore/lighthouse": "4.10.1-dev.5",
|
|
77
|
+
"@faststore/ui": "4.10.1-dev.5",
|
|
78
|
+
"@faststore/sdk": "4.10.1-dev.5"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@cypress/code-coverage": "^3.12.1",
|
package/src/pages/api/graphql.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BadRequestError,
|
|
3
|
+
type CacheControl,
|
|
3
4
|
UnauthorizedError,
|
|
4
5
|
isFastStoreError,
|
|
5
6
|
stringifyCacheControl,
|
|
@@ -10,7 +11,7 @@ import type { NextApiHandler, NextApiRequest } from 'next'
|
|
|
10
11
|
import discoveryConfig from 'discovery.config'
|
|
11
12
|
import { getJWTAutCookie } from 'src/utils/getCookie'
|
|
12
13
|
import { getRequestHostname } from 'src/utils/getRequestHostname'
|
|
13
|
-
import {
|
|
14
|
+
import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
|
|
14
15
|
import {
|
|
15
16
|
isHostAllowed,
|
|
16
17
|
removeCookieDomain,
|
|
@@ -260,6 +261,87 @@ const respondToUnexpectedError = (
|
|
|
260
261
|
response.status(500).end()
|
|
261
262
|
}
|
|
262
263
|
|
|
264
|
+
/**
|
|
265
|
+
* With `experimental.refreshToken` on, rejects a ValidateSession with 401 when
|
|
266
|
+
* the token needs a refresh, so the client runs the refresh-token flow first.
|
|
267
|
+
*
|
|
268
|
+
* Skipped on localhost and WebOps previews: neither proxies VTEX ID, so the
|
|
269
|
+
* refresh would fail and log out a manually injected auth cookie.
|
|
270
|
+
*/
|
|
271
|
+
const assertValidateSessionTokenIsFresh = ({
|
|
272
|
+
request,
|
|
273
|
+
operationName,
|
|
274
|
+
sessionRefreshAfter,
|
|
275
|
+
}: {
|
|
276
|
+
request: NextApiRequest
|
|
277
|
+
operationName: string | undefined
|
|
278
|
+
sessionRefreshAfter: string | null | undefined
|
|
279
|
+
}) => {
|
|
280
|
+
if (
|
|
281
|
+
operationName !== 'ValidateSession' ||
|
|
282
|
+
!discoveryConfig.experimental?.refreshToken ||
|
|
283
|
+
shouldBypassRefreshTokenForRequest(request.headers)
|
|
284
|
+
) {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const jwt = getJWTAutCookie({
|
|
289
|
+
headers: request.headers,
|
|
290
|
+
account: discoveryConfig.api.storeId,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
if (shouldForceRefreshTokenForValidateSession({ jwt, sessionRefreshAfter })) {
|
|
294
|
+
throw new UnauthorizedError(
|
|
295
|
+
'Unauthorized: Token expired. Please login again or refresh the page.'
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Cache-Control for a successful response: the directives collected while
|
|
302
|
+
* executing the operation when there are any, otherwise a default
|
|
303
|
+
* `s-maxage`/`stale-while-revalidate` for GET queries and `no-store` for
|
|
304
|
+
* everything else.
|
|
305
|
+
*/
|
|
306
|
+
const getSuccessCacheControl = ({
|
|
307
|
+
request,
|
|
308
|
+
operationName,
|
|
309
|
+
cacheControl,
|
|
310
|
+
hasAuthCookie,
|
|
311
|
+
}: {
|
|
312
|
+
request: NextApiRequest
|
|
313
|
+
operationName: string | undefined
|
|
314
|
+
cacheControl: CacheControl | undefined
|
|
315
|
+
hasAuthCookie: boolean
|
|
316
|
+
}) => {
|
|
317
|
+
if (cacheControl) {
|
|
318
|
+
return stringifyCacheControl(cacheControl, hasAuthCookie)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (
|
|
322
|
+
request.method !== 'GET' ||
|
|
323
|
+
!operationName?.toLowerCase()?.endsWith('query')
|
|
324
|
+
) {
|
|
325
|
+
return 'no-cache, no-store'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const graphqlCacheControl = discoveryConfig?.experimental?.graphqlCacheControl
|
|
329
|
+
|
|
330
|
+
const maxAge =
|
|
331
|
+
graphqlCacheControl?.maxAge && graphqlCacheControl.maxAge > 0
|
|
332
|
+
? graphqlCacheControl.maxAge
|
|
333
|
+
: DEFAULT_MAX_AGE // 5 minutes
|
|
334
|
+
|
|
335
|
+
const staleWhileRevalidate =
|
|
336
|
+
graphqlCacheControl?.staleWhileRevalidate &&
|
|
337
|
+
graphqlCacheControl.staleWhileRevalidate > 0
|
|
338
|
+
? graphqlCacheControl.staleWhileRevalidate
|
|
339
|
+
: DEFAULT_STALE_WHILE_REVALIDATE // 1 hour
|
|
340
|
+
|
|
341
|
+
const scope = hasAuthCookie ? 'private' : 'public'
|
|
342
|
+
return `${scope}, s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`
|
|
343
|
+
}
|
|
344
|
+
|
|
263
345
|
const handler: NextApiHandler = async (request, response) => {
|
|
264
346
|
if (request.method !== 'POST' && request.method !== 'GET') {
|
|
265
347
|
response.status(405).end()
|
|
@@ -286,29 +368,11 @@ const handler: NextApiHandler = async (request, response) => {
|
|
|
286
368
|
operation?.__meta__?.operationHash
|
|
287
369
|
)
|
|
288
370
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
discoveryConfig.experimental?.refreshToken
|
|
295
|
-
) {
|
|
296
|
-
const jwt = getJWTAutCookie({
|
|
297
|
-
headers: request.headers,
|
|
298
|
-
account: discoveryConfig.api.storeId,
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
const shouldRefreshToken = shouldForceRefreshTokenForValidateSession({
|
|
302
|
-
jwt,
|
|
303
|
-
sessionRefreshAfter: variables?.session?.refreshAfter,
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
if (shouldRefreshToken) {
|
|
307
|
-
throw new UnauthorizedError(
|
|
308
|
-
'Unauthorized: Token expired. Please login again or refresh the page.'
|
|
309
|
-
)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
371
|
+
assertValidateSessionTokenIsFresh({
|
|
372
|
+
request,
|
|
373
|
+
operationName: operation.__meta__.operationName,
|
|
374
|
+
sessionRefreshAfter: variables?.session?.refreshAfter,
|
|
375
|
+
})
|
|
312
376
|
|
|
313
377
|
// Prevents to call ValidateSession or ValidateCartMutation without session (required) and get GraphQLError
|
|
314
378
|
const doNotRun =
|
|
@@ -338,39 +402,15 @@ const handler: NextApiHandler = async (request, response) => {
|
|
|
338
402
|
|
|
339
403
|
const hasAuthCookie = hasVtexIdclientAutCookie(request)
|
|
340
404
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
) {
|
|
351
|
-
const maxAge =
|
|
352
|
-
discoveryConfig?.experimental?.graphqlCacheControl?.maxAge &&
|
|
353
|
-
discoveryConfig?.experimental?.graphqlCacheControl?.maxAge > 0
|
|
354
|
-
? discoveryConfig.experimental.graphqlCacheControl.maxAge
|
|
355
|
-
: DEFAULT_MAX_AGE // 5 minutes
|
|
356
|
-
|
|
357
|
-
const staleWhileRevalidate =
|
|
358
|
-
discoveryConfig?.experimental?.graphqlCacheControl
|
|
359
|
-
?.staleWhileRevalidate &&
|
|
360
|
-
discoveryConfig?.experimental?.graphqlCacheControl
|
|
361
|
-
?.staleWhileRevalidate > 0
|
|
362
|
-
? discoveryConfig.experimental.graphqlCacheControl
|
|
363
|
-
.staleWhileRevalidate
|
|
364
|
-
: DEFAULT_STALE_WHILE_REVALIDATE // 1 hour
|
|
365
|
-
|
|
366
|
-
const scope = hasAuthCookie ? 'private' : 'public'
|
|
367
|
-
response.setHeader(
|
|
368
|
-
'cache-control',
|
|
369
|
-
`${scope}, s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`
|
|
370
|
-
)
|
|
371
|
-
} else {
|
|
372
|
-
response.setHeader('cache-control', 'no-cache, no-store')
|
|
373
|
-
}
|
|
405
|
+
response.setHeader(
|
|
406
|
+
'cache-control',
|
|
407
|
+
getSuccessCacheControl({
|
|
408
|
+
request,
|
|
409
|
+
operationName: operation.__meta__.operationName,
|
|
410
|
+
cacheControl: extensions.cacheControl,
|
|
411
|
+
hasAuthCookie,
|
|
412
|
+
})
|
|
413
|
+
)
|
|
374
414
|
|
|
375
415
|
const setCookieValues = Array.from(extensions.cookies.values())
|
|
376
416
|
if (setCookieValues.length > 0 && !hasErrors) {
|
|
@@ -24,8 +24,7 @@ import PageProvider from 'src/sdk/overrides/PageProvider'
|
|
|
24
24
|
import { execute } from 'src/server'
|
|
25
25
|
import { injectGlobalSections } from 'src/server/cms/global'
|
|
26
26
|
import { fetchMyAccountPageContent } from 'src/server/cms/fetchMyAccountPageContent'
|
|
27
|
-
import {
|
|
28
|
-
import { isLocalHost } from 'src/utils/isLocalHost'
|
|
27
|
+
import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
|
|
29
28
|
import { withLocaleValidationSSR } from 'src/utils/localization/withLocaleValidation'
|
|
30
29
|
import { getMyAccountRedirect } from 'src/utils/myAccountRedirect'
|
|
31
30
|
|
|
@@ -137,14 +136,16 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
137
136
|
|
|
138
137
|
const statusCode: number = (account.errors[0] as any)?.extensions?.status
|
|
139
138
|
|
|
140
|
-
const
|
|
139
|
+
const bypassRefreshToken = shouldBypassRefreshTokenForRequest(
|
|
140
|
+
context.req.headers
|
|
141
|
+
)
|
|
141
142
|
|
|
142
143
|
return {
|
|
143
144
|
props: {
|
|
144
145
|
globalSections: globalSectionsResult,
|
|
145
146
|
pageSections: pageContent.sections,
|
|
146
147
|
needsRefreshToken:
|
|
147
|
-
!
|
|
148
|
+
!bypassRefreshToken &&
|
|
148
149
|
(statusCode === 401 || statusCode === 403) &&
|
|
149
150
|
storeConfig.experimental?.refreshToken,
|
|
150
151
|
fromPage,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
|
|
3
3
|
import { logoutAndClearSession, sessionStore } from '../session'
|
|
4
4
|
import { isRefreshTokenSuccessful, refreshTokenRequest } from './refreshToken'
|
|
5
5
|
|
|
@@ -13,13 +13,12 @@ export const useRefreshToken = (
|
|
|
13
13
|
const handleRefreshTokenAndUpdateSession = async () => {
|
|
14
14
|
if (!needsRefreshToken) return
|
|
15
15
|
|
|
16
|
-
// The refresh-token endpoint
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// which would also wipe the manually injected `VtexIdclientAutCookie_<account>`
|
|
16
|
+
// The refresh-token endpoint relies on VTEX ID, which neither localhost
|
|
17
|
+
// nor WebOps previews proxy, so the refresh always fails there — which
|
|
18
|
+
// would also wipe the manually injected `VtexIdclientAutCookie_<account>`
|
|
20
19
|
// via `logoutAndClearSession`. Bail out and fall back to the static 403
|
|
21
20
|
// view so developers can keep testing logged-in flows.
|
|
22
|
-
if (
|
|
21
|
+
if (shouldBypassRefreshTokenBrowser()) {
|
|
23
22
|
setShouldShow403(true)
|
|
24
23
|
return
|
|
25
24
|
}
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
ValidateSessionMutationVariables,
|
|
9
9
|
} from '@generated/graphql'
|
|
10
10
|
import deepEqual from 'fast-deep-equal'
|
|
11
|
-
import {
|
|
11
|
+
import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
|
|
12
12
|
import { filterChannel } from 'src/utils/utilities'
|
|
13
13
|
import storeConfig from '../../../discovery.config'
|
|
14
14
|
import {
|
|
@@ -109,6 +109,43 @@ async function handleRefreshToken(session: Session): Promise<Session | null> {
|
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The refresh-token flow runs only when `experimental.refreshToken` is on and
|
|
114
|
+
* the host can reach VTEX ID: localhost and WebOps previews do not proxy it.
|
|
115
|
+
*/
|
|
116
|
+
function canRefreshToken(): boolean {
|
|
117
|
+
return (
|
|
118
|
+
!!storeConfig.experimental?.refreshToken &&
|
|
119
|
+
!shouldBypassRefreshTokenBrowser()
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* With deliveryPromise on and no postalCode in the session, fills it from the
|
|
125
|
+
* B2B saved address or from the shopper location.
|
|
126
|
+
*/
|
|
127
|
+
async function syncDeliveryPromisePostalCode(session: Session) {
|
|
128
|
+
if (
|
|
129
|
+
!storeConfig.deliveryPromise?.enabled ||
|
|
130
|
+
(session.postalCode &&
|
|
131
|
+
session.postalCode !== storeConfig.session.postalCode)
|
|
132
|
+
) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
|
|
137
|
+
if (session.b2b?.savedPostalCode) {
|
|
138
|
+
sessionStore.set({
|
|
139
|
+
...session,
|
|
140
|
+
postalCode: session.b2b.savedPostalCode,
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sessionWithLocation = await getPostalCode(session)
|
|
146
|
+
!!sessionWithLocation && sessionStore.set(sessionWithLocation)
|
|
147
|
+
}
|
|
148
|
+
|
|
112
149
|
function isRefreshAfterExpired(session: Session): boolean {
|
|
113
150
|
return (
|
|
114
151
|
!!session.refreshAfter &&
|
|
@@ -126,12 +163,8 @@ export const validateSession = async (session: Session) => {
|
|
|
126
163
|
// If the refreshToken is enabled and the refreshAfter is expired, refresh the token.
|
|
127
164
|
// On success, continue to the validation flow with the refreshed session.
|
|
128
165
|
// On failure (logoutAndClearSession already triggered), bail out.
|
|
129
|
-
// Skipped
|
|
130
|
-
if (
|
|
131
|
-
!isLocalHostBrowser() &&
|
|
132
|
-
storeConfig.experimental?.refreshToken &&
|
|
133
|
-
isRefreshAfterExpired(session)
|
|
134
|
-
) {
|
|
166
|
+
// Skipped on localhost and WebOps previews, where the refresh token infrastructure is unavailable.
|
|
167
|
+
if (canRefreshToken() && isRefreshAfterExpired(session)) {
|
|
135
168
|
const refreshed = await handleRefreshToken(session)
|
|
136
169
|
if (!refreshed) {
|
|
137
170
|
return null
|
|
@@ -139,23 +172,7 @@ export const validateSession = async (session: Session) => {
|
|
|
139
172
|
session = refreshed
|
|
140
173
|
}
|
|
141
174
|
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
storeConfig.deliveryPromise?.enabled &&
|
|
145
|
-
(!session.postalCode ||
|
|
146
|
-
session.postalCode === storeConfig.session.postalCode)
|
|
147
|
-
) {
|
|
148
|
-
// Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
|
|
149
|
-
if (session.b2b && session.b2b?.savedPostalCode) {
|
|
150
|
-
sessionStore.set({
|
|
151
|
-
...session,
|
|
152
|
-
postalCode: session.b2b.savedPostalCode,
|
|
153
|
-
})
|
|
154
|
-
} else {
|
|
155
|
-
const sessionWithLocation = await getPostalCode(session)
|
|
156
|
-
!!sessionWithLocation && sessionStore.set(sessionWithLocation)
|
|
157
|
-
}
|
|
158
|
-
}
|
|
175
|
+
await syncDeliveryPromisePostalCode(session)
|
|
159
176
|
|
|
160
177
|
try {
|
|
161
178
|
// Prevents to call ValidateSession without session (required) and get Error
|
|
@@ -173,12 +190,7 @@ export const validateSession = async (session: Session) => {
|
|
|
173
190
|
|
|
174
191
|
return data.validateSession
|
|
175
192
|
} catch (error) {
|
|
176
|
-
|
|
177
|
-
!isLocalHostBrowser() &&
|
|
178
|
-
error?.status === 401 &&
|
|
179
|
-
storeConfig.experimental?.refreshToken
|
|
180
|
-
|
|
181
|
-
if (shouldRefreshToken) {
|
|
193
|
+
if (error?.status === 401 && canRefreshToken()) {
|
|
182
194
|
const refreshed = await handleRefreshToken(session)
|
|
183
195
|
if (refreshed) {
|
|
184
196
|
sessionStore.set(refreshed)
|
package/src/utils/isLocalHost.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hostnames that represent a local development environment.
|
|
3
|
-
*
|
|
4
|
-
* These are the only hosts where the FastStore app skips the refresh-token
|
|
5
|
-
* flow so developers can drive the app with a manually injected
|
|
6
|
-
* `VtexIdclientAutCookie_<account>` cookie regardless of feature flags.
|
|
7
3
|
*/
|
|
8
4
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
|
|
9
5
|
|
|
@@ -11,9 +7,8 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
|
|
|
11
7
|
* Returns `true` when the given hostname corresponds to a local development
|
|
12
8
|
* environment (`localhost` / `127.0.0.1`).
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* injected cookies, independent of any experimental flag.
|
|
10
|
+
* To decide whether to skip the refresh-token flow, use
|
|
11
|
+
* `shouldBypassRefreshToken`, which also covers WebOps preview hosts.
|
|
17
12
|
*
|
|
18
13
|
* The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
|
|
19
14
|
* on server-side request headers before calling this helper.
|
|
@@ -22,12 +17,3 @@ export function isLocalHost(hostname: string | null | undefined): boolean {
|
|
|
22
17
|
if (!hostname) return false
|
|
23
18
|
return LOCAL_HOSTNAMES.has(hostname.toLowerCase())
|
|
24
19
|
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Browser-side wrapper that reads `window.location.hostname`. Returns `false`
|
|
28
|
-
* when `window` is not available (SSR/Node).
|
|
29
|
-
*/
|
|
30
|
-
export function isLocalHostBrowser(): boolean {
|
|
31
|
-
if (typeof window === 'undefined') return false
|
|
32
|
-
return isLocalHost(window.location.hostname)
|
|
33
|
-
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getRequestHostname } from './getRequestHostname'
|
|
2
|
+
import { isLocalHost } from './isLocalHost'
|
|
3
|
+
import { isHostAllowed, singleForwardedHost } from './trustedForwardedHost'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* WebOps preview deployments are served under `<preview>.preview.vtex.app`.
|
|
7
|
+
*
|
|
8
|
+
* Only this subdomain is matched: `{account}.vtex.app` is a production default
|
|
9
|
+
* domain and MUST keep the refresh-token flow.
|
|
10
|
+
*/
|
|
11
|
+
const PREVIEW_HOST_SUFFIXES = ['.preview.vtex.app']
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns `true` when the refresh-token flow must be skipped for the given
|
|
15
|
+
* hostname: local development (`localhost` / `127.0.0.1`) and WebOps previews.
|
|
16
|
+
*
|
|
17
|
+
* Neither environment proxies VTEX ID, so `/api/vtexid/refreshtoken/webstore`
|
|
18
|
+
* always fails there and the resulting logout wipes a manually injected
|
|
19
|
+
* `VtexIdclientAutCookie_<account>` cookie. Skipping the flow lets developers
|
|
20
|
+
* test logged-in scenarios with that cookie alone.
|
|
21
|
+
*
|
|
22
|
+
* The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
|
|
23
|
+
* on server-side request headers before calling this helper. A trailing dot
|
|
24
|
+
* (fully qualified name) is ignored.
|
|
25
|
+
*/
|
|
26
|
+
export function shouldBypassRefreshToken(
|
|
27
|
+
hostname: string | null | undefined
|
|
28
|
+
): boolean {
|
|
29
|
+
if (!hostname) return false
|
|
30
|
+
const normalizedHostname = hostname.toLowerCase().replace(/\.$/, '')
|
|
31
|
+
return (
|
|
32
|
+
isLocalHost(normalizedHostname) ||
|
|
33
|
+
isHostAllowed(normalizedHostname, PREVIEW_HOST_SUFFIXES)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Server-side check from the request headers. Behind the WebOps preview
|
|
39
|
+
* ingress the browser host arrives in `x-forwarded-host` rather than `Host`
|
|
40
|
+
* (see `normalizeSetCookieDomain` in `pages/api/graphql.ts`), so both are
|
|
41
|
+
* checked. Both are client-controlled: a forged value only skips the forced
|
|
42
|
+
* refresh, which grants nothing since the JWT is still validated upstream.
|
|
43
|
+
*/
|
|
44
|
+
export function shouldBypassRefreshTokenForRequest(headers: {
|
|
45
|
+
host?: string
|
|
46
|
+
'x-forwarded-host'?: string | string[]
|
|
47
|
+
}): boolean {
|
|
48
|
+
return (
|
|
49
|
+
shouldBypassRefreshToken(getRequestHostname(headers.host)) ||
|
|
50
|
+
shouldBypassRefreshToken(
|
|
51
|
+
getRequestHostname(singleForwardedHost(headers['x-forwarded-host']))
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Browser-side wrapper that reads `window.location.hostname`. Returns `false`
|
|
58
|
+
* when `window` is not available (SSR/Node).
|
|
59
|
+
*/
|
|
60
|
+
export function shouldBypassRefreshTokenBrowser(): boolean {
|
|
61
|
+
if (globalThis.window === undefined) return false
|
|
62
|
+
return shouldBypassRefreshToken(globalThis.window.location.hostname)
|
|
63
|
+
}
|
|
@@ -9,14 +9,23 @@ import { GraphQLError } from 'graphql'
|
|
|
9
9
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
11
11
|
|
|
12
|
+
import discoveryConfig from '../../../discovery.config'
|
|
12
13
|
import handler from '../../../src/pages/api/graphql'
|
|
13
14
|
import { execute } from '../../../src/server'
|
|
15
|
+
import { shouldForceRefreshTokenForValidateSession } from '../../../src/utils/validateSessionRefreshToken'
|
|
14
16
|
|
|
15
17
|
vi.mock('../../../src/server', () => ({
|
|
16
18
|
execute: vi.fn(),
|
|
17
19
|
}))
|
|
18
20
|
|
|
21
|
+
vi.mock('../../../src/utils/validateSessionRefreshToken', () => ({
|
|
22
|
+
shouldForceRefreshTokenForValidateSession: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
19
25
|
const mockedExecute = vi.mocked(execute)
|
|
26
|
+
const mockedShouldForceRefreshToken = vi.mocked(
|
|
27
|
+
shouldForceRefreshTokenForValidateSession
|
|
28
|
+
)
|
|
20
29
|
|
|
21
30
|
const createResponse = ({ headersSent = false } = {}) => {
|
|
22
31
|
const res = {
|
|
@@ -229,3 +238,169 @@ describe('/api/graphql request handling', () => {
|
|
|
229
238
|
expect(res.end).toHaveBeenCalled()
|
|
230
239
|
})
|
|
231
240
|
})
|
|
241
|
+
|
|
242
|
+
describe('/api/graphql ValidateSession refresh-token bypass', () => {
|
|
243
|
+
const originalRefreshToken = discoveryConfig.experimental.refreshToken
|
|
244
|
+
|
|
245
|
+
const createValidateSessionRequest = (
|
|
246
|
+
host: string,
|
|
247
|
+
extraHeaders: Record<string, string> = {}
|
|
248
|
+
) =>
|
|
249
|
+
({
|
|
250
|
+
method: 'GET',
|
|
251
|
+
url: '/api/graphql',
|
|
252
|
+
headers: { host, ...extraHeaders },
|
|
253
|
+
query: {
|
|
254
|
+
operationName: 'ValidateSession',
|
|
255
|
+
operationHash: 'hash',
|
|
256
|
+
variables: JSON.stringify({ session: {} }),
|
|
257
|
+
},
|
|
258
|
+
}) as unknown as NextApiRequest
|
|
259
|
+
|
|
260
|
+
beforeEach(() => {
|
|
261
|
+
vi.clearAllMocks()
|
|
262
|
+
discoveryConfig.experimental.refreshToken = true
|
|
263
|
+
mockedShouldForceRefreshToken.mockReturnValue(true)
|
|
264
|
+
mockedExecute.mockResolvedValue({
|
|
265
|
+
data: { validateSession: null },
|
|
266
|
+
errors: undefined,
|
|
267
|
+
extensions: { cookies: new Map(), cacheControl: undefined },
|
|
268
|
+
} as never)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
afterEach(() => {
|
|
272
|
+
discoveryConfig.experimental.refreshToken = originalRefreshToken
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it.each([
|
|
276
|
+
'localhost:3000',
|
|
277
|
+
'127.0.0.1:3000',
|
|
278
|
+
'brandless-abc123.preview.vtex.app',
|
|
279
|
+
])('does not force a 401 on %s', async (host) => {
|
|
280
|
+
const res = createResponse()
|
|
281
|
+
await handler(createValidateSessionRequest(host), res)
|
|
282
|
+
|
|
283
|
+
expect(mockedShouldForceRefreshToken).not.toHaveBeenCalled()
|
|
284
|
+
expect(res.status).not.toHaveBeenCalledWith(401)
|
|
285
|
+
expect(mockedExecute).toHaveBeenCalled()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('does not force a 401 on a preview behind the ingress', async () => {
|
|
289
|
+
const res = createResponse()
|
|
290
|
+
await handler(
|
|
291
|
+
createValidateSessionRequest('faststore-internal:3000', {
|
|
292
|
+
'x-forwarded-host': 'brandless-abc123.preview.vtex.app',
|
|
293
|
+
}),
|
|
294
|
+
res
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
expect(mockedShouldForceRefreshToken).not.toHaveBeenCalled()
|
|
298
|
+
expect(res.status).not.toHaveBeenCalledWith(401)
|
|
299
|
+
expect(mockedExecute).toHaveBeenCalled()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it.each(['brandless.vtex.app', 'brandless.fast.store'])(
|
|
303
|
+
'forces a 401 on the production host %s',
|
|
304
|
+
async (host) => {
|
|
305
|
+
const res = createResponse()
|
|
306
|
+
await handler(createValidateSessionRequest(host), res)
|
|
307
|
+
|
|
308
|
+
expect(res.status).toHaveBeenCalledWith(401)
|
|
309
|
+
expect(mockedExecute).not.toHaveBeenCalled()
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('/api/graphql success cache-control', () => {
|
|
315
|
+
const originalGraphqlCacheControl =
|
|
316
|
+
discoveryConfig.experimental.graphqlCacheControl
|
|
317
|
+
|
|
318
|
+
const mockExecuteWithCacheControl = (cacheControl?: unknown) => {
|
|
319
|
+
mockedExecute.mockResolvedValue({
|
|
320
|
+
data: {},
|
|
321
|
+
errors: undefined,
|
|
322
|
+
extensions: { cookies: new Map(), cacheControl },
|
|
323
|
+
} as never)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
beforeEach(() => {
|
|
327
|
+
vi.clearAllMocks()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
afterEach(() => {
|
|
331
|
+
discoveryConfig.experimental.graphqlCacheControl =
|
|
332
|
+
originalGraphqlCacheControl
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('uses the cache-control collected while executing the operation', async () => {
|
|
336
|
+
mockExecuteWithCacheControl({ scope: 'public', sMaxAge: 120 })
|
|
337
|
+
|
|
338
|
+
const res = createResponse()
|
|
339
|
+
await handler(createRequest(), res)
|
|
340
|
+
|
|
341
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
342
|
+
'cache-control',
|
|
343
|
+
'public, s-maxage=120, stale-while-revalidate=0'
|
|
344
|
+
)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('makes collected cache-control private when there is an auth cookie', async () => {
|
|
348
|
+
mockExecuteWithCacheControl({ scope: 'public', sMaxAge: 120 })
|
|
349
|
+
|
|
350
|
+
const req = createRequest()
|
|
351
|
+
req.headers.cookie = 'VtexIdclientAutCookie_store=jwt'
|
|
352
|
+
const res = createResponse()
|
|
353
|
+
await handler(req, res)
|
|
354
|
+
|
|
355
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
356
|
+
'cache-control',
|
|
357
|
+
'private, s-maxage=120, stale-while-revalidate=0'
|
|
358
|
+
)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('falls back to the default max-age for GET queries without collected cache-control', async () => {
|
|
362
|
+
discoveryConfig.experimental.graphqlCacheControl = {
|
|
363
|
+
maxAge: 0,
|
|
364
|
+
staleWhileRevalidate: 0,
|
|
365
|
+
}
|
|
366
|
+
mockExecuteWithCacheControl()
|
|
367
|
+
|
|
368
|
+
const res = createResponse()
|
|
369
|
+
await handler(createRequest(), res)
|
|
370
|
+
|
|
371
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
372
|
+
'cache-control',
|
|
373
|
+
'public, s-maxage=300, stale-while-revalidate=3600'
|
|
374
|
+
)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('uses the configured max-age and stale-while-revalidate for GET queries', async () => {
|
|
378
|
+
discoveryConfig.experimental.graphqlCacheControl = {
|
|
379
|
+
maxAge: 60,
|
|
380
|
+
staleWhileRevalidate: 30,
|
|
381
|
+
}
|
|
382
|
+
mockExecuteWithCacheControl()
|
|
383
|
+
|
|
384
|
+
const req = createRequest()
|
|
385
|
+
req.headers.cookie = 'VtexIdclientAutCookie_store=jwt'
|
|
386
|
+
const res = createResponse()
|
|
387
|
+
await handler(req, res)
|
|
388
|
+
|
|
389
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
390
|
+
'cache-control',
|
|
391
|
+
'private, s-maxage=60, stale-while-revalidate=30'
|
|
392
|
+
)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('does not cache operations that are not GET queries', async () => {
|
|
396
|
+
mockExecuteWithCacheControl()
|
|
397
|
+
|
|
398
|
+
const res = createResponse()
|
|
399
|
+
await handler(createRequest('SubscribeToNewsletter'), res)
|
|
400
|
+
|
|
401
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
402
|
+
'cache-control',
|
|
403
|
+
'no-cache, no-store'
|
|
404
|
+
)
|
|
405
|
+
})
|
|
406
|
+
})
|