Skip to content

Commit 6f3f9f1

Browse files
authored
Handle refresh token errors (#3771)
* Handle refresh token errors
1 parent e8f5e33 commit 6f3f9f1

File tree

8 files changed

+234
-14
lines changed

8 files changed

+234
-14
lines changed

packages/commerce-sdk-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680)
33
- use nightly release of isomorphic sdk that supports httponly cookies [#3754](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3754)
44
- Handle refresh token flow for HttpOnly session cookies [#3759](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3759)
5+
- Handle missing refresh token for HttpOnly session cookies [#3771](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3771)
56

67
## v5.2.0-dev (Mar 20, 2026)
78

packages/commerce-sdk-react/src/auth/index.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,6 +1563,45 @@ describe('HttpOnly Session Cookies', () => {
15631563
)
15641564
})
15651565

1566+
test('ready skips refresh and goes straight to guest login on first visit (no cc-nx-exists)', async () => {
1567+
const auth = new Auth({...config, enableHttpOnlySessionCookies: true})
1568+
const loginGuestMock = helpers.loginGuestUser as jest.Mock
1569+
loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse)
1570+
1571+
// First visit: no cc-nx-exists cookie, no refresh token, no cc-at-expires
1572+
// Should NOT attempt refresh — should go straight to loginGuestUser
1573+
await auth.ready()
1574+
1575+
expect(helpers.refreshAccessToken).not.toHaveBeenCalled()
1576+
expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1)
1577+
})
1578+
1579+
test('ready attempts refresh when cc-nx-exists is set (returning visitor)', async () => {
1580+
const auth = new Auth({...config, enableHttpOnlySessionCookies: true})
1581+
1582+
// Simulate a returning visitor: expired access token + cc-nx-exists indicator
1583+
const expiredTime = Math.floor(Date.now() / 1000) - 100
1584+
// @ts-expect-error private method
1585+
auth.set('cc-at-expires', String(expiredTime))
1586+
// @ts-expect-error private method
1587+
auth.set('cc-nx-exists', '1')
1588+
// @ts-expect-error private method
1589+
auth.set('customer_type', 'guest')
1590+
// @ts-expect-error private method
1591+
auth.set('access_token', JWTExpired)
1592+
1593+
const refreshMock = helpers.refreshAccessToken as jest.Mock
1594+
refreshMock.mockResolvedValueOnce(httpOnlyTokenResponse)
1595+
1596+
await auth.ready()
1597+
1598+
// Should attempt refresh because cc-nx-exists indicates an HttpOnly refresh token exists
1599+
expect(helpers.refreshAccessToken).toHaveBeenCalledTimes(1)
1600+
expect(helpers.refreshAccessToken).toHaveBeenCalledWith(
1601+
expect.objectContaining({enableHttpOnlySessionCookies: true})
1602+
)
1603+
})
1604+
15661605
test('ready triggers refresh when cc-at-expires cookie is expired', async () => {
15671606
const auth = new Auth({...config, enableHttpOnlySessionCookies: true})
15681607

packages/commerce-sdk-react/src/auth/index.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ type AuthDataKeys =
140140
| 'idp_refresh_token'
141141
| 'dnt'
142142
| 'cc-at-expires'
143+
| 'cc-nx-exists'
143144

144145
type AuthDataMap = Record<
145146
AuthDataKeys,
@@ -258,6 +259,10 @@ const DATA_MAP: AuthDataMap = {
258259
'cc-at-expires': {
259260
storageType: 'cookie',
260261
key: 'cc-at-expires'
262+
},
263+
'cc-nx-exists': {
264+
storageType: 'cookie',
265+
key: 'cc-nx-exists'
261266
}
262267
}
263268

@@ -527,6 +532,15 @@ class Auth {
527532
return validTimeSeconds <= tokenAgeSeconds
528533
}
529534

535+
/**
536+
* Returns whether a refresh token exists in an HttpOnly cookie. Since JavaScript cannot
537+
* read HttpOnly cookies, we check the non-HttpOnly indicator cookie (cc-nx-exists) that
538+
* is set alongside the refresh token with the same expiry.
539+
*/
540+
private hasHttpOnlyRefreshToken(): boolean {
541+
return this.enableHttpOnlySessionCookies && onClient() && this.get('cc-nx-exists') === '1'
542+
}
543+
530544
/**
531545
* Returns whether the access token is expired. When enableHttpOnlySessionCookies is true,
532546
* uses cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken().
@@ -748,12 +762,11 @@ class Auth {
748762
const refreshToken = refreshTokenRegistered || refreshTokenGuest
749763

750764
// When HttpOnly session cookies are enabled on the client, the refresh token is in an
751-
// HttpOnly cookie that JavaScript cannot read. We still attempt the refresh request —
752-
// the SLAS private proxy injects the refresh token via the sfdc_refresh_token header.
753-
const hasHttpOnlyRefreshToken =
754-
!refreshToken && this.enableHttpOnlySessionCookies && onClient()
755-
756-
if (refreshToken || hasHttpOnlyRefreshToken) {
765+
// HttpOnly cookie that JavaScript cannot read. We check the non-HttpOnly indicator
766+
// cookie (cc-nx-exists) to avoid a wasted round-trip when the refresh token is absent.
767+
// If cc-nx-exists is also missing (e.g. cleared by the user), the proxy layer will
768+
// catch the missing refresh token and return a 401, falling through to guest login.
769+
if (refreshToken || (!refreshToken && this.hasHttpOnlyRefreshToken())) {
757770
try {
758771
const isGuest = this.get('customer_type') !== 'registered'
759772
// Signal the proxy that this is a refresh token request so it can

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Rename the configuration flag `disableHttpOnlySessionCookies` to `enableHttpOnlySessionCookies` [#3723](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3723)
66
- updated package-lock [#3754](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3754)
77
- Handle refresh token flow and consolidate `x-site-id` header usage for HttpOnly session cookies [#3759](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3759)
8+
- Handle missing refresh token for HttpOnly session cookies [#3771](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3771)
89

910
## v3.18.0-dev (Mar 20, 2026)
1011
- Add additional logging and error handling for SLAS error handling [#3750](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3750)

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,34 +96,51 @@ export const isBinary = (headers) => {
9696
return isContentTypeBinary(headers)
9797
}
9898

99+
/**
100+
* Error thrown when the refresh token HttpOnly cookie is not found on the incoming request.
101+
* Handled specifically in the SLAS proxy to return a 401 instead of forwarding to SLAS.
102+
*/
103+
export class RefreshTokenNotFoundError extends Error {
104+
constructor(message) {
105+
super(message)
106+
this.name = 'RefreshTokenNotFoundError'
107+
}
108+
}
109+
99110
/**
100111
* Inject the refresh token from HttpOnly cookies as the sfdc_refresh_token header.
101112
* SLAS uses sfdc_refresh_token as a fallback when the refresh_token body parameter is
102113
* missing or empty (which is the case when HttpOnly session cookies are enabled, because
103114
* client-side JavaScript cannot read the HttpOnly refresh token cookie).
115+
* @throws {RefreshTokenNotFoundError} If the refresh token cookie is missing.
104116
* @private
105117
*/
106118
export const setRefreshTokenHeader = (proxyRequest, incomingRequest) => {
107119
const cookieHeader = incomingRequest.headers.cookie
108-
if (!cookieHeader) return
120+
if (!cookieHeader) {
121+
throw new RefreshTokenNotFoundError(
122+
'No cookies present on request. Cannot inject refresh token.'
123+
)
124+
}
109125

110126
const cookies = cookie.parse(cookieHeader)
111127
const siteId = incomingRequest.headers[X_SITE_ID]
112128
if (!siteId) {
113-
logger.warn(
129+
throw new RefreshTokenNotFoundError(
114130
'x-site-id header is missing on SLAS token request. ' +
115-
'Refresh token header injection skipped. ' +
116-
'Ensure the x-site-id header is set in CommerceApiProvider headers.',
117-
{namespace: 'setRefreshTokenHeader'}
131+
'Cannot inject refresh token. ' +
132+
'Ensure the x-site-id header is set in CommerceApiProvider headers.'
118133
)
119-
return
120134
}
121135

122136
// Try registered refresh token first, then guest
123137
const refreshToken = cookies[`cc-nx_${siteId}`] || cookies[`cc-nx-g_${siteId}`]
124-
if (refreshToken) {
125-
proxyRequest.setHeader('sfdc_refresh_token', refreshToken)
138+
if (!refreshToken) {
139+
throw new RefreshTokenNotFoundError(
140+
'Refresh token cookie not found. Cannot proceed with refresh token flow.'
141+
)
126142
}
143+
proxyRequest.setHeader('sfdc_refresh_token', refreshToken)
127144
}
128145

129146
/**
@@ -1097,6 +1114,18 @@ export const RemoteServerFactory = {
10971114
}
10981115
}
10991116
} catch (error) {
1117+
if (error instanceof RefreshTokenNotFoundError) {
1118+
logger.warn(error.message, {
1119+
namespace: 'setRefreshTokenHeader'
1120+
})
1121+
if (!res.headersSent) {
1122+
proxyRequest.destroy()
1123+
res.status(401).json({
1124+
message: 'invalid refresh_token'
1125+
})
1126+
}
1127+
return
1128+
}
11001129
logger.error('Error in SLAS private proxy request handling', {
11011130
namespace: '_setupSlasPrivateClientProxy',
11021131
additionalProperties: {

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,111 @@ describe('HttpOnly session cookies', () => {
926926
const cookies = response.headers['set-cookie']
927927
expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true)
928928
expect(cookies.some((c) => c.includes('cc-nx-g_testsite'))).toBe(true)
929+
// cc-nx-exists indicator cookie is set (non-HttpOnly)
930+
expect(cookies.some((c) => c.includes('cc-nx-exists_testsite=1'))).toBe(true)
931+
} finally {
932+
mockSlasServerInstance.close()
933+
}
934+
})
935+
936+
test('returns 401 when refresh token cookie is missing on a refresh_token request', async () => {
937+
process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES = 'true'
938+
939+
const mockSlasServer = mockExpress()
940+
// The mock server should NOT be hit — the proxy should short-circuit with 401
941+
const slasHit = jest.fn()
942+
mockSlasServer.post('/shopper/auth/v1/oauth2/token', (req, res) => {
943+
slasHit()
944+
res.status(200).json({access_token: 'should-not-reach'})
945+
})
946+
947+
const mockSlasServerInstance = mockSlasServer.listen(0)
948+
const mockSlasPort = mockSlasServerInstance.address().port
949+
950+
try {
951+
const app = mockExpress()
952+
const options = RemoteServerFactory._configure({
953+
useSLASPrivateClient: true,
954+
slasTarget: `http://localhost:${mockSlasPort}`,
955+
mobify: {
956+
app: {
957+
commerceAPI: {
958+
parameters: {
959+
shortCode: 'test',
960+
organizationId: 'f_ecom_test',
961+
clientId: 'test-client-id',
962+
siteId: 'testsite'
963+
}
964+
}
965+
}
966+
}
967+
})
968+
969+
process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret'
970+
971+
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
972+
973+
// Send refresh_token request with NO cookies at all
974+
const response = await request(app)
975+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/token')
976+
.set(X_SITE_ID, 'testsite')
977+
.set(X_GRANT_TYPE, 'refresh_token')
978+
979+
expect(response.status).toBe(401)
980+
expect(response.body.message).toBe('invalid refresh_token')
981+
// SLAS server should NOT have been called
982+
expect(slasHit).not.toHaveBeenCalled()
983+
} finally {
984+
mockSlasServerInstance.close()
985+
}
986+
})
987+
988+
test('returns 401 when refresh token cookie is missing but other cookies are present', async () => {
989+
process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES = 'true'
990+
991+
const mockSlasServer = mockExpress()
992+
const slasHit = jest.fn()
993+
mockSlasServer.post('/shopper/auth/v1/oauth2/token', (req, res) => {
994+
slasHit()
995+
res.status(200).json({access_token: 'should-not-reach'})
996+
})
997+
998+
const mockSlasServerInstance = mockSlasServer.listen(0)
999+
const mockSlasPort = mockSlasServerInstance.address().port
1000+
1001+
try {
1002+
const app = mockExpress()
1003+
const options = RemoteServerFactory._configure({
1004+
useSLASPrivateClient: true,
1005+
slasTarget: `http://localhost:${mockSlasPort}`,
1006+
mobify: {
1007+
app: {
1008+
commerceAPI: {
1009+
parameters: {
1010+
shortCode: 'test',
1011+
organizationId: 'f_ecom_test',
1012+
clientId: 'test-client-id',
1013+
siteId: 'testsite'
1014+
}
1015+
}
1016+
}
1017+
}
1018+
})
1019+
1020+
process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret'
1021+
1022+
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
1023+
1024+
// Has cookies but NOT the refresh token cookie
1025+
const response = await request(app)
1026+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/token')
1027+
.set('Cookie', 'cc-at-expires_testsite=12345')
1028+
.set(X_SITE_ID, 'testsite')
1029+
.set(X_GRANT_TYPE, 'refresh_token')
1030+
1031+
expect(response.status).toBe(401)
1032+
expect(response.body.message).toBe('invalid refresh_token')
1033+
expect(slasHit).not.toHaveBeenCalled()
9291034
} finally {
9301035
mockSlasServerInstance.close()
9311036
}

packages/pwa-kit-runtime/src/ssr/server/process-token-response.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,21 @@ export function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, op
198198
})
199199
)
200200

201+
// Non-HttpOnly indicator so the client can check if a refresh token cookie exists
202+
// (JavaScript cannot read HttpOnly cookies). Shares the same expiry as the refresh token.
203+
res.append(
204+
SET_COOKIE,
205+
cookieAsString({
206+
name: `cc-nx-exists_${site}`,
207+
value: '1',
208+
path: '/',
209+
secure: true,
210+
sameSite: 'lax',
211+
httpOnly: false,
212+
expires: refreshExpires
213+
})
214+
)
215+
201216
// Delete the opposite refresh token cookie to mirror client-side behavior:
202217
// Login (guest → registered): delete guest cookie cc-nx-g
203218
// Logout (registered → guest): delete registered cookie cc-nx

packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ describe('setHttpOnlySessionCookies', () => {
188188
expect(uidoCookie.value).toBe('ecom')
189189
expect(uidoCookie.httpOnly).toBeUndefined()
190190

191+
// cc-nx-exists: non-HttpOnly indicator that a refresh token cookie exists
192+
const nxExistsCookie = parseCookie(
193+
res.cookies.find((c) => c.includes('cc-nx-exists_testsite='))
194+
)
195+
expect(nxExistsCookie.value).toBe('1')
196+
expect(nxExistsCookie.httpOnly).toBeUndefined()
197+
expect(nxExistsCookie.secure).toBe(true)
198+
191199
// Registered refresh cookie should be expired (deleted)
192200
const staleRegisteredCookie = parseCookie(
193201
res.cookies.find((c) => c.startsWith('cc-nx_testsite='))
@@ -237,6 +245,13 @@ describe('setHttpOnlySessionCookies', () => {
237245
const uidoCookie = parseCookie(res.cookies.find((c) => c.includes('uido_testsite=')))
238246
expect(uidoCookie.value).toBe('ecom')
239247

248+
// cc-nx-exists: non-HttpOnly indicator that a refresh token cookie exists
249+
const nxExistsCookie = parseCookie(
250+
res.cookies.find((c) => c.includes('cc-nx-exists_testsite='))
251+
)
252+
expect(nxExistsCookie.value).toBe('1')
253+
expect(nxExistsCookie.httpOnly).toBeUndefined()
254+
240255
// Guest refresh cookie should be expired (deleted)
241256
const staleGuestCookie = parseCookie(
242257
res.cookies.find((c) => c.startsWith('cc-nx-g_testsite='))
@@ -292,6 +307,8 @@ describe('setHttpOnlySessionCookies', () => {
292307
const body = JSON.parse(result.toString('utf8'))
293308

294309
expect(res.cookies).toHaveLength(0)
310+
// cc-nx-exists should NOT be set when there is no refresh token
311+
expect(res.cookies.find((c) => c.includes('cc-nx-exists'))).toBeUndefined()
295312
expect(body.other_field).toBe('value')
296313
})
297314

0 commit comments

Comments
 (0)