Skip to content

Commit a22fa1c

Browse files
committed
refactor
1 parent 9d46554 commit a22fa1c

File tree

5 files changed

+130
-166
lines changed

5 files changed

+130
-166
lines changed

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

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,25 +1507,24 @@ describe('HttpOnly Session Cookies', () => {
15071507
const expiresAtFuture = Math.floor(Date.now() / 1000) + 3600
15081508

15091509
const httpOnlyTokenResponse: ShopperLoginTypes.TokenResponse = {
1510-
...TOKEN_RESPONSE,
1511-
// When HttpOnly cookies are enabled, the proxy strips tokens from the body
1512-
// and adds access_token_expires_at for client-side expiry checks
1513-
access_token_expires_at: expiresAtFuture
1510+
...TOKEN_RESPONSE
15141511
}
15151512

15161513
beforeEach(() => {
15171514
jest.clearAllMocks()
15181515
})
15191516

1520-
test('loginGuestUser stores access_token_expires_at but not tokens', async () => {
1517+
test('loginGuestUser does not store tokens when HttpOnly cookies are enabled', async () => {
15211518
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
15221519
const loginGuestMock = helpers.loginGuestUser as jest.Mock
15231520
loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse)
15241521

1522+
// Set cc-at-expires cookie (as server would via Set-Cookie header)
1523+
// @ts-expect-error private method
1524+
auth.set('cc-at-expires', String(expiresAtFuture))
1525+
15251526
await auth.loginGuestUser()
15261527

1527-
// access_token_expires_at should be stored for client-side expiry checks
1528-
expect(auth.get('access_token_expires_at')).toBe(String(expiresAtFuture))
15291528
// Tokens should NOT be stored in localStorage (they're in HttpOnly cookies)
15301529
expect(auth.get('access_token')).toBeFalsy()
15311530
expect(auth.get('refresh_token_guest')).toBeFalsy()
@@ -1534,27 +1533,32 @@ describe('HttpOnly Session Cookies', () => {
15341533
expect(auth.get('usid')).toBe(TOKEN_RESPONSE.usid)
15351534
})
15361535

1537-
test('ready re-uses data when access_token_expires_at is still valid', async () => {
1536+
test('ready re-uses data when cc-at-expires cookie is still valid', async () => {
15381537
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
15391538
const loginGuestMock = helpers.loginGuestUser as jest.Mock
15401539
loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse)
15411540

15421541
// First call: triggers loginGuestUser
15431542
await auth.ready()
1543+
1544+
// Set cc-at-expires cookie (as server would via Set-Cookie header)
1545+
// @ts-expect-error private method
1546+
auth.set('cc-at-expires', String(expiresAtFuture))
1547+
15441548
expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1)
15451549

1546-
// Second call: access_token_expires_at is in the future, so it should re-use data
1550+
// Second call: cc-at-expires is in the future, so it should re-use data
15471551
await auth.ready()
15481552
expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) // Not called again
15491553
})
15501554

1551-
test('ready triggers refresh when access_token_expires_at is expired', async () => {
1555+
test('ready triggers refresh when cc-at-expires cookie is expired', async () => {
15521556
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
15531557

15541558
// Simulate a previous login that left behind stored data with an expired token
15551559
const expiredTime = Math.floor(Date.now() / 1000) - 100
15561560
// @ts-expect-error private method
1557-
auth.set('access_token_expires_at', String(expiredTime))
1561+
auth.set('cc-at-expires', String(expiredTime))
15581562
// @ts-expect-error private method
15591563
auth.set('refresh_token_guest', 'refresh_token')
15601564
// @ts-expect-error private method

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

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ type AuthDataKeys =
138138
| 'uido'
139139
| 'idp_refresh_token'
140140
| 'dnt'
141-
| 'access_token_expires_at'
141+
| 'cc-at-expires'
142142

143143
type AuthDataMap = Record<
144144
AuthDataKeys,
@@ -254,9 +254,9 @@ const DATA_MAP: AuthDataMap = {
254254
storageType: 'local',
255255
key: 'uido'
256256
},
257-
access_token_expires_at: {
258-
storageType: 'local',
259-
key: 'access_token_expires_at'
257+
'cc-at-expires': {
258+
storageType: 'cookie',
259+
key: 'cc-at-expires'
260260
}
261261
}
262262

@@ -528,11 +528,11 @@ class Auth {
528528

529529
/**
530530
* Returns whether the access token is expired. When useHttpOnlySessionCookies is true,
531-
* uses access_token_expires_at from store; otherwise decodes the JWT from getAccessToken().
531+
* uses cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken().
532532
*/
533533
private isAccessTokenExpired(): boolean {
534534
if (this.useHttpOnlySessionCookies) {
535-
const expiresAt = this.get('access_token_expires_at')
535+
const expiresAt = this.get('cc-at-expires')
536536
if (expiresAt == null || expiresAt === '') return true
537537
const expiresAtSec = Number(expiresAt)
538538
if (Number.isNaN(expiresAtSec)) return true
@@ -735,14 +735,6 @@ class Auth {
735735
const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
736736
this.set(refreshTokenKey, res.refresh_token, {expires: expiresDate})
737737
}
738-
if (
739-
res &&
740-
typeof res === 'object' &&
741-
'access_token_expires_at' in res &&
742-
res.access_token_expires_at != null
743-
) {
744-
this.set('access_token_expires_at', String(res.access_token_expires_at))
745-
}
746738
}
747739

748740
async refreshAccessToken() {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ describe('HttpOnly session cookies', () => {
582582
expect(response.headers['set-cookie']).toBeDefined()
583583
const cookies = response.headers['set-cookie']
584584
expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true)
585-
expect(cookies.some((c) => c.includes('cc-at-expires-at_testsite'))).toBe(true)
585+
expect(cookies.some((c) => c.includes('cc-at-expires_testsite'))).toBe(true)
586586
expect(cookies.some((c) => c.includes('cc-nx-g_testsite'))).toBe(true)
587587
} finally {
588588
mockSlasServerInstance.close()
@@ -692,7 +692,7 @@ describe('HttpOnly session cookies', () => {
692692
expect(response.headers['set-cookie']).toBeDefined()
693693
const cookies = response.headers['set-cookie']
694694
expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true)
695-
expect(cookies.some((c) => c.includes('cc-at-expires-at_testsite'))).toBe(true)
695+
expect(cookies.some((c) => c.includes('cc-at-expires_testsite'))).toBe(true)
696696
} finally {
697697
mockSlasServerInstance.close()
698698
}

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

Lines changed: 94 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -35,56 +35,19 @@ export function getRefreshTokenCookieTTL(refreshTokenExpiresInSLASValue, isGuest
3535
}
3636

3737
/**
38-
* Decodes the SLAS access token JWT, extracts claims, and sets non-HttpOnly metadata cookies
39-
* (expires-at, dnt, uido) so the client can read them. Same field extraction as
38+
* Decodes the SLAS access token JWT and extracts claims. Same field extraction as
4039
* commerce-sdk-react parseSlasJWT.
41-
*
42-
* Returns {isGuest} for the caller to determine the refresh token cookie name.
4340
* @private
4441
*/
45-
function setTokenClaimCookies(res, siteId, accessToken, expiresInSeconds) {
42+
function getTokenClaims(accessToken) {
4643
let payload
4744
try {
4845
payload = jwtDecode(accessToken)
4946
} catch (error) {
5047
throw new Error(`Failed to decode access token JWT: ${error.message || error}. `)
5148
}
5249

53-
const accessExpires = new Date(Date.now() + expiresInSeconds * 1000)
54-
55-
// Expiry timestamp — use JWT iat when available (non-HttpOnly so client can check expiry)
56-
let expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds
57-
if (typeof payload.iat === 'number') {
58-
expiresAt = payload.iat + expiresInSeconds
59-
}
60-
res.append(
61-
SET_COOKIE,
62-
cookieAsString({
63-
name: `cc-at-expires-at_${siteId}`,
64-
value: String(expiresAt),
65-
path: '/',
66-
secure: true,
67-
sameSite: 'lax',
68-
httpOnly: false,
69-
expires: accessExpires
70-
})
71-
)
72-
73-
// Do-not-track flag from JWT (non-HttpOnly so client can read it)
74-
if (payload.dnt !== undefined) {
75-
res.append(
76-
SET_COOKIE,
77-
cookieAsString({
78-
name: `cc-at-dnt_${siteId}`,
79-
value: String(payload.dnt),
80-
path: '/',
81-
secure: true,
82-
sameSite: 'lax',
83-
httpOnly: false,
84-
expires: accessExpires
85-
})
86-
)
87-
}
50+
const accessExpires = new Date(payload.exp * 1000)
8851

8952
// Extract isGuest and uido from JWT isb claim
9053
let isGuest = true
@@ -96,66 +59,7 @@ function setTokenClaimCookies(res, siteId, accessToken, expiresInSeconds) {
9659
if (uidoPart) uido = uidoPart
9760
}
9861

99-
// uido: IDP origin (e.g. "slas", "ecom"); non-HttpOnly so client can read for useCustomerType/isExternal
100-
if (uido) {
101-
res.append(
102-
SET_COOKIE,
103-
cookieAsString({
104-
name: `uido_${siteId}`,
105-
value: uido,
106-
path: '/',
107-
secure: true,
108-
sameSite: 'lax',
109-
httpOnly: false,
110-
expires: accessExpires
111-
})
112-
)
113-
}
114-
115-
return {isGuest}
116-
}
117-
118-
/**
119-
* Sets the IDP access token as an HttpOnly cookie.
120-
* @private
121-
*/
122-
function setIdpAccessTokenCookie(res, siteId, idpAccessToken, expiresInSeconds) {
123-
const idpExpires = new Date(Date.now() + expiresInSeconds * 1000)
124-
res.append(
125-
SET_COOKIE,
126-
cookieAsString({
127-
name: `idp_access_token_${siteId}`,
128-
value: idpAccessToken,
129-
path: '/',
130-
secure: true,
131-
sameSite: 'lax',
132-
httpOnly: true,
133-
expires: idpExpires
134-
})
135-
)
136-
}
137-
138-
/**
139-
* Sets the refresh token as an HttpOnly cookie. Cookie name depends on guest vs registered user.
140-
* @private
141-
*/
142-
function setRefreshTokenCookie(res, siteId, refreshToken, refreshTokenExpiresIn, isGuest) {
143-
const refreshTTL = getRefreshTokenCookieTTL(refreshTokenExpiresIn, isGuest)
144-
const refreshExpires = new Date(Date.now() + refreshTTL * 1000)
145-
const refreshCookieName = isGuest ? `cc-nx-g_${siteId}` : `cc-nx_${siteId}`
146-
147-
res.append(
148-
SET_COOKIE,
149-
cookieAsString({
150-
name: refreshCookieName,
151-
value: refreshToken,
152-
path: '/',
153-
secure: true,
154-
sameSite: 'lax',
155-
httpOnly: true,
156-
expires: refreshExpires
157-
})
158-
)
62+
return {accessExpires, expiresAt: payload.exp, dnt: payload.dnt, isGuest, uido}
15963
}
16064

16165
/**
@@ -180,13 +84,20 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res,
18084
}
18185

18286
const site = siteId.trim()
183-
const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800
18487

185-
// Decode JWT, set metadata cookies (expires-at, dnt, uido), get isGuest
88+
// Decode JWT and extract claims
18689
let isGuest = true
18790
if (parsed.access_token) {
91+
const {
92+
accessExpires,
93+
expiresAt,
94+
dnt,
95+
uido,
96+
isGuest: guest
97+
} = getTokenClaims(parsed.access_token)
98+
isGuest = guest
99+
188100
// Access token (HttpOnly)
189-
const accessExpires = new Date(Date.now() + expiresInSeconds * 1000)
190101
res.append(
191102
SET_COOKIE,
192103
cookieAsString({
@@ -200,23 +111,91 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res,
200111
})
201112
)
202113

203-
const claims = setTokenClaimCookies(res, site, parsed.access_token, expiresInSeconds)
204-
isGuest = claims.isGuest
205-
}
114+
// Expiry timestamp from JWT exp claim (non-HttpOnly so client can check expiry)
115+
res.append(
116+
SET_COOKIE,
117+
cookieAsString({
118+
name: `cc-at-expires_${site}`,
119+
value: String(expiresAt),
120+
path: '/',
121+
secure: true,
122+
sameSite: 'lax',
123+
httpOnly: false,
124+
expires: accessExpires
125+
})
126+
)
206127

207-
// IDP access token
208-
if (parsed.idp_access_token) {
209-
setIdpAccessTokenCookie(res, site, parsed.idp_access_token, expiresInSeconds)
128+
// Do-not-track flag from JWT (non-HttpOnly so client can read it)
129+
if (dnt !== undefined) {
130+
res.append(
131+
SET_COOKIE,
132+
cookieAsString({
133+
name: `cc-at-dnt_${site}`,
134+
value: String(dnt),
135+
path: '/',
136+
secure: true,
137+
sameSite: 'lax',
138+
httpOnly: false,
139+
expires: accessExpires
140+
})
141+
)
142+
}
143+
144+
// uido: IDP origin (e.g. "slas", "ecom"); non-HttpOnly so client can read for useCustomerType/isExternal
145+
if (uido) {
146+
res.append(
147+
SET_COOKIE,
148+
cookieAsString({
149+
name: `uido_${site}`,
150+
value: uido,
151+
path: '/',
152+
secure: true,
153+
sameSite: 'lax',
154+
httpOnly: false,
155+
expires: accessExpires
156+
})
157+
)
158+
}
159+
160+
// IDP access token (HttpOnly)
161+
if (parsed.idp_access_token) {
162+
res.append(
163+
SET_COOKIE,
164+
cookieAsString({
165+
name: `idp_access_token_${site}`,
166+
value: parsed.idp_access_token,
167+
path: '/',
168+
secure: true,
169+
sameSite: 'lax',
170+
httpOnly: true,
171+
expires: accessExpires
172+
})
173+
)
174+
}
210175
}
211176

212-
// Refresh token
177+
// Refresh token (HttpOnly) — uses its own TTL, independent of access token expiry
213178
if (parsed.refresh_token) {
214-
setRefreshTokenCookie(
215-
res,
216-
site,
217-
parsed.refresh_token,
179+
const commerceAPI = options.mobify?.app?.commerceAPI || {}
180+
const refreshTTL = getRefreshTokenCookieTTL(
218181
parsed.refresh_token_expires_in,
219-
isGuest
182+
isGuest,
183+
commerceAPI
184+
)
185+
const refreshExpires = new Date(Date.now() + refreshTTL * 1000)
186+
const refreshCookieName = isGuest ? `cc-nx-g_${site}` : `cc-nx_${site}`
187+
188+
res.append(
189+
SET_COOKIE,
190+
cookieAsString({
191+
name: refreshCookieName,
192+
value: parsed.refresh_token,
193+
path: '/',
194+
secure: true,
195+
sameSite: 'lax',
196+
httpOnly: true,
197+
expires: refreshExpires
198+
})
220199
)
221200
}
222201

0 commit comments

Comments
 (0)