Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## [Unreleased]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just to avoid merge conflict when we merge in to develop branch

- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680)

## v5.1.0-dev
- Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)

Expand Down
69 changes: 69 additions & 0 deletions packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1502,3 +1502,72 @@ describe('hybridAuthEnabled property toggles clearECOMSession', () => {
expect(auth.get('dwsid')).toBe('test-dwsid-value')
})
})

describe('HttpOnly Session Cookies', () => {
const expiresAtFuture = Math.floor(Date.now() / 1000) + 3600

const httpOnlyTokenResponse: ShopperLoginTypes.TokenResponse = {
...TOKEN_RESPONSE
}

beforeEach(() => {
jest.clearAllMocks()
})

test('loginGuestUser does not store tokens when HttpOnly cookies are enabled', async () => {
const auth = new Auth({...config, useHttpOnlySessionCookies: true})
const loginGuestMock = helpers.loginGuestUser as jest.Mock
loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse)

// Set cc-at-expires cookie (as server would via Set-Cookie header)
// @ts-expect-error private method
auth.set('cc-at-expires', String(expiresAtFuture))

await auth.loginGuestUser()

// Tokens should NOT be stored in localStorage (they're in HttpOnly cookies)
expect(auth.get('access_token')).toBeFalsy()
expect(auth.get('refresh_token_guest')).toBeFalsy()
// Common fields should still be stored
expect(auth.get('customer_id')).toBe(TOKEN_RESPONSE.customer_id)
expect(auth.get('usid')).toBe(TOKEN_RESPONSE.usid)
})

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

// First call: triggers loginGuestUser
await auth.ready()

// Set cc-at-expires cookie (as server would via Set-Cookie header)
// @ts-expect-error private method
auth.set('cc-at-expires', String(expiresAtFuture))

expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1)

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

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

// Simulate a previous login that left behind stored data with an expired token
const expiredTime = Math.floor(Date.now() / 1000) - 100
// @ts-expect-error private method
auth.set('cc-at-expires', String(expiredTime))
// @ts-expect-error private method
auth.set('refresh_token_guest', 'refresh_token')
// @ts-expect-error private method
auth.set('customer_type', 'guest')
// Set a valid JWT so parseSlasJWT works during the refresh flow
// @ts-expect-error private method
auth.set('access_token', JWTExpired)

await auth.ready()
expect(helpers.refreshAccessToken).toHaveBeenCalled()
})
})
61 changes: 44 additions & 17 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ interface AuthConfig extends ApiClientConfigParams {
refreshTokenRegisteredCookieTTL?: number
refreshTokenGuestCookieTTL?: number
hybridAuthEnabled?: boolean
/** When true, session tokens are set as HttpOnly cookies */
useHttpOnlySessionCookies?: boolean
}

interface JWTHeaders {
Expand Down Expand Up @@ -136,6 +138,7 @@ type AuthDataKeys =
| 'uido'
| 'idp_refresh_token'
| 'dnt'
| 'cc-at-expires'

type AuthDataMap = Record<
AuthDataKeys,
Expand Down Expand Up @@ -250,6 +253,10 @@ const DATA_MAP: AuthDataMap = {
uido: {
storageType: 'local',
key: 'uido'
},
'cc-at-expires': {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must validate if cc-at-expires cookies is also set by Hybrid Auth in ECOM whenever a token is generated by hybrid auth.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reminding. We had a discussion with ECOM and we are aligned on the cookies

storageType: 'cookie',
key: 'cc-at-expires'
}
}

Expand Down Expand Up @@ -284,6 +291,7 @@ class Auth {
| undefined

private hybridAuthEnabled: boolean
private useHttpOnlySessionCookies: boolean

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

this.hybridAuthEnabled = config.hybridAuthEnabled || false
this.useHttpOnlySessionCookies = config.useHttpOnlySessionCookies ?? false
}

get(name: AuthDataKeys) {
Expand Down Expand Up @@ -517,6 +526,23 @@ class Auth {
return validTimeSeconds <= tokenAgeSeconds
}

/**
* Returns whether the access token is expired. When useHttpOnlySessionCookies is true,
* uses cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken().
*/
private isAccessTokenExpired(): boolean {
if (this.useHttpOnlySessionCookies) {
const expiresAt = this.get('cc-at-expires')
if (expiresAt == null || expiresAt === '') return true
const expiresAtSec = Number(expiresAt)
if (Number.isNaN(expiresAtSec)) return true
const bufferSeconds = 60
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of bufferSeconds is to avoid race conditions where a token is technically still valid when the check happens, but expires by the time the SCAPI request reaches the server. By refreshing 60 seconds early, you ensure the token is still valid when the downstream API processes the request.

The original code has the same pattern

return Date.now() / 1000 >= expiresAtSec - bufferSeconds
}
const token = this.getAccessToken()
return !token || this.isTokenExpired(token)
}

/**
* Returns the SLAS access token or an empty string if the access token
* is not found in local store or if SFRA wants PWA to trigger refresh token login.
Expand Down Expand Up @@ -680,33 +706,35 @@ class Auth {
private handleTokenResponse(res: TokenResponse, isGuest: boolean) {
// Delete the SFRA auth token cookie if it exists
this.clearSFRAAuthToken()
this.set('access_token', res.access_token)

this.set('customer_id', res.customer_id)
this.set('enc_user_id', res.enc_user_id)
this.set('expires_in', `${res.expires_in}`)
this.set('id_token', res.id_token)
this.set('idp_access_token', res.idp_access_token)
this.set('token_type', res.token_type)
this.set('customer_type', isGuest ? 'guest' : 'registered')

const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue(
res.refresh_token_expires_in,
isGuest
)
if (res.access_token) {
const {uido} = this.parseSlasJWT(res.access_token)
this.set('uido', uido)
}
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
this.set('refresh_token_expires_in', refreshTokenTTLValue.toString())
this.set(refreshTokenKey, res.refresh_token, {
expires: expiresDate
})
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
this.set('usid', res.usid ?? '', {expires: expiresDate})

this.set('usid', res.usid, {
expires: expiresDate
})
if (this.useHttpOnlySessionCookies) {
const uidoFromCookie = this.stores['cookie'].get('uido')
if (uidoFromCookie) this.set('uido', uidoFromCookie)
} else {
this.set('access_token', res.access_token)
this.set('idp_access_token', res.idp_access_token)
if (res.access_token) {
const {uido} = this.parseSlasJWT(res.access_token)
this.set('uido', uido)
}
const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
this.set(refreshTokenKey, res.refresh_token, {expires: expiresDate})
}
}

async refreshAccessToken() {
Expand Down Expand Up @@ -747,7 +775,7 @@ class Auth {

// refresh flow for TAOB
const accessToken = this.getAccessToken()
if (accessToken && this.isTokenExpired(accessToken)) {
if (this.isAccessTokenExpired()) {
try {
const {isGuest, usid, loginId, isAgent} = this.parseSlasJWT(accessToken)
if (isAgent) {
Expand Down Expand Up @@ -888,8 +916,7 @@ class Auth {
return await this.pendingToken
}

const accessToken = this.getAccessToken()
if (accessToken && !this.isTokenExpired(accessToken)) {
if (!this.isAccessTokenExpired()) {
return this.data
}

Expand Down
60 changes: 60 additions & 0 deletions packages/commerce-sdk-react/src/provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,64 @@ describe('provider', () => {
const authInstance = (Auth as jest.Mock).mock.instances[0]
expect(authInstance.ready).toHaveBeenCalledTimes(1)
})

test('passes useHttpOnlySessionCookies to Auth constructor', () => {
renderWithProviders(<h1>HttpOnly cookies enabled!</h1>, {
useHttpOnlySessionCookies: true
})
expect(screen.getByText('HttpOnly cookies enabled!')).toBeInTheDocument()
expect(Auth).toHaveBeenCalledTimes(1)
expect(Auth).toHaveBeenCalledWith(
expect.objectContaining({
useHttpOnlySessionCookies: true
})
)
})

test('defaults fetchOptions.credentials to same-origin when useHttpOnlySessionCookies is true', () => {
renderWithProviders(<h1>test</h1>, {
useHttpOnlySessionCookies: true
})
expect(Auth).toHaveBeenCalledWith(
expect.objectContaining({
fetchOptions: expect.objectContaining({credentials: 'same-origin'})
})
)
})

test('overrides fetchOptions.credentials from omit to same-origin when useHttpOnlySessionCookies is true', () => {
renderWithProviders(<h1>test</h1>, {
useHttpOnlySessionCookies: true,
fetchOptions: {credentials: 'omit'}
})
expect(Auth).toHaveBeenCalledWith(
expect.objectContaining({
fetchOptions: expect.objectContaining({credentials: 'same-origin'})
})
)
})

test('keeps fetchOptions.credentials as include when useHttpOnlySessionCookies is true', () => {
renderWithProviders(<h1>test</h1>, {
useHttpOnlySessionCookies: true,
fetchOptions: {credentials: 'include'}
})
expect(Auth).toHaveBeenCalledWith(
expect.objectContaining({
fetchOptions: expect.objectContaining({credentials: 'include'})
})
)
})

test('does not modify fetchOptions.credentials when useHttpOnlySessionCookies is false', () => {
renderWithProviders(<h1>test</h1>, {
useHttpOnlySessionCookies: false,
fetchOptions: {credentials: 'omit'}
})
expect(Auth).toHaveBeenCalledWith(
expect.objectContaining({
fetchOptions: expect.objectContaining({credentials: 'omit'})
})
)
})
})
29 changes: 21 additions & 8 deletions packages/commerce-sdk-react/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
apiClients?: ApiClients
disableAuthInit?: boolean
hybridAuthEnabled?: boolean
/** When true, proxy returns tokens in HttpOnly cookies. */
useHttpOnlySessionCookies?: boolean
}

/**
Expand Down Expand Up @@ -145,12 +147,21 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
refreshTokenGuestCookieTTL,
apiClients,
disableAuthInit = false,
hybridAuthEnabled = false
hybridAuthEnabled = false,
useHttpOnlySessionCookies = false
} = props

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

// When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent.
const effectiveFetchOptions = useMemo(() => {
return useHttpOnlySessionCookies &&
(!fetchOptions?.credentials || fetchOptions.credentials === 'omit')
? {...fetchOptions, credentials: 'same-origin' as RequestCredentials}
: fetchOptions
}, [useHttpOnlySessionCookies, fetchOptions])

const auth = useMemo(() => {
return new Auth({
clientId,
Expand All @@ -160,7 +171,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
proxy,
redirectURI,
headers,
fetchOptions,
fetchOptions: effectiveFetchOptions,
fetchedToken,
enablePWAKitPrivateClient,
privateClientProxyEndpoint,
Expand All @@ -171,7 +182,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
passwordlessLoginCallbackURI,
refreshTokenRegisteredCookieTTL,
refreshTokenGuestCookieTTL,
hybridAuthEnabled
hybridAuthEnabled,
useHttpOnlySessionCookies
})
}, [
clientId,
Expand All @@ -181,7 +193,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
proxy,
redirectURI,
headers,
fetchOptions,
effectiveFetchOptions,
fetchedToken,
enablePWAKitPrivateClient,
privateClientProxyEndpoint,
Expand All @@ -193,7 +205,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
refreshTokenRegisteredCookieTTL,
refreshTokenGuestCookieTTL,
apiClients,
hybridAuthEnabled
hybridAuthEnabled,
useHttpOnlySessionCookies
])

const dwsid = auth.get(DWSID_COOKIE_NAME)
Expand All @@ -212,7 +225,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
throwOnBadResponse: true,
fetchOptions: {
...options.fetchOptions,
...fetchOptions
...effectiveFetchOptions
}
}
}
Expand Down Expand Up @@ -252,7 +265,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
currency
},
throwOnBadResponse: true,
fetchOptions
fetchOptions: effectiveFetchOptions
}

return {
Expand All @@ -279,7 +292,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
shortCode,
siteId,
proxy,
fetchOptions,
effectiveFetchOptions,
locale,
currency,
headers?.['correlation-id'],
Expand Down
3 changes: 3 additions & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## [Unreleased]
- Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635)

## v3.17.0-dev
- Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
- Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,8 @@ module.exports = {
// Additional parameters that configure Express app behavior.
ssrParameters: {
ssrFunctionNodeVersion: '24.x',
// Store the session cookies as HttpOnly for enhanced security.
disableHttpOnlySessionCookies: false,
// Store the session cookies as HttpOnly for enhanced security.
disableHttpOnlySessionCookies: false,
proxyConfigs: [
{
host: '{{answers.project.commerce.shortCode}}.api.commercecloud.salesforce.com',
Expand Down
Loading
Loading