Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
254ee2e
Apply configurable domain to cookies created by slas proxy
vcua-mobify May 15, 2026
3184639
Lint
vcua-mobify May 20, 2026
739de37
Merge branch 'develop' into vc/http-only-configurable-cookie-domain
vcua-mobify May 20, 2026
5e96d76
Merge remote-tracking branch 'origin/develop' into vc/http-only-store…
vcua-mobify May 22, 2026
8b03ac4
Add plumbing for supporting Storefront Preview with HttpOnly cookies
vcua-mobify May 22, 2026
b6b1782
Apply review adjustments
vcua-mobify May 22, 2026
30ad079
Add dynamic SameSite for Storefront Preview
vcua-mobify May 22, 2026
5172b0e
Update CHANGELOG.md
vcua-mobify May 22, 2026
a2e536a
Merge branch 'develop' into vc/http-only-storefront-preview
vcua-mobify May 25, 2026
e561730
Merge branch 'vc/http-only-storefront-preview' into vc/http-only-stor…
vcua-mobify May 25, 2026
c6d7857
Cleanup host scoped marker cookie if cookieDomain is set
vcua-mobify May 25, 2026
a984a81
Address comments and apply suggestions
vcua-mobify May 25, 2026
4c529ff
Merge branch 'vc/http-only-storefront-preview' into vc/http-only-stor…
vcua-mobify May 25, 2026
703cd9b
Apply suggestions from PR 1
vcua-mobify May 25, 2026
1fa047d
Update CSP to allow non-prod preview
vcua-mobify May 26, 2026
0ce3dee
Merge branch 'vc/http-only-storefront-preview' into vc/http-only-stor…
vcua-mobify May 26, 2026
fd9b03e
Merge branch 'develop' into vc/http-only-storefront-preview
vcua-mobify May 26, 2026
97e82af
Merge branch 'vc/http-only-storefront-preview' into vc/http-only-stor…
vcua-mobify May 26, 2026
580b9e0
Add soak and dev MRT to preview trusted list
vcua-mobify May 27, 2026
559174a
Merge branch 'vc/http-only-storefront-preview' into vc/http-only-stor…
vcua-mobify May 27, 2026
4eb2a23
Merge branch 'develop' into vc/http-only-storefront-preview
vcua-mobify May 27, 2026
a48aa5c
Update CHANGELOG.md
vcua-mobify May 27, 2026
177fd0e
Merge branch 'vc/http-only-storefront-preview' of github.com:Salesfor…
vcua-mobify May 27, 2026
da41a7d
Merge branch 'vc/http-only-storefront-preview' into vc/http-only-stor…
vcua-mobify May 27, 2026
6768444
Merge remote-tracking branch 'origin/develop' into vc/http-only-store…
vcua-mobify May 27, 2026
570653a
Merge remote-tracking branch 'origin/develop' into vc/http-only-bff-b…
vcua-mobify May 27, 2026
aafc140
Allow for token passthrough if bearer token is valid
vcua-mobify May 27, 2026
5d9d287
Update CHANGELOG.md
vcua-mobify May 27, 2026
11a64b8
Remove empty if block
vcua-mobify May 28, 2026
fc7b7a2
Run e2e-pr in Playwright container to unblock PRs
shethj May 29, 2026
cdd91c1
e2e-pr: install GNU time in Playwright container
shethj May 29, 2026
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
29 changes: 23 additions & 6 deletions .github/workflows/e2e-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ permissions:
jobs:
test_e2e_private_client:
runs-on: ubuntu-latest
# Run inside the official Playwright Docker image, which has Chromium
# and all OS deps pre-installed at /ms-playwright. This avoids the
# `npx playwright install --with-deps` step, whose Chromium download
# from cdn.playwright.dev was stalling indefinitely and blocking PRs.
# The image is built from Apache-2.0 source (github.com/microsoft/playwright);
# tag must match the @playwright/test version resolved in package-lock.json.
container:
image: mcr.microsoft.com/playwright:v1.57.0-noble
env:
AWS_S3_BUCKET: ${{ vars.AWS_S3_BUCKET }}
AWS_REGION: ${{ vars.AWS_REGION }}
Expand All @@ -63,6 +71,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

# The Playwright container ships Node 24, git, and the browsers,
# but not jq or GNU time. jq is used by several steps below
# and by composite actions under .github/actions/. GNU time
# (/usr/bin/time) is required by scripts/gtime.js which wraps
# npm ci for Datadog telemetry; without it gtime.js crashes
# at stderr.toString() because spawnSync returns stderr=undefined
# when the binary is missing.
- name: Install jq and GNU time
run: |-
apt-get update
apt-get install -y --no-install-recommends jq time
rm -rf /var/lib/apt/lists/*

- name: Check PWA Kit Version
run: |-
version=`jq -r ".version" package.json`
Expand Down Expand Up @@ -202,12 +223,8 @@ jobs:
CLOUD_ORIGIN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mrt_admin_cloud_origin || vars.MRT_STG_CLOUD_ORIGIN }}
FLAGS: --wait

# Only install chromium since we're only testing for chrome and chrome mobile for per-pr test runs.
# Nightly tests will run against multiple browsers.
- name: Install Playwright Browsers
if: ${{ env.SKIP_WORKFLOW != 'true' && !github.event.inputs.skip_tests }}
run: npx playwright install chromium --with-deps

# Browsers are pre-installed in the mcr.microsoft.com/playwright container
# at /ms-playwright, so no `playwright install` step is needed here.
# Run all 4 playwright projects in parallel to reduce run time.
# Number of workers must match number of projects for parallel runs so we have it set to 4.
# Limit the number of workers to 2x the number of cores on the machine to avoid overloading the machine (Github-hosted runner).
Expand Down
1 change: 1 addition & 0 deletions packages/pwa-kit-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- HttpOnly session cookies: also strip `idp_access_token_{siteId}` from upstream proxy requests to SLAS
- Storefront Preview iframe (1/2 — foundations, no behavior change): the BFF now sets a server-only `__Host-pwakit_preview_ctx` marker cookie (`Path=/; Secure; HttpOnly; SameSite=None; Partitioned`) when the incoming request is an iframe document load from a trusted Runtime Admin parent (validated via `Sec-Fetch-Dest`/`Sec-Fetch-Site`/`Referer`, accepting both `cross-site` and `same-site` so non-prod RA testing works). PR 2/2 will read this marker on SLAS proxy responses to issue session cookies as `SameSite=None; Partitioned` instead of `Lax` so they attach on cross-site iframe fetches. Also adds `Partitioned` (CHIPS) support to the BFF cookie serializer. The default CSP `frame-ancestors`/`connect-src`/`script-src` directives now include the staging and preview Runtime Admin hosts in addition to the production host, so Storefront Preview against non-prod environments is no longer blocked at the browser layer. [#3850](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3850)
- Storefront Preview iframe (2/2 — fix): the SLAS proxy response handler now reads the `__Host-pwakit_preview_ctx` marker (set by the middleware in 1/2) and, when the marker validates against the trusted-parent allow-list, issues every session cookie (`cc-at`, `cc-nx-g`, `cc-nx`, `idp_access_token`, `idp_refresh_token`, and the indicator cookies) with `SameSite=None; Partitioned` instead of `SameSite=Lax`. Top-level (non-preview) traffic continues to receive `SameSite=Lax` — no regression for the common path. Logout (`expireHttpOnlySessionCookies`) also clears the marker cookie itself so iframe-context state doesn't outlive the SLAS session. Resolves "Failed to receive a response to 'requestUsid' within the 10000ms timeout" / `access_token_cookie_missing` 400s when the storefront is loaded inside a trusted Runtime Admin preview iframe. [#3851](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3851)
- HttpOnly session cookies: SCAPI proxy now narrows its `Authorization` replacement to non-Bearer cases. A valid `Bearer <jwt>` (case-insensitive scheme, non-empty value, `\s+`-separated) is passed through to SCAPI unchanged so the SDK's SSR-time bearer (obtained via `commerce-sdk-isomorphic@5.2.0`'s SSR-aware SLAS helpers) is no longer overwritten by a stale or empty cookie. Cookie-derived bearer is still injected when the incoming header is empty `Bearer `, `Basic` (Protected Storefronts), or absent. Closes the cold-tab 401 and the expired-token-on-hard-refresh 401 on PLP/PDP routes when `MRT_ENABLE_HTTPONLY_SESSION_COOKIES=true`. The same narrowing must be applied to MRT's CloudFront Lambda@Edge for the production fix to take effect.
- **Bug Fix**: Fixed signature mismatch between client and server versions of `getCustomSitePreferences` and `getCustomGlobalPreferences`. Both functions are now async and accept the same parameters for consistency. Client version accepts but ignores `siteId` parameter. This enables proper usage with React Query and SSR prepass patterns. [#3811](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3811)

## v3.18.1 (May 21, 2026)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,181 @@ describe('setScapiAuthRequestHeaders', () => {
expect(proxyRequest.setHeader).not.toHaveBeenCalledWith('authorization', expect.any(String))
})

it('preserves a valid Bearer header when access token cookie is also present', () => {
// SDK during SSR sets `Authorization: Bearer <jwt>` after fetching a
// fresh token from SLAS. The proxy must not overwrite it with a stale
// cookie value. This is the load-bearing assertion for both the
// cold-tab 401 and the expired-token 401 fixes.
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({'cc-at_RefArch': 'stale-cookie-token'})

const proxyRequest = {
setHeader: jest.fn(),
removeHeader: jest.fn()
}
const incomingRequest = {
url: '/shopper/products/v1/products',
headers: {
cookie: 'cc-at_RefArch=stale-cookie-token',
authorization: 'Bearer fresh-sdk-token',
[X_SITE_ID]: 'RefArch'
}
}

setScapiAuthRequestHeaders({
proxyRequest,
incomingRequest,
caching: false,
targetHost: 'abc-001.api.commercecloud.salesforce.com'
})

expect(proxyRequest.setHeader).not.toHaveBeenCalledWith('authorization', expect.any(String))
})

it('replaces empty `Bearer ` (no value) with cookie-derived Bearer', () => {
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({'cc-at_RefArch': 'cookie-token'})

const proxyRequest = {
setHeader: jest.fn(),
removeHeader: jest.fn()
}
const incomingRequest = {
url: '/shopper/products/v1/products',
headers: {
cookie: 'cc-at_RefArch=cookie-token',
authorization: 'Bearer ',
[X_SITE_ID]: 'RefArch'
}
}

setScapiAuthRequestHeaders({
proxyRequest,
incomingRequest,
caching: false,
targetHost: 'abc-001.api.commercecloud.salesforce.com'
})

expect(proxyRequest.setHeader).toHaveBeenCalledWith('authorization', 'Bearer cookie-token')
})

it('replaces Basic auth header with cookie-derived Bearer (Protected Storefronts)', () => {
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({'cc-at_RefArch': 'cookie-token'})

const proxyRequest = {
setHeader: jest.fn(),
removeHeader: jest.fn()
}
const incomingRequest = {
url: '/shopper/products/v1/products',
headers: {
cookie: 'cc-at_RefArch=cookie-token',
authorization: 'Basic dXNlcjpwYXNzd29yZA==',
[X_SITE_ID]: 'RefArch'
}
}

setScapiAuthRequestHeaders({
proxyRequest,
incomingRequest,
caching: false,
targetHost: 'abc-001.api.commercecloud.salesforce.com'
})

expect(proxyRequest.setHeader).toHaveBeenCalledWith('authorization', 'Bearer cookie-token')
})

it('passes Basic auth header through unchanged when no cookie is present', () => {
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({})

const proxyRequest = {
setHeader: jest.fn(),
removeHeader: jest.fn()
}
const incomingRequest = {
url: '/shopper/products/v1/products',
headers: {
cookie: 'some-other-cookie=value',
authorization: 'Basic dXNlcjpwYXNzd29yZA==',
[X_SITE_ID]: 'RefArch'
}
}

expect(() =>
setScapiAuthRequestHeaders({
proxyRequest,
incomingRequest,
caching: false,
targetHost: 'abc-001.api.commercecloud.salesforce.com'
})
).not.toThrow()

expect(proxyRequest.setHeader).not.toHaveBeenCalledWith('authorization', expect.any(String))
})

it('matches the Bearer scheme case-insensitively', () => {
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({'cc-at_RefArch': 'cookie-token'})

const variations = ['Bearer fresh-token', 'bearer fresh-token', 'BEARER fresh-token']

variations.forEach((authValue) => {
const proxyRequest = {
setHeader: jest.fn(),
removeHeader: jest.fn()
}
const incomingRequest = {
url: '/shopper/products/v1/products',
headers: {
cookie: 'cc-at_RefArch=cookie-token',
authorization: authValue,
[X_SITE_ID]: 'RefArch'
}
}

setScapiAuthRequestHeaders({
proxyRequest,
incomingRequest,
caching: false,
targetHost: 'abc-001.api.commercecloud.salesforce.com'
})

expect(proxyRequest.setHeader).not.toHaveBeenCalledWith(
'authorization',
expect.any(String)
)
})
})

it('preserves a Bearer header when scheme and token are tab-separated', () => {
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({'cc-at_RefArch': 'cookie-token'})

const proxyRequest = {
setHeader: jest.fn(),
removeHeader: jest.fn()
}
const incomingRequest = {
url: '/shopper/products/v1/products',
headers: {
cookie: 'cc-at_RefArch=cookie-token',
authorization: 'Bearer\tfresh-sdk-token',
[X_SITE_ID]: 'RefArch'
}
}

setScapiAuthRequestHeaders({
proxyRequest,
incomingRequest,
caching: false,
targetHost: 'abc-001.api.commercecloud.salesforce.com'
})

expect(proxyRequest.setHeader).not.toHaveBeenCalledWith('authorization', expect.any(String))
})

it('uses x-site-id header to resolve correct cookie', () => {
utils.isScapiDomain.mockReturnValue(true)
cookie.parse.mockReturnValue({'cc-at_OtherSite': 'other-access-token'})
Expand Down
69 changes: 48 additions & 21 deletions packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,47 @@ export const ALLOWED_CACHING_PROXY_REQUEST_METHODS = ['HEAD', 'GET', 'OPTIONS']
const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/

/**
* Apply the Authorization header with the shopper's access token (Bearer token) to a proxy request.
* Apply the Authorization header for an SCAPI proxy request.
*
* This function is intended to be called from within a proxy's onProxyReq method.
* It reads the access token from HttpOnly cookies and sets it as the Authorization header
* for applicable SCAPI endpoints.
* Intended to be called from within a proxy's onProxyReq method. Decides
* what Authorization to forward to SCAPI based on the incoming header and
* the HttpOnly access-token cookie.
*
* Logic for determining if Bearer token should be applied:
* 1. Caching proxies never use auth (skip)
* 2. x-site-id header must be present (skip if not)
* 3. Target must be SCAPI domain (skip if not)
* Skipped entirely when:
* 1. caching is true (caching proxies never use auth);
* 2. the target is not an SCAPI domain;
* 3. the x-site-id header is missing (logged as a warning).
*
* Otherwise the Authorization precedence is:
*
* 1. Incoming `Authorization: Bearer <jwt>` — passed through unchanged.
* The caller (typically the SDK during SSR after a fresh SLAS token
* fetch) is trusted to have set a valid bearer. Scheme match is
* case-insensitive and requires at least one non-whitespace character
* after the scheme.
*
* 2. Access-token cookie present — set `Authorization: Bearer <cookie>`,
* overwriting whatever was there. Covers:
* - no incoming Authorization (client-side navigation in HttpOnly
* mode, where JS can't read the cookie);
* - empty `Bearer ` (no value);
* - `Basic <…>` (the Protected Storefronts pattern — swap Basic
* page-level credentials for the JWT bearer SCAPI expects).
*
* 3. No cookie and no incoming Authorization — throw
* {@link AccessTokenNotFoundError}.
*
* 4. No cookie but a non-valid Bearer incoming Authorization (Basic, custom,
* etc.) — pass through unchanged. SCAPI will reject it; we don't
* actively rewrite it.
*
* @private
* @function
* @param proxyRequest {http.ClientRequest} the request that will be sent to the target host
* @param incomingRequest {http.IncomingMessage} the request made to this Express app
* @param caching {Boolean} true for a caching proxy, false for a standard proxy
* @param targetHost {String} the target hostname (host+port)
*/
/**
* @throws {AccessTokenNotFoundError} If this is an SCAPI request and the access token cookie is missing.
* @throws {AccessTokenNotFoundError} when this is an SCAPI request with no access-token cookie and no incoming Authorization.
*/
export const setScapiAuthRequestHeaders = ({
proxyRequest,
Expand Down Expand Up @@ -93,20 +114,26 @@ export const setScapiAuthRequestHeaders = ({
const tokenKey = getCookieName(SESSION_COOKIE_CONFIG.accessToken, resolvedSiteId)
const accessToken = cookies[tokenKey]

if (!accessToken) {
// During SSR, the SDK sets the Authorization header directly (onClient() is false),
// so the cookie won't be present on the server-side loopback request. Only throw
// when there is no existing Authorization header — meaning the client relied on
// the proxy to inject it from the cookie, but the cookie is missing.
const hasExistingAuth = incomingRequest.headers.authorization
if (!hasExistingAuth) {
const existingAuth = incomingRequest.headers.authorization
// A `Bearer <token>` header (case-insensitive scheme, requires at least
// one non-whitespace character after the space) is treated as already
// authenticated. An empty `Bearer ` falls through to the cookie path.
const isBearerWithValue = /^bearer\s+\S/i.test(existingAuth || '')

// The caller — typically the SDK during SSR after obtaining a fresh
// token from SLAS — has already set a valid Bearer. Pass it through
// to SCAPI unchanged. If not, we need to inject the cookie-derived JWT.
if (!isBearerWithValue) {
if (accessToken) {
// No incoming Authorization, an empty `Bearer `, or `Basic <…>` (the
// Protected Storefronts pattern). Inject the cookie-derived JWT.
proxyRequest.setHeader('authorization', `Bearer ${accessToken}`)
} else if (!existingAuth) {
// No cookie and no incoming auth — nothing to forward.
throw new AccessTokenNotFoundError(
'Access token cookie not found. Cannot proceed with SCAPI request.'
)
}
} else {
// Cookie-based auth takes precedence over any existing header
proxyRequest.setHeader('authorization', `Bearer ${accessToken}`)
}

// Transform dwsid cookie into sfdc_dwsid header (same as MRT)
Expand Down
Loading