Skip to content

Commit 24723fb

Browse files
unandyalaclaude
andcommitted
Add HttpOnly session cookies for SLAS private client proxy
When MRT_DISABLE_HTTPONLY_SESSION_COOKIES is 'false', token responses from SLAS are intercepted: access_token, refresh_token, and idp_access_token are set as HttpOnly cookies and stripped from the response body. The client receives access_token_expires_at for expiry checks without needing the JWT. Server-side (pwa-kit-runtime): - applyHttpOnlySessionCookies() intercepts token responses, sets HttpOnly cookies with siteId suffix, and strips tokens from body - applyProxyRequestAuthHeader() reads access token from HttpOnly cookie and sets Authorization header for SCAPI proxy requests - isScapiDomain() utility for identifying Commerce API domains - Configurable tokenResponseEndpoints and slasEndpointsRequiringAccessToken regexes for controlling which endpoints are processed Client-side (commerce-sdk-react): - useHttpOnlySessionCookies flag on Auth and CommerceApiProvider - isAccessTokenExpired() uses access_token_expires_at when HttpOnly enabled - handleTokenResponse() skips storing tokens in localStorage when HttpOnly - Provider ensures fetch credentials allow cookies to be sent Note: TAOB (Trusted Agent on Behalf) and refresh token flows with HttpOnly cookies will be handled in follow-up work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fa33cd4 commit 24723fb

File tree

17 files changed

+1152
-48
lines changed

17 files changed

+1152
-48
lines changed

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,3 +1502,68 @@ describe('hybridAuthEnabled property toggles clearECOMSession', () => {
15021502
expect(auth.get('dwsid')).toBe('test-dwsid-value')
15031503
})
15041504
})
1505+
1506+
describe('HttpOnly Session Cookies', () => {
1507+
const expiresAtFuture = Math.floor(Date.now() / 1000) + 3600
1508+
1509+
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
1514+
}
1515+
1516+
beforeEach(() => {
1517+
jest.clearAllMocks()
1518+
})
1519+
1520+
test('loginGuestUser stores access_token_expires_at but not tokens', async () => {
1521+
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
1522+
const loginGuestMock = helpers.loginGuestUser as jest.Mock
1523+
loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse)
1524+
1525+
await auth.loginGuestUser()
1526+
1527+
// access_token_expires_at should be stored for client-side expiry checks
1528+
expect(auth.get('access_token_expires_at')).toBe(String(expiresAtFuture))
1529+
// Tokens should NOT be stored in localStorage (they're in HttpOnly cookies)
1530+
expect(auth.get('access_token')).toBeFalsy()
1531+
expect(auth.get('refresh_token_guest')).toBeFalsy()
1532+
// Common fields should still be stored
1533+
expect(auth.get('customer_id')).toBe(TOKEN_RESPONSE.customer_id)
1534+
expect(auth.get('usid')).toBe(TOKEN_RESPONSE.usid)
1535+
})
1536+
1537+
test('ready re-uses data when access_token_expires_at is still valid', async () => {
1538+
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
1539+
const loginGuestMock = helpers.loginGuestUser as jest.Mock
1540+
loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse)
1541+
1542+
// First call: triggers loginGuestUser
1543+
await auth.ready()
1544+
expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1)
1545+
1546+
// Second call: access_token_expires_at is in the future, so it should re-use data
1547+
await auth.ready()
1548+
expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) // Not called again
1549+
})
1550+
1551+
test('ready triggers refresh when access_token_expires_at is expired', async () => {
1552+
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
1553+
1554+
// Simulate a previous login that left behind stored data with an expired token
1555+
const expiredTime = Math.floor(Date.now() / 1000) - 100
1556+
// @ts-expect-error private method
1557+
auth.set('access_token_expires_at', String(expiredTime))
1558+
// @ts-expect-error private method
1559+
auth.set('refresh_token_guest', 'refresh_token')
1560+
// @ts-expect-error private method
1561+
auth.set('customer_type', 'guest')
1562+
// Set a valid JWT so parseSlasJWT works during the refresh flow
1563+
// @ts-expect-error private method
1564+
auth.set('access_token', JWTExpired)
1565+
1566+
await auth.ready()
1567+
expect(helpers.refreshAccessToken).toHaveBeenCalled()
1568+
})
1569+
})

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

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ interface AuthConfig extends ApiClientConfigParams {
5454
refreshTokenRegisteredCookieTTL?: number
5555
refreshTokenGuestCookieTTL?: number
5656
hybridAuthEnabled?: boolean
57+
/** When true, token response may be sanitized (tokens in HttpOnly cookies); only set non-token fields and use access_token_expires_at for expiry. */
58+
useHttpOnlySessionCookies?: boolean
5759
}
5860

5961
interface JWTHeaders {
@@ -136,6 +138,7 @@ type AuthDataKeys =
136138
| 'uido'
137139
| 'idp_refresh_token'
138140
| 'dnt'
141+
| 'access_token_expires_at'
139142

140143
type AuthDataMap = Record<
141144
AuthDataKeys,
@@ -250,6 +253,10 @@ const DATA_MAP: AuthDataMap = {
250253
uido: {
251254
storageType: 'local',
252255
key: 'uido'
256+
},
257+
access_token_expires_at: {
258+
storageType: 'local',
259+
key: 'access_token_expires_at'
253260
}
254261
}
255262

@@ -284,6 +291,7 @@ class Auth {
284291
| undefined
285292

286293
private hybridAuthEnabled: boolean
294+
private useHttpOnlySessionCookies: boolean
287295

288296
constructor(config: AuthConfig) {
289297
// Special proxy endpoint for injecting SLAS private client secret.
@@ -380,6 +388,7 @@ class Auth {
380388
this.passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI || ''
381389

382390
this.hybridAuthEnabled = config.hybridAuthEnabled || false
391+
this.useHttpOnlySessionCookies = config.useHttpOnlySessionCookies ?? false
383392
}
384393

385394
get(name: AuthDataKeys) {
@@ -517,6 +526,23 @@ class Auth {
517526
return validTimeSeconds <= tokenAgeSeconds
518527
}
519528

529+
/**
530+
* 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().
532+
*/
533+
private isAccessTokenExpired(): boolean {
534+
if (this.useHttpOnlySessionCookies) {
535+
const expiresAt = this.get('access_token_expires_at')
536+
if (expiresAt == null || expiresAt === '') return true
537+
const expiresAtSec = Number(expiresAt)
538+
if (Number.isNaN(expiresAtSec)) return true
539+
const bufferSeconds = 60
540+
return Date.now() / 1000 >= expiresAtSec - bufferSeconds
541+
}
542+
const token = this.getAccessToken()
543+
return !token || this.isTokenExpired(token)
544+
}
545+
520546
/**
521547
* Returns the SLAS access token or an empty string if the access token
522548
* is not found in local store or if SFRA wants PWA to trigger refresh token login.
@@ -680,33 +706,43 @@ class Auth {
680706
private handleTokenResponse(res: TokenResponse, isGuest: boolean) {
681707
// Delete the SFRA auth token cookie if it exists
682708
this.clearSFRAAuthToken()
683-
this.set('access_token', res.access_token)
709+
684710
this.set('customer_id', res.customer_id)
685711
this.set('enc_user_id', res.enc_user_id)
686712
this.set('expires_in', `${res.expires_in}`)
687713
this.set('id_token', res.id_token)
688-
this.set('idp_access_token', res.idp_access_token)
689714
this.set('token_type', res.token_type)
690715
this.set('customer_type', isGuest ? 'guest' : 'registered')
691716

692-
const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
693717
const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue(
694718
res.refresh_token_expires_in,
695719
isGuest
696720
)
697-
if (res.access_token) {
698-
const {uido} = this.parseSlasJWT(res.access_token)
699-
this.set('uido', uido)
700-
}
701-
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
702721
this.set('refresh_token_expires_in', refreshTokenTTLValue.toString())
703-
this.set(refreshTokenKey, res.refresh_token, {
704-
expires: expiresDate
705-
})
722+
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
723+
this.set('usid', res.usid ?? '', {expires: expiresDate})
706724

707-
this.set('usid', res.usid, {
708-
expires: expiresDate
709-
})
725+
if (this.useHttpOnlySessionCookies) {
726+
const uidoFromCookie = this.stores['cookie'].get('uido')
727+
if (uidoFromCookie) this.set('uido', uidoFromCookie)
728+
} else {
729+
this.set('access_token', res.access_token)
730+
this.set('idp_access_token', res.idp_access_token)
731+
if (res.access_token) {
732+
const {uido} = this.parseSlasJWT(res.access_token)
733+
this.set('uido', uido)
734+
}
735+
const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
736+
this.set(refreshTokenKey, res.refresh_token, {expires: expiresDate})
737+
}
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+
}
710746
}
711747

712748
async refreshAccessToken() {
@@ -747,7 +783,7 @@ class Auth {
747783

748784
// refresh flow for TAOB
749785
const accessToken = this.getAccessToken()
750-
if (accessToken && this.isTokenExpired(accessToken)) {
786+
if (this.isAccessTokenExpired()) {
751787
try {
752788
const {isGuest, usid, loginId, isAgent} = this.parseSlasJWT(accessToken)
753789
if (isAgent) {
@@ -888,8 +924,7 @@ class Auth {
888924
return await this.pendingToken
889925
}
890926

891-
const accessToken = this.getAccessToken()
892-
if (accessToken && !this.isTokenExpired(accessToken)) {
927+
if (!this.isAccessTokenExpired()) {
893928
return this.data
894929
}
895930

packages/commerce-sdk-react/src/provider.test.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,64 @@ describe('provider', () => {
8989
const authInstance = (Auth as jest.Mock).mock.instances[0]
9090
expect(authInstance.ready).toHaveBeenCalledTimes(1)
9191
})
92+
93+
test('passes useHttpOnlySessionCookies to Auth constructor', () => {
94+
renderWithProviders(<h1>HttpOnly cookies enabled!</h1>, {
95+
useHttpOnlySessionCookies: true
96+
})
97+
expect(screen.getByText('HttpOnly cookies enabled!')).toBeInTheDocument()
98+
expect(Auth).toHaveBeenCalledTimes(1)
99+
expect(Auth).toHaveBeenCalledWith(
100+
expect.objectContaining({
101+
useHttpOnlySessionCookies: true
102+
})
103+
)
104+
})
105+
106+
test('defaults fetchOptions.credentials to same-origin when useHttpOnlySessionCookies is true', () => {
107+
renderWithProviders(<h1>test</h1>, {
108+
useHttpOnlySessionCookies: true
109+
})
110+
expect(Auth).toHaveBeenCalledWith(
111+
expect.objectContaining({
112+
fetchOptions: expect.objectContaining({credentials: 'same-origin'})
113+
})
114+
)
115+
})
116+
117+
test('overrides fetchOptions.credentials from omit to same-origin when useHttpOnlySessionCookies is true', () => {
118+
renderWithProviders(<h1>test</h1>, {
119+
useHttpOnlySessionCookies: true,
120+
fetchOptions: {credentials: 'omit'}
121+
})
122+
expect(Auth).toHaveBeenCalledWith(
123+
expect.objectContaining({
124+
fetchOptions: expect.objectContaining({credentials: 'same-origin'})
125+
})
126+
)
127+
})
128+
129+
test('keeps fetchOptions.credentials as include when useHttpOnlySessionCookies is true', () => {
130+
renderWithProviders(<h1>test</h1>, {
131+
useHttpOnlySessionCookies: true,
132+
fetchOptions: {credentials: 'include'}
133+
})
134+
expect(Auth).toHaveBeenCalledWith(
135+
expect.objectContaining({
136+
fetchOptions: expect.objectContaining({credentials: 'include'})
137+
})
138+
)
139+
})
140+
141+
test('does not modify fetchOptions.credentials when useHttpOnlySessionCookies is false', () => {
142+
renderWithProviders(<h1>test</h1>, {
143+
useHttpOnlySessionCookies: false,
144+
fetchOptions: {credentials: 'omit'}
145+
})
146+
expect(Auth).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
fetchOptions: expect.objectContaining({credentials: 'omit'})
149+
})
150+
)
151+
})
92152
})

packages/commerce-sdk-react/src/provider.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
4848
apiClients?: ApiClients
4949
disableAuthInit?: boolean
5050
hybridAuthEnabled?: boolean
51+
/** When true, proxy returns tokens in HttpOnly cookies. */
52+
useHttpOnlySessionCookies?: boolean
5153
}
5254

5355
/**
@@ -145,12 +147,20 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
145147
refreshTokenGuestCookieTTL,
146148
apiClients,
147149
disableAuthInit = false,
148-
hybridAuthEnabled = false
150+
hybridAuthEnabled = false,
151+
useHttpOnlySessionCookies = false
149152
} = props
150153

151154
// Set the logger based on provided configuration, or default to the console object if no logger is provided
152155
const configLogger = logger || console
153156

157+
// When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent.
158+
// Override 'omit' or unset to 'same-origin'; keep 'same-origin' or 'include' as-is.
159+
const effectiveFetchOptions =
160+
useHttpOnlySessionCookies && (!fetchOptions?.credentials || fetchOptions.credentials === 'omit')
161+
? {...fetchOptions, credentials: 'same-origin' as RequestCredentials}
162+
: fetchOptions
163+
154164
const auth = useMemo(() => {
155165
return new Auth({
156166
clientId,
@@ -160,7 +170,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
160170
proxy,
161171
redirectURI,
162172
headers,
163-
fetchOptions,
173+
fetchOptions: effectiveFetchOptions,
164174
fetchedToken,
165175
enablePWAKitPrivateClient,
166176
privateClientProxyEndpoint,
@@ -171,7 +181,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
171181
passwordlessLoginCallbackURI,
172182
refreshTokenRegisteredCookieTTL,
173183
refreshTokenGuestCookieTTL,
174-
hybridAuthEnabled
184+
hybridAuthEnabled,
185+
useHttpOnlySessionCookies
175186
})
176187
}, [
177188
clientId,
@@ -181,7 +192,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
181192
proxy,
182193
redirectURI,
183194
headers,
184-
fetchOptions,
195+
effectiveFetchOptions,
185196
fetchedToken,
186197
enablePWAKitPrivateClient,
187198
privateClientProxyEndpoint,
@@ -193,7 +204,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
193204
refreshTokenRegisteredCookieTTL,
194205
refreshTokenGuestCookieTTL,
195206
apiClients,
196-
hybridAuthEnabled
207+
hybridAuthEnabled,
208+
useHttpOnlySessionCookies
197209
])
198210

199211
const dwsid = auth.get(DWSID_COOKIE_NAME)
@@ -212,7 +224,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
212224
throwOnBadResponse: true,
213225
fetchOptions: {
214226
...options.fetchOptions,
215-
...fetchOptions
227+
...effectiveFetchOptions
216228
}
217229
}
218230
}
@@ -252,7 +264,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
252264
currency
253265
},
254266
throwOnBadResponse: true,
255-
fetchOptions
267+
fetchOptions: effectiveFetchOptions
256268
}
257269

258270
return {
@@ -279,7 +291,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
279291
shortCode,
280292
siteId,
281293
proxy,
282-
fetchOptions,
294+
effectiveFetchOptions,
283295
locale,
284296
currency,
285297
headers?.['correlation-id'],

packages/pwa-kit-dev/bin/pwa-kit-dev.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,12 @@ const main = async () => {
257257
// This mimics how MRT sets the system environment variable
258258
const config = getConfig() || {}
259259
const disableHttpOnlySessionCookies =
260-
config.ssrParameters?.disableHttpOnlySessionCookies || true
260+
config.ssrParameters?.disableHttpOnlySessionCookies ?? true
261261
execSync(`${babelNode} ${inspect ? '--inspect' : ''} ${babelArgs} ${entrypoint}`, {
262262
env: {
263263
...process.env,
264264
...(noHMR ? {HMR: 'false'} : {}),
265-
MRT_DISABLE_HTTPONLY_SESSION_COOKIES: disableHttpOnlySessionCookies
265+
MRT_DISABLE_HTTPONLY_SESSION_COOKIES: String(disableHttpOnlySessionCookies)
266266
}
267267
})
268268
})

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## v3.17.0-dev
22
- Add Node 24 support. Migrate deprecated Node.js `url.parse()` and `url.format()` to the WHATWG `URL` [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
3+
- Add HttpOnly session cookies for SLAS private client proxy: when `MRT_DISABLE_HTTPONLY_SESSION_COOKIES` is not `'true'`, token responses are intercepted and session tokens are set as HttpOnly cookies; token fields (access_token, idp_access_token, refresh_token) are stripped from the response body. The client continues to get expires_in and refresh_token_expires_in from the response body. This runs before `onSLASPrivateProxyRes`; custom callbacks receive the sanitized response and should read from response headers (e.g. Set-Cookie) rather than the body when using HttpOnly session cookies. An error is thrown if `siteId` is missing in commerce API parameters.
34

45
## v3.16.0 (Feb 12, 2026)
56
- Migrate AWS SDK from v2 to v3 [#3566](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3566)

0 commit comments

Comments
 (0)