@salesforce/pwa-kit-runtime 3.19.0-preview.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/ssr/server/build-remote-server.js +10 -0
- package/ssr/server/build-remote-server.test.js +46 -0
- package/ssr/server/constants.js +7 -1
- package/ssr/server/httponly-cookie-config.js +5 -4
- package/ssr/server/preview-context.js +40 -0
- package/ssr/server/preview-context.test.js +80 -0
- package/ssr/server/process-token-response.js +55 -23
- package/ssr/server/process-token-response.test.js +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/pwa-kit-runtime",
|
|
3
|
-
"version": "3.19.0
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "The PWAKit Runtime",
|
|
5
5
|
"homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/pwa-kit-runtime#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@loadable/component": "^5.15.3",
|
|
51
|
-
"@salesforce/pwa-kit-dev": "3.19.0
|
|
51
|
+
"@salesforce/pwa-kit-dev": "3.19.0",
|
|
52
52
|
"@serverless/event-mocks": "^1.1.1",
|
|
53
53
|
"aws-lambda-mock-context": "^3.2.1",
|
|
54
54
|
"fs-extra": "^11.1.1",
|
|
55
|
-
"internal-lib-build": "3.19.0
|
|
55
|
+
"internal-lib-build": "3.19.0",
|
|
56
56
|
"nock": "^13.3.0",
|
|
57
57
|
"nodemon": "^2.0.22",
|
|
58
58
|
"sinon": "^13.0.2",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"publishConfig": {
|
|
67
67
|
"directory": "dist"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "5192e11826187be0dbdacf4754c84ac18af64f67"
|
|
70
70
|
}
|
|
@@ -1197,6 +1197,11 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
|
|
|
1197
1197
|
targetProtocol: 'https'
|
|
1198
1198
|
});
|
|
1199
1199
|
|
|
1200
|
+
// Internal preview-context signal consumed by the response
|
|
1201
|
+
// interceptor (process-token-response); never forwarded to
|
|
1202
|
+
// SLAS, regardless of the HttpOnly session-cookie flag.
|
|
1203
|
+
proxyRequest.removeHeader(_constants.X_PREVIEW_PARENT);
|
|
1204
|
+
|
|
1200
1205
|
// Credential mode is taken from the allow-list entry
|
|
1201
1206
|
// attached by the pre-proxy guard.
|
|
1202
1207
|
const {
|
|
@@ -1369,6 +1374,11 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
|
|
|
1369
1374
|
targetHost: options.slasHostName,
|
|
1370
1375
|
targetProtocol: 'https'
|
|
1371
1376
|
});
|
|
1377
|
+
|
|
1378
|
+
// Internal preview-context signal consumed by the response
|
|
1379
|
+
// interceptor (process-token-response); never forwarded to
|
|
1380
|
+
// SLAS, regardless of the HttpOnly session-cookie flag.
|
|
1381
|
+
proxyRequest.removeHeader(_constants.X_PREVIEW_PARENT);
|
|
1372
1382
|
if (httpOnlyCookiesEnabled) {
|
|
1373
1383
|
handleHttpOnlyCookiesOnProxyReq(proxyRequest, incomingRequest);
|
|
1374
1384
|
}
|
|
@@ -888,6 +888,52 @@ describe('HttpOnly session cookies', () => {
|
|
|
888
888
|
mockSlasServerInstance.close();
|
|
889
889
|
}
|
|
890
890
|
}));
|
|
891
|
+
test('strips x-pwakit-preview-parent from the upstream request even when HttpOnly is disabled', /*#__PURE__*/_asyncToGenerator(function* () {
|
|
892
|
+
// The header is an internal preview-context signal consumed by the
|
|
893
|
+
// response interceptor; it must never reach SLAS, regardless of the
|
|
894
|
+
// HttpOnly session-cookie flag (the private proxy is wired whenever
|
|
895
|
+
// useSLASPrivateClient is true).
|
|
896
|
+
delete process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES;
|
|
897
|
+
let capturedPreviewParent = 'sentinel';
|
|
898
|
+
const mockSlasServer = mockExpress();
|
|
899
|
+
mockSlasServer.post('/shopper/auth/v1/organizations/f_ecom_test/oauth2/token', (req, res) => {
|
|
900
|
+
capturedPreviewParent = req.headers[_constants.X_PREVIEW_PARENT];
|
|
901
|
+
res.status(200).json({
|
|
902
|
+
access_token: 'mock-token',
|
|
903
|
+
expires_in: 1800,
|
|
904
|
+
refresh_token: 'mock-refresh-token'
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
const mockSlasServerInstance = mockSlasServer.listen(0);
|
|
908
|
+
const mockSlasPort = mockSlasServerInstance.address().port;
|
|
909
|
+
try {
|
|
910
|
+
const app = mockExpress();
|
|
911
|
+
const options = _buildRemoteServer.RemoteServerFactory._configure({
|
|
912
|
+
useSLASPrivateClient: true,
|
|
913
|
+
slasTarget: `http://localhost:${mockSlasPort}`,
|
|
914
|
+
mobify: {
|
|
915
|
+
app: {
|
|
916
|
+
commerceAPI: {
|
|
917
|
+
parameters: {
|
|
918
|
+
shortCode: 'test',
|
|
919
|
+
organizationId: 'f_ecom_test',
|
|
920
|
+
clientId: 'test-client-id',
|
|
921
|
+
siteId: 'testsite'
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret';
|
|
928
|
+
_buildRemoteServer.RemoteServerFactory._setupSlasPrivateClientProxy(app, options, false);
|
|
929
|
+
const response = yield request(app).post('/mobify/slas/private/shopper/auth/v1/organizations/f_ecom_test/oauth2/token').set(_constants.X_PREVIEW_PARENT, 'https://runtime.commercecloud.com');
|
|
930
|
+
expect(response.status).toBe(200);
|
|
931
|
+
// Upstream SLAS must not see the internal preview-context header.
|
|
932
|
+
expect(capturedPreviewParent).toBeUndefined();
|
|
933
|
+
} finally {
|
|
934
|
+
mockSlasServerInstance.close();
|
|
935
|
+
}
|
|
936
|
+
}));
|
|
891
937
|
test('returns 500 when siteId is missing', /*#__PURE__*/_asyncToGenerator(function* () {
|
|
892
938
|
process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES = 'true';
|
|
893
939
|
const mockSlasServer = mockExpress();
|
package/ssr/server/constants.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.X_SITE_ID = exports.X_ORIGINAL_CONTENT_TYPE = exports.X_MOBIFY_QUERYSTRING = exports.X_MOBIFY_FROM_CACHE = exports.X_GRANT_TYPE = exports.X_ENCODED_HEADERS = exports.STRICT_TRANSPORT_SECURITY = exports.STOREFRONT_PREVIEW_PARENT_ALLOW_LIST = exports.STOREFRONT_PREVIEW_CTX_COOKIE = exports.STATIC_ASSETS = exports.SLAS_TOKEN_RESPONSE_ENDPOINTS = exports.SLAS_LOGOUT_ENDPOINT = exports.SLAS_CUSTOM_PROXY_PATH = exports.SET_COOKIE = exports.PROXY_PATH_PREFIX = exports.NO_CACHE = exports.DWSID_COOKIE_NAME = exports.CONTENT_TYPE = exports.CONTENT_SECURITY_POLICY = exports.CONTENT_ENCODING = exports.CACHE_CONTROL = exports.BUILD = exports.APPLICATION_OCTET_STREAM = void 0;
|
|
6
|
+
exports.X_SITE_ID = exports.X_PREVIEW_PARENT = exports.X_ORIGINAL_CONTENT_TYPE = exports.X_MOBIFY_QUERYSTRING = exports.X_MOBIFY_FROM_CACHE = exports.X_GRANT_TYPE = exports.X_ENCODED_HEADERS = exports.STRICT_TRANSPORT_SECURITY = exports.STOREFRONT_PREVIEW_PARENT_ALLOW_LIST = exports.STOREFRONT_PREVIEW_CTX_COOKIE = exports.STATIC_ASSETS = exports.SLAS_TOKEN_RESPONSE_ENDPOINTS = exports.SLAS_LOGOUT_ENDPOINT = exports.SLAS_CUSTOM_PROXY_PATH = exports.SET_COOKIE = exports.PROXY_PATH_PREFIX = exports.NO_CACHE = exports.DWSID_COOKIE_NAME = exports.CONTENT_TYPE = exports.CONTENT_SECURITY_POLICY = exports.CONTENT_ENCODING = exports.CACHE_CONTROL = exports.BUILD = exports.APPLICATION_OCTET_STREAM = void 0;
|
|
7
7
|
/*
|
|
8
8
|
* Copyright (c) 2021, salesforce.com, inc.
|
|
9
9
|
* All rights reserved.
|
|
@@ -47,6 +47,12 @@ const SLAS_LOGOUT_ENDPOINT = exports.SLAS_LOGOUT_ENDPOINT = /\/oauth2\/logout/;
|
|
|
47
47
|
const DWSID_COOKIE_NAME = exports.DWSID_COOKIE_NAME = 'dwsid';
|
|
48
48
|
const X_SITE_ID = exports.X_SITE_ID = 'x-site-id';
|
|
49
49
|
const X_GRANT_TYPE = exports.X_GRANT_TYPE = 'x-grant-type';
|
|
50
|
+
// Client-sent header carrying the trusted Storefront Preview parent origin on
|
|
51
|
+
// SLAS token requests. Read (and re-validated against
|
|
52
|
+
// STOREFRONT_PREVIEW_PARENT_ALLOW_LIST) when choosing SameSite=None; Partitioned
|
|
53
|
+
// for session cookies, as a CDN-cache-proof alternative to the
|
|
54
|
+
// __Host-pwakit_preview_ctx marker cookie. Stripped before forwarding to SLAS/SCAPI.
|
|
55
|
+
const X_PREVIEW_PARENT = exports.X_PREVIEW_PARENT = 'x-pwakit-preview-parent';
|
|
50
56
|
|
|
51
57
|
// Server-only marker cookie set when the storefront is loaded inside a
|
|
52
58
|
// trusted Storefront Preview iframe (parent origin attested by Sec-Fetch-*
|
|
@@ -20,10 +20,11 @@ var _constants = require("./constants");
|
|
|
20
20
|
*
|
|
21
21
|
* `attributes` covers the *static* per-cookie attributes only (`httpOnly`,
|
|
22
22
|
* `secure`, `path`). `SameSite` and `Partitioned` are resolved per-request
|
|
23
|
-
* by `setHttpOnlySessionCookies` based on the Storefront Preview
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `SameSite=
|
|
23
|
+
* by `setHttpOnlySessionCookies` based on the Storefront Preview signal —
|
|
24
|
+
* either the server-set marker cookie or the client-sent
|
|
25
|
+
* `x-pwakit-preview-parent` header (see `isTrustedPreviewRequest` in
|
|
26
|
+
* `preview-context.js`). Top-level traffic gets `SameSite=Lax`; traffic from a
|
|
27
|
+
* validated trusted iframe gets `SameSite=None; Partitioned`.
|
|
27
28
|
*/
|
|
28
29
|
const SESSION_COOKIE_CONFIG = exports.SESSION_COOKIE_CONFIG = {
|
|
29
30
|
accessToken: {
|
|
@@ -4,7 +4,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.clearStorefrontPreviewMarker = clearStorefrontPreviewMarker;
|
|
7
|
+
exports.isTrustedPreviewRequest = isTrustedPreviewRequest;
|
|
7
8
|
exports.readStorefrontPreviewMarker = readStorefrontPreviewMarker;
|
|
9
|
+
exports.readTrustedPreviewParentHeader = readTrustedPreviewParentHeader;
|
|
8
10
|
exports.tryWriteStorefrontPreviewMarker = tryWriteStorefrontPreviewMarker;
|
|
9
11
|
var _ssrProxying = require("../../utils/ssr-proxying");
|
|
10
12
|
var _constants = require("./constants");
|
|
@@ -175,6 +177,44 @@ function readStorefrontPreviewMarker(req) {
|
|
|
175
177
|
return value;
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Returns the validated parent origin from the `x-pwakit-preview-parent`
|
|
182
|
+
* request header, or undefined when the header is absent or carries a value
|
|
183
|
+
* that is not on the trusted allow-list.
|
|
184
|
+
*
|
|
185
|
+
* The client (commerce-sdk-react) sends this header on SLAS token requests
|
|
186
|
+
* when it detects — via `window.location.ancestorOrigins` — that the
|
|
187
|
+
* storefront is running inside a trusted Storefront Preview iframe. Because
|
|
188
|
+
* the token request is a POST that is never served from the CDN cache, this
|
|
189
|
+
* header reliably reaches the origin even when the iframe *document* load
|
|
190
|
+
* (which sets the `__Host-pwakit_preview_ctx` marker cookie) is served from
|
|
191
|
+
* cache and never runs the marker-writer middleware.
|
|
192
|
+
*
|
|
193
|
+
* The value is re-validated here against the same allow-list as the marker
|
|
194
|
+
* cookie. A spoofed header is harmless: the worst case is that a caller
|
|
195
|
+
* receives its own session cookies with SameSite=None; Partitioned (which are
|
|
196
|
+
* partitioned by the caller's top-level site and grant no cross-site access),
|
|
197
|
+
* and only when the asserted origin is itself on the trusted allow-list.
|
|
198
|
+
*/
|
|
199
|
+
function readTrustedPreviewParentHeader(req) {
|
|
200
|
+
var _req$headers5;
|
|
201
|
+
const value = (_req$headers5 = req.headers) === null || _req$headers5 === void 0 ? void 0 : _req$headers5[_constants.X_PREVIEW_PARENT];
|
|
202
|
+
if (!value || !isAllowedParentOrigin(value)) return undefined;
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Returns true when the request originates from a trusted Storefront Preview
|
|
208
|
+
* iframe — either via the server-set marker cookie (iframe document load that
|
|
209
|
+
* reached the origin) or via the client-sent `x-pwakit-preview-parent` header
|
|
210
|
+
* (CDN-cache-proof). Either signal is sufficient; both are re-validated
|
|
211
|
+
* against the trusted allow-list. Used to decide SameSite=None; Partitioned
|
|
212
|
+
* vs. SameSite=Lax for session cookies.
|
|
213
|
+
*/
|
|
214
|
+
function isTrustedPreviewRequest(req) {
|
|
215
|
+
return Boolean(readStorefrontPreviewMarker(req) || readTrustedPreviewParentHeader(req));
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
/**
|
|
179
219
|
* Append a Set-Cookie header to expire the storefront-preview marker
|
|
180
220
|
* cookie. Used on logout (called from `expireHttpOnlySessionCookies`) so
|
|
@@ -189,6 +189,86 @@ describe('readStorefrontPreviewMarker', () => {
|
|
|
189
189
|
expect((0, _previewContext.readStorefrontPreviewMarker)(req)).toBeUndefined();
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
|
+
describe('readTrustedPreviewParentHeader', () => {
|
|
193
|
+
test('returns undefined when the header is absent', () => {
|
|
194
|
+
expect((0, _previewContext.readTrustedPreviewParentHeader)({
|
|
195
|
+
headers: {}
|
|
196
|
+
})).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
test('returns the validated origin when the header value is on the allow-list', () => {
|
|
199
|
+
const req = {
|
|
200
|
+
headers: {
|
|
201
|
+
[_constants.X_PREVIEW_PARENT]: TRUSTED_PARENT
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBe(TRUSTED_PARENT);
|
|
205
|
+
});
|
|
206
|
+
test.each(_constants.STOREFRONT_PREVIEW_PARENT_ALLOW_LIST)('returns the origin for trusted parent %s', origin => {
|
|
207
|
+
const req = {
|
|
208
|
+
headers: {
|
|
209
|
+
[_constants.X_PREVIEW_PARENT]: origin
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBe(origin);
|
|
213
|
+
});
|
|
214
|
+
test('returns undefined when the header value is not on the allow-list', () => {
|
|
215
|
+
const req = {
|
|
216
|
+
headers: {
|
|
217
|
+
[_constants.X_PREVIEW_PARENT]: 'https://evil.example.com'
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
test('returns undefined when the header value is empty', () => {
|
|
223
|
+
const req = {
|
|
224
|
+
headers: {
|
|
225
|
+
[_constants.X_PREVIEW_PARENT]: ''
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('isTrustedPreviewRequest', () => {
|
|
232
|
+
test('true when only the marker cookie is present', () => {
|
|
233
|
+
const req = {
|
|
234
|
+
headers: {
|
|
235
|
+
cookie: `${_constants.STOREFRONT_PREVIEW_CTX_COOKIE}=${TRUSTED_PARENT}`
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
test('true when only the client header is present (CDN cache bypasses the marker)', () => {
|
|
241
|
+
const req = {
|
|
242
|
+
headers: {
|
|
243
|
+
[_constants.X_PREVIEW_PARENT]: TRUSTED_PARENT
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
test('true when both signals are present', () => {
|
|
249
|
+
const req = {
|
|
250
|
+
headers: {
|
|
251
|
+
cookie: `${_constants.STOREFRONT_PREVIEW_CTX_COOKIE}=${TRUSTED_PARENT}`,
|
|
252
|
+
[_constants.X_PREVIEW_PARENT]: TRUSTED_PARENT
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
test('false when neither signal is present', () => {
|
|
258
|
+
expect((0, _previewContext.isTrustedPreviewRequest)({
|
|
259
|
+
headers: {}
|
|
260
|
+
})).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
test('false when both signals carry untrusted values', () => {
|
|
263
|
+
const req = {
|
|
264
|
+
headers: {
|
|
265
|
+
cookie: `${_constants.STOREFRONT_PREVIEW_CTX_COOKIE}=https://evil.example.com`,
|
|
266
|
+
[_constants.X_PREVIEW_PARENT]: 'https://evil.example.com'
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
192
272
|
describe('STOREFRONT_PREVIEW_PARENT_ALLOW_LIST parity', () => {
|
|
193
273
|
// The server-side allow-list (constants.js) MUST mirror IFRAME_HOST_ALLOW_LIST
|
|
194
274
|
// in commerce-sdk-react/src/constant.ts. Drift would cause client and server
|
|
@@ -38,15 +38,17 @@ const PREVIEW_IFRAME_SITE_ATTRS = Object.freeze({
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Resolves the SameSite/Partitioned attributes to apply to all session
|
|
41
|
-
* cookies on this response. When the request
|
|
42
|
-
* Storefront
|
|
43
|
-
* iframe document load
|
|
44
|
-
*
|
|
41
|
+
* cookies on this response. When the request originates from a trusted
|
|
42
|
+
* Storefront Preview iframe — signalled either by the server-set marker
|
|
43
|
+
* cookie (iframe document load that reached the origin) or by the
|
|
44
|
+
* client-sent `x-pwakit-preview-parent` header (CDN-cache-proof, rides on
|
|
45
|
+
* the non-cacheable token POST) — returns `{sameSite: 'none', partitioned:
|
|
46
|
+
* true}` so cookies attach inside the cross-site iframe. Otherwise returns
|
|
45
47
|
* `{sameSite: 'lax'}` (the existing top-level behavior).
|
|
46
48
|
* @private
|
|
47
49
|
*/
|
|
48
50
|
function getSiteAttrsForRequest(req) {
|
|
49
|
-
return (0, _previewContext.
|
|
51
|
+
return (0, _previewContext.isTrustedPreviewRequest)(req) ? PREVIEW_IFRAME_SITE_ATTRS : DEFAULT_SITE_ATTRS;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
// Refresh token cookie TTL defaults (seconds). Must stay in sync with commerce-sdk-react auth constants.
|
|
@@ -55,11 +57,27 @@ const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60;
|
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
59
|
* Returns a function that appends a Set-Cookie header to `res`. When
|
|
58
|
-
* `cookieDomain` is configured, every write
|
|
59
|
-
* Set-Cookie for the same name
|
|
60
|
-
* pre-existing host-scoped cookie
|
|
61
|
-
* `CookieStorage.removeHostAndDomainCookie` in commerce-sdk-react
|
|
62
|
-
* stale duplicates when a merchant first enables the cookieDomain
|
|
60
|
+
* `cookieDomain` is configured, every write first emits an expiring
|
|
61
|
+
* Set-Cookie for the same name WITHOUT a Domain attribute (clearing any
|
|
62
|
+
* pre-existing host-scoped cookie), THEN emits the real Domain-scoped cookie.
|
|
63
|
+
* This mirrors `CookieStorage.removeHostAndDomainCookie` in commerce-sdk-react
|
|
64
|
+
* and prevents stale duplicates when a merchant first enables the cookieDomain
|
|
65
|
+
* config.
|
|
66
|
+
*
|
|
67
|
+
* Order matters for SSR: on a cookieless SSR load, commerce-sdk-isomorphic
|
|
68
|
+
* reconstructs the guest-login TokenResponse from this response's Set-Cookie
|
|
69
|
+
* headers, parsing the array with last-write-wins per cookie name. Emitting the
|
|
70
|
+
* host-scoped deletion (empty value) BEFORE the real cookie ensures the real
|
|
71
|
+
* token value wins instead of being clobbered by the empty deletion —
|
|
72
|
+
* otherwise the SSR shopper token would be empty and data-bearing routes
|
|
73
|
+
* (e.g. a PLP) would 401 (regression of W-23388089). This rests on a cross-repo
|
|
74
|
+
* invariant: the parser is commerce-sdk-isomorphic's server-side TokenResponse
|
|
75
|
+
* reconstruction (helpers/slasHelper), which walks the Set-Cookie array keyed
|
|
76
|
+
* only by cookie NAME (Domain ignored) and assigns each token field with
|
|
77
|
+
* last-write-wins — notably both cc-nx and cc-nx-g map to refresh_token. If a
|
|
78
|
+
* future SDK version stops being last-write-wins (or skips empty values), this
|
|
79
|
+
* ordering must be revisited. In the browser the two writes target different
|
|
80
|
+
* cookie scopes (host vs Domain), so their relative order has no effect.
|
|
63
81
|
*
|
|
64
82
|
* `siteAttrs` (sameSite/partitioned) is decided per-request by
|
|
65
83
|
* `getSiteAttrsForRequest` and applied uniformly to every cookie this
|
|
@@ -75,13 +93,9 @@ function makeAppendCookie(res, cookieDomain, siteAttrs) {
|
|
|
75
93
|
expires,
|
|
76
94
|
attributes
|
|
77
95
|
}) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
expires
|
|
82
|
-
}, attributes), siteAttrs), cookieDomain && {
|
|
83
|
-
domain: cookieDomain
|
|
84
|
-
})));
|
|
96
|
+
// Clear any pre-existing host-scoped cookie first. This must precede the
|
|
97
|
+
// real write so the SSR last-write-wins Set-Cookie parser recovers the
|
|
98
|
+
// real token value, not the empty deletion (see JSDoc above).
|
|
85
99
|
if (cookieDomain) {
|
|
86
100
|
res.append(_constants.SET_COOKIE, (0, _ssrProxying.cookieAsString)(_objectSpread(_objectSpread({
|
|
87
101
|
name,
|
|
@@ -89,6 +103,13 @@ function makeAppendCookie(res, cookieDomain, siteAttrs) {
|
|
|
89
103
|
expires: new Date(0)
|
|
90
104
|
}, attributes), siteAttrs)));
|
|
91
105
|
}
|
|
106
|
+
res.append(_constants.SET_COOKIE, (0, _ssrProxying.cookieAsString)(_objectSpread(_objectSpread(_objectSpread({
|
|
107
|
+
name,
|
|
108
|
+
value,
|
|
109
|
+
expires
|
|
110
|
+
}, attributes), siteAttrs), cookieDomain && {
|
|
111
|
+
domain: cookieDomain
|
|
112
|
+
})));
|
|
92
113
|
};
|
|
93
114
|
}
|
|
94
115
|
|
|
@@ -271,16 +292,21 @@ function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options)
|
|
|
271
292
|
const refreshTTL = getRefreshTokenCookieTTL(parsed.refresh_token_expires_in, isGuest, commerceAPI);
|
|
272
293
|
const refreshExpires = new Date(Date.now() + refreshTTL * 1000);
|
|
273
294
|
const refreshConfig = isGuest ? refreshTokenGuest : refreshTokenRegistered;
|
|
274
|
-
appendCookie({
|
|
275
|
-
name: (0, _httponlyCookieConfig.getCookieName)(refreshConfig, site),
|
|
276
|
-
value: parsed.refresh_token,
|
|
277
|
-
expires: refreshExpires,
|
|
278
|
-
attributes: refreshConfig.attributes
|
|
279
|
-
});
|
|
280
295
|
|
|
281
296
|
// Delete the opposite refresh token cookie to mirror client-side behavior:
|
|
282
297
|
// Login (guest → registered): delete guest cookie cc-nx-g
|
|
283
298
|
// Logout (registered → guest): delete registered cookie cc-nx
|
|
299
|
+
//
|
|
300
|
+
// This deletion MUST be emitted BEFORE the real refresh-token write
|
|
301
|
+
// below. Both cc-nx and cc-nx-g map to `refresh_token` in
|
|
302
|
+
// commerce-sdk-isomorphic's SSR TokenResponse reconstruction, which
|
|
303
|
+
// parses the Set-Cookie array with last-write-wins per token field
|
|
304
|
+
// (ignoring the Domain attribute). If the empty opposite-cookie deletion
|
|
305
|
+
// were emitted last it would clobber the reconstructed refresh_token to
|
|
306
|
+
// empty — the same failure mode fixed for the access token in
|
|
307
|
+
// makeAppendCookie. The browser is unaffected either way (cc-nx and
|
|
308
|
+
// cc-nx-g are distinct cookie names, so their relative order is
|
|
309
|
+
// irrelevant there).
|
|
284
310
|
const staleRefreshConfig = isGuest ? refreshTokenRegistered : refreshTokenGuest;
|
|
285
311
|
appendCookie({
|
|
286
312
|
name: (0, _httponlyCookieConfig.getCookieName)(staleRefreshConfig, site),
|
|
@@ -288,6 +314,12 @@ function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options)
|
|
|
288
314
|
expires: new Date(0),
|
|
289
315
|
attributes: staleRefreshConfig.attributes
|
|
290
316
|
});
|
|
317
|
+
appendCookie({
|
|
318
|
+
name: (0, _httponlyCookieConfig.getCookieName)(refreshConfig, site),
|
|
319
|
+
value: parsed.refresh_token,
|
|
320
|
+
expires: refreshExpires,
|
|
321
|
+
attributes: refreshConfig.attributes
|
|
322
|
+
});
|
|
291
323
|
|
|
292
324
|
// Hybrid SFRA + PWA: mirror session-scoped SLAS metadata as
|
|
293
325
|
// siteId-suffixed cookies so SFRA can read the same session state. These
|
|
@@ -65,6 +65,27 @@ function makeOptionsWithCookieDomain(cookieDomain) {
|
|
|
65
65
|
function findCookies(cookies, name) {
|
|
66
66
|
return cookies.filter(c => c.startsWith(`${name}=`)).map(parseCookie);
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// Mirrors commerce-sdk-isomorphic's SSR TokenResponse reconstruction: it walks
|
|
70
|
+
// the response's Set-Cookie array in emission order and assigns per token field
|
|
71
|
+
// with last-write-wins, keyed only by cookie NAME (the Domain attribute is
|
|
72
|
+
// ignored). The SDK reconstructs exactly three fields — cc-at → access_token,
|
|
73
|
+
// cc-nx/cc-nx-g → refresh_token (BOTH names), idp_access_token → idp_access_token
|
|
74
|
+
// — so those are the ones modelled here. This is what a cookieless SSR guest
|
|
75
|
+
// login uses to recover its tokens, so the raw emission order of these
|
|
76
|
+
// Set-Cookie headers is load-bearing — see the ordering notes in makeAppendCookie
|
|
77
|
+
// and the refresh-token block of process-token-response.js.
|
|
78
|
+
function reconstructSsrTokens(cookies, site = 'testsite') {
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const raw of cookies) {
|
|
81
|
+
const {
|
|
82
|
+
name,
|
|
83
|
+
value
|
|
84
|
+
} = parseCookie(raw);
|
|
85
|
+
if (name === `cc-at_${site}`) out.access_token = value;else if (name === `cc-nx_${site}` || name === `cc-nx-g_${site}`) out.refresh_token = value;else if (name === `idp_access_token_${site}`) out.idp_access_token = value;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
68
89
|
describe('getRefreshTokenCookieTTL', () => {
|
|
69
90
|
const GUEST_DEFAULT = 30 * 24 * 60 * 60;
|
|
70
91
|
const REGISTERED_DEFAULT = 90 * 24 * 60 * 60;
|
|
@@ -577,6 +598,107 @@ describe('cookieDomain support', () => {
|
|
|
577
598
|
expect(atActive.domain).toBe('.example.com');
|
|
578
599
|
expect(atActive.httpOnly).toBe(true);
|
|
579
600
|
});
|
|
601
|
+
test('emits the host-scoped deletion BEFORE the real Domain-scoped write (raw emission order)', () => {
|
|
602
|
+
// The existing cleanup test above sorts the two writes by Domain, so it
|
|
603
|
+
// would still pass if the two res.append calls in makeAppendCookie were
|
|
604
|
+
// flipped back — reintroducing the SSR-reconstruction 401. Assert the
|
|
605
|
+
// raw emission order directly so that regression is caught.
|
|
606
|
+
const res = makeRes();
|
|
607
|
+
const accessToken = makeJWT({
|
|
608
|
+
iat: 1000,
|
|
609
|
+
exp: 2800,
|
|
610
|
+
isb: 'uido:ecom::upn:Guest'
|
|
611
|
+
});
|
|
612
|
+
const buf = makeResponseBuffer({
|
|
613
|
+
access_token: accessToken,
|
|
614
|
+
refresh_token: 'refresh-value'
|
|
615
|
+
});
|
|
616
|
+
(0, _processTokenResponse.setHttpOnlySessionCookies)(buf, {}, makeReq(), res, makeOptionsWithCookieDomain('.example.com'));
|
|
617
|
+
|
|
618
|
+
// For a given cookie name, the empty host-scoped deletion must be emitted
|
|
619
|
+
// before the real Domain-scoped value so last-write-wins recovers the value.
|
|
620
|
+
const assertDeletionFirst = (name, realValue) => {
|
|
621
|
+
const idxOf = predicate => res.cookies.findIndex(c => c.startsWith(`${name}=`) && predicate(parseCookie(c)));
|
|
622
|
+
const deletionIdx = idxOf(c => c.value === '' && c.domain === undefined);
|
|
623
|
+
const realIdx = idxOf(c => c.value === realValue && c.domain === '.example.com');
|
|
624
|
+
expect(deletionIdx).toBeGreaterThanOrEqual(0);
|
|
625
|
+
expect(realIdx).toBeGreaterThanOrEqual(0);
|
|
626
|
+
expect(deletionIdx).toBeLessThan(realIdx);
|
|
627
|
+
};
|
|
628
|
+
assertDeletionFirst('cc-at_testsite', accessToken);
|
|
629
|
+
assertDeletionFirst('cc-nx-g_testsite', 'refresh-value');
|
|
630
|
+
});
|
|
631
|
+
test('SSR last-write-wins reconstruction recovers the real access, refresh AND idp tokens', () => {
|
|
632
|
+
// Guards the end-to-end invariant, including the cross-name refresh case
|
|
633
|
+
// that the per-name ordering test above cannot catch: the empty
|
|
634
|
+
// opposite-refresh deletion (cc-nx) is emitted as a separate appendCookie
|
|
635
|
+
// AFTER the real cc-nx-g in the pre-fix code, and both names map to
|
|
636
|
+
// refresh_token — so the deletion would clobber the reconstructed token.
|
|
637
|
+
// idp_access_token is also SDK-reconstructed and body-stripped, so it
|
|
638
|
+
// relies on the same makeAppendCookie deletion-before-real ordering; pin
|
|
639
|
+
// it here too.
|
|
640
|
+
const accessToken = makeJWT({
|
|
641
|
+
iat: 1000,
|
|
642
|
+
exp: 2800,
|
|
643
|
+
isb: 'uido:ecom::upn:Guest'
|
|
644
|
+
});
|
|
645
|
+
const body = {
|
|
646
|
+
access_token: accessToken,
|
|
647
|
+
refresh_token: 'refresh-value',
|
|
648
|
+
idp_access_token: 'idp-at-value'
|
|
649
|
+
};
|
|
650
|
+
const expected = {
|
|
651
|
+
access_token: accessToken,
|
|
652
|
+
refresh_token: 'refresh-value',
|
|
653
|
+
idp_access_token: 'idp-at-value'
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// With cookieDomain configured (the reported 401 scenario).
|
|
657
|
+
const withDomain = makeRes();
|
|
658
|
+
(0, _processTokenResponse.setHttpOnlySessionCookies)(makeResponseBuffer(body), {}, makeReq(), withDomain, makeOptionsWithCookieDomain('.example.com'));
|
|
659
|
+
expect(reconstructSsrTokens(withDomain.cookies)).toEqual(expected);
|
|
660
|
+
|
|
661
|
+
// And without cookieDomain: the opposite-refresh deletion is still emitted
|
|
662
|
+
// last in the pre-fix code, so refresh_token must survive here too.
|
|
663
|
+
const noDomain = makeRes();
|
|
664
|
+
(0, _processTokenResponse.setHttpOnlySessionCookies)(makeResponseBuffer(body), {}, makeReq(), noDomain, {});
|
|
665
|
+
expect(reconstructSsrTokens(noDomain.cookies)).toEqual(expected);
|
|
666
|
+
});
|
|
667
|
+
test('SSR last-write-wins reconstruction recovers tokens for a REGISTERED login (mirror case)', () => {
|
|
668
|
+
// The guest test above exercises cc-nx-g as the real cookie and cc-nx as
|
|
669
|
+
// the empty opposite-deletion. The registered branch is the mirror: cc-nx
|
|
670
|
+
// is the real refresh cookie and cc-nx-g is the empty deletion, so the
|
|
671
|
+
// deletion-before-real ordering must hold for it too. A registered JWT
|
|
672
|
+
// uses a non-Guest `isb`.
|
|
673
|
+
const registeredToken = makeJWT({
|
|
674
|
+
iat: 1000,
|
|
675
|
+
exp: 2800,
|
|
676
|
+
isb: 'uido:ecom::upn:user@example.com::uidn:Jane'
|
|
677
|
+
});
|
|
678
|
+
const res = makeRes();
|
|
679
|
+
(0, _processTokenResponse.setHttpOnlySessionCookies)(makeResponseBuffer({
|
|
680
|
+
access_token: registeredToken,
|
|
681
|
+
refresh_token: 'refresh-value'
|
|
682
|
+
}), {}, makeReq(), res, makeOptionsWithCookieDomain('.example.com'));
|
|
683
|
+
expect(reconstructSsrTokens(res.cookies)).toEqual({
|
|
684
|
+
access_token: registeredToken,
|
|
685
|
+
refresh_token: 'refresh-value'
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
test('logout expires both refresh cookies so SSR cannot reconstruct a token', () => {
|
|
689
|
+
// After logout SSR must not be able to rebuild a refresh token from the
|
|
690
|
+
// response. Order doesn't matter here (all writes are empty) but presence
|
|
691
|
+
// does: cc-nx and cc-nx-g both map to refresh_token, so both must be
|
|
692
|
+
// emitted empty.
|
|
693
|
+
const res = makeRes();
|
|
694
|
+
(0, _processTokenResponse.expireHttpOnlySessionCookies)(makeReq(), res, makeOptionsWithCookieDomain('.example.com'));
|
|
695
|
+
for (const name of ['cc-nx_testsite', 'cc-nx-g_testsite']) {
|
|
696
|
+
const matches = findCookies(res.cookies, name);
|
|
697
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
698
|
+
expect(matches.every(c => c.value === '')).toBe(true);
|
|
699
|
+
}
|
|
700
|
+
expect(reconstructSsrTokens(res.cookies).refresh_token).toBe('');
|
|
701
|
+
});
|
|
580
702
|
test('setHttpOnlySessionCookies warns and ignores invalid cookieDomain', () => {
|
|
581
703
|
const res = makeRes();
|
|
582
704
|
const accessToken = makeJWT({
|
|
@@ -645,6 +767,17 @@ describe('trusted Storefront Preview iframe', () => {
|
|
|
645
767
|
}
|
|
646
768
|
};
|
|
647
769
|
}
|
|
770
|
+
|
|
771
|
+
// The client-sent header path — used when the iframe document load is
|
|
772
|
+
// served from the CDN cache and never sets the marker cookie.
|
|
773
|
+
function makeReqWithHeader(value, siteId = 'testsite') {
|
|
774
|
+
return {
|
|
775
|
+
headers: {
|
|
776
|
+
[_constants.X_SITE_ID]: siteId,
|
|
777
|
+
[_constants.X_PREVIEW_PARENT]: value
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
}
|
|
648
781
|
function makeFullSlasResponse() {
|
|
649
782
|
const accessToken = makeJWT({
|
|
650
783
|
iat: 1000,
|
|
@@ -717,6 +850,30 @@ describe('trusted Storefront Preview iframe', () => {
|
|
|
717
850
|
expect(cookieStr).not.toMatch(/Partitioned/i);
|
|
718
851
|
}
|
|
719
852
|
});
|
|
853
|
+
test('every session cookie carries SameSite=none; Partitioned when only the client header is present (CDN-cached document load, no marker cookie)', () => {
|
|
854
|
+
const res = makeRes();
|
|
855
|
+
const {
|
|
856
|
+
buf
|
|
857
|
+
} = makeFullSlasResponse();
|
|
858
|
+
(0, _processTokenResponse.setHttpOnlySessionCookies)(buf, {}, makeReqWithHeader(TRUSTED_PARENT), res, {});
|
|
859
|
+
expect(res.cookies.length).toBeGreaterThan(0);
|
|
860
|
+
for (const cookieStr of res.cookies) {
|
|
861
|
+
expect(cookieStr).toMatch(/SameSite=none/i);
|
|
862
|
+
expect(cookieStr).toMatch(/Partitioned/i);
|
|
863
|
+
expect(cookieStr).not.toMatch(/SameSite=lax/i);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
test('falls back to SameSite=Lax when the client header value is not on the allow-list', () => {
|
|
867
|
+
const res = makeRes();
|
|
868
|
+
const {
|
|
869
|
+
buf
|
|
870
|
+
} = makeFullSlasResponse();
|
|
871
|
+
(0, _processTokenResponse.setHttpOnlySessionCookies)(buf, {}, makeReqWithHeader('https://attacker.example.com'), res, {});
|
|
872
|
+
for (const cookieStr of res.cookies) {
|
|
873
|
+
expect(cookieStr).toMatch(/SameSite=lax/i);
|
|
874
|
+
expect(cookieStr).not.toMatch(/Partitioned/i);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
720
877
|
test('expireHttpOnlySessionCookies under the iframe path emits SameSite=none; Partitioned on every deletion (so partitioned cookies actually clear)', () => {
|
|
721
878
|
const res = makeRes();
|
|
722
879
|
(0, _processTokenResponse.expireHttpOnlySessionCookies)(makeReqWithMarker(TRUSTED_PARENT), res, {});
|