Skip to content

Commit 92a7c88

Browse files
unandyalaclaude
andcommitted
Feat: Support enableHttpOnlySessionCookies in SLAS helper functions
When enableHttpOnlySessionCookies is true, the PWA-KIT middleware strips tokens from the SLAS response body and sets them as httpOnly cookies. During SSR, the SDK needs to extract these tokens from Set-Cookie headers and merge them back into the TokenResponse. Changes: - Add extractTokensFromResponse (private) to parse Set-Cookie headers and map cookie names (cc-at_, cc-nx_, cc-nx-g_, idp_access_token_) back to TokenResponse fields - Add optional enableHttpOnlySessionCookies flag (defaults to false) to: loginGuestUser, loginGuestUserPrivate, loginIDPUser, loginRegisteredUserB2C, refreshAccessToken, getPasswordLessAccessToken - When flag is true: call getAccessToken with rawResponse=true and extract tokens from Set-Cookie headers (server-side only) - When flag is false: existing behavior unchanged (no rawResponse) - isBrowser check ensures tokens are never exposed on the client Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0a8a6bc commit 92a7c88

2 files changed

Lines changed: 529 additions & 13 deletions

File tree

src/static/helpers/slasHelper.test.ts

Lines changed: 317 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,63 @@ const parameters = {
5353
const url =
5454
'https://localhost:3000/callback?usid=048adcfb-aa93-4978-be9e-09cb569fdcb9&code=J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o';
5555

56+
const createMockResponse = (
57+
body: unknown,
58+
setCookieHeaders?: string[]
59+
): Response =>
60+
({
61+
ok: true,
62+
status: 200,
63+
text: jest.fn().mockResolvedValue(JSON.stringify(body)),
64+
headers: {
65+
get: jest.fn((name: string) =>
66+
name === 'set-cookie' && setCookieHeaders
67+
? setCookieHeaders.join(', ')
68+
: null
69+
),
70+
getSetCookie: setCookieHeaders
71+
? jest.fn(() => setCookieHeaders)
72+
: undefined,
73+
},
74+
} as unknown as Response);
75+
76+
// Simulates the middleware stripping tokens from the body and setting them as httpOnly cookies
77+
const strippedTokenResponse = {
78+
id_token: 'id_token',
79+
expires_in: 0,
80+
refresh_token_expires_in: 0,
81+
token_type: 'Bearer',
82+
usid: 'usid',
83+
customer_id: 'customer_id',
84+
enc_user_id: 'enc_user_id',
85+
};
86+
87+
const mockSetCookieHeaders = [
88+
'cc-at_site_id=access_token; Path=/; HttpOnly; Secure; SameSite=lax',
89+
'cc-nx-g_site_id=refresh_token; Path=/; HttpOnly; Secure; SameSite=lax',
90+
'idp_access_token_site_id=idp; Path=/; HttpOnly; Secure; SameSite=lax',
91+
];
92+
5693
const authenticateCustomerMock = jest.fn(() => ({url}));
5794

95+
// Returns parsed TokenResponse directly (enableHttpOnlySessionCookies=false)
5896
const getAccessTokenMock = jest.fn(() => expectedTokenResponse);
97+
98+
// Simulates httpOnly mode: returns raw Response with tokens stripped
99+
// from body and placed in Set-Cookie headers (enableHttpOnlySessionCookies=true)
100+
const getAccessTokenMockHttpOnlySessionCookies = jest.fn(() =>
101+
createMockResponse(strippedTokenResponse, mockSetCookieHeaders)
102+
);
103+
59104
const logoutCustomerMock = jest.fn(() => expectedTokenResponse);
60105
const generateCodeChallengeMock = jest.fn(() => 'code_challenge');
61106
const authorizePasswordlessCustomerMock = jest.fn();
62-
const getPasswordLessAccessTokenMock = jest.fn();
107+
108+
const getPasswordLessAccessTokenMock = jest.fn(() => expectedTokenResponse);
109+
110+
const getPasswordLessAccessTokenMockHttpOnlySessionCookies = jest.fn(() =>
111+
createMockResponse(strippedTokenResponse, mockSetCookieHeaders)
112+
);
63113

64114
const createMockSlasClient = () =>
65115
({
@@ -84,6 +134,32 @@ const createMockSlasClient = () =>
84134
siteId: string;
85135
}>);
86136

137+
// Mock client for httpOnly session cookies tests.
138+
// Uses mocks that return raw Response with tokens in Set-Cookie headers.
139+
const createMockSlasClientHttpOnlySessionCookies = () =>
140+
({
141+
clientConfig: {
142+
parameters: {
143+
shortCode: 'short_code',
144+
organizationId: 'organization_id',
145+
clientId: 'client_id',
146+
siteId: 'site_id',
147+
},
148+
},
149+
authenticateCustomer: authenticateCustomerMock,
150+
getAccessToken: getAccessTokenMockHttpOnlySessionCookies,
151+
logoutCustomer: logoutCustomerMock,
152+
generateCodeChallenge: generateCodeChallengeMock,
153+
authorizePasswordlessCustomer: authorizePasswordlessCustomerMock,
154+
getPasswordLessAccessToken:
155+
getPasswordLessAccessTokenMockHttpOnlySessionCookies,
156+
} as unknown as ShopperLogin<{
157+
shortCode: string;
158+
organizationId: string;
159+
clientId: string;
160+
siteId: string;
161+
}>);
162+
87163
const mockIsBrowserTrue = {
88164
isBrowser: true,
89165
};
@@ -724,7 +800,7 @@ describe('Registered B2C user flow', () => {
724800
credentials,
725801
parameters: registeredUserFlowParams,
726802
});
727-
expect(accessToken).toStrictEqual(expectedTokenResponse);
803+
expect(accessToken).toBe(expectedTokenResponse);
728804
});
729805
});
730806

@@ -968,7 +1044,7 @@ describe('getPasswordLessAccessToken is working', () => {
9681044
});
9691045

9701046
describe('Refresh Token', () => {
971-
test('refreshes the token with slas public client', () => {
1047+
test('refreshes the token with slas public client', async () => {
9721048
const expectedBody = {
9731049
body: {
9741050
client_id: 'client_id',
@@ -978,15 +1054,15 @@ describe('Refresh Token', () => {
9781054
dnt: 'false',
9791055
},
9801056
};
981-
const token = slasHelper.refreshAccessToken({
1057+
const token = await slasHelper.refreshAccessToken({
9821058
slasClient: createMockSlasClient(),
9831059
parameters,
9841060
});
9851061
expect(getAccessTokenMock).toBeCalledWith(expectedBody);
9861062
expect(token).toStrictEqual(expectedTokenResponse);
9871063
});
9881064

989-
test('refreshes the token with slas private client', () => {
1065+
test('refreshes the token with slas private client', async () => {
9901066
const expectedReqOpts = {
9911067
headers: {
9921068
Authorization: `Basic ${stringToBase64(
@@ -1001,7 +1077,7 @@ describe('Refresh Token', () => {
10011077
dnt: 'false',
10021078
},
10031079
};
1004-
const token = slasHelper.refreshAccessToken({
1080+
const token = await slasHelper.refreshAccessToken({
10051081
slasClient: createMockSlasClient(),
10061082
parameters,
10071083
credentials: {
@@ -1034,3 +1110,238 @@ describe('Logout', () => {
10341110
expect(token).toStrictEqual(expectedTokenResponse);
10351111
});
10361112
});
1113+
1114+
describe('httpOnly session cookies', () => {
1115+
test('loginGuestUser extracts tokens from Set-Cookie headers', async () => {
1116+
const mockSlasClient = createMockSlasClientHttpOnlySessionCookies();
1117+
const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters;
1118+
1119+
nock(`https://${shortCode}.api.commercecloud.salesforce.com`)
1120+
.get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`)
1121+
.query(true)
1122+
.reply(303, {response_body: 'response_body'}, {location: url});
1123+
1124+
const accessToken = await slasHelper.loginGuestUser({
1125+
slasClient: mockSlasClient,
1126+
parameters: {
1127+
redirectURI: 'redirect_uri',
1128+
},
1129+
enableHttpOnlySessionCookies: true,
1130+
});
1131+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1132+
expect.any(Object),
1133+
true
1134+
);
1135+
expect(accessToken.access_token).toBe('access_token');
1136+
expect(accessToken.refresh_token).toBe('refresh_token');
1137+
expect(accessToken.idp_access_token).toBe('idp');
1138+
});
1139+
1140+
test('loginGuestUserPrivate extracts tokens from Set-Cookie headers', async () => {
1141+
const accessToken = await slasHelper.loginGuestUserPrivate({
1142+
slasClient: createMockSlasClientHttpOnlySessionCookies(),
1143+
parameters: {},
1144+
credentials: {clientSecret: 'slas_private_secret'},
1145+
enableHttpOnlySessionCookies: true,
1146+
});
1147+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1148+
expect.any(Object),
1149+
true
1150+
);
1151+
expect(accessToken.access_token).toBe('access_token');
1152+
expect(accessToken.refresh_token).toBe('refresh_token');
1153+
});
1154+
1155+
test('refreshAccessToken extracts tokens from Set-Cookie headers', async () => {
1156+
const token = await slasHelper.refreshAccessToken({
1157+
slasClient: createMockSlasClientHttpOnlySessionCookies(),
1158+
parameters: {
1159+
refreshToken: 'refresh_token',
1160+
dnt: false,
1161+
},
1162+
enableHttpOnlySessionCookies: true,
1163+
});
1164+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1165+
expect.any(Object),
1166+
true
1167+
);
1168+
expect(token.access_token).toBe('access_token');
1169+
expect(token.refresh_token).toBe('refresh_token');
1170+
});
1171+
1172+
test('refreshAccessToken with private client extracts tokens from Set-Cookie headers', async () => {
1173+
const token = await slasHelper.refreshAccessToken({
1174+
slasClient: createMockSlasClientHttpOnlySessionCookies(),
1175+
parameters: {
1176+
refreshToken: 'refresh_token',
1177+
dnt: false,
1178+
},
1179+
credentials: {clientSecret: 'slas_private_secret'},
1180+
enableHttpOnlySessionCookies: true,
1181+
});
1182+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1183+
expect.any(Object),
1184+
true
1185+
);
1186+
expect(token.access_token).toBe('access_token');
1187+
expect(token.refresh_token).toBe('refresh_token');
1188+
});
1189+
1190+
test('loginIDPUser with private client extracts tokens from Set-Cookie headers', async () => {
1191+
const accessToken = await slasHelper.loginIDPUser({
1192+
slasClient: createMockSlasClientHttpOnlySessionCookies(),
1193+
credentials: {clientSecret: credentialsPrivate.clientSecret},
1194+
parameters: {
1195+
redirectURI: 'redirect_uri',
1196+
code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o',
1197+
usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9',
1198+
dnt: false,
1199+
},
1200+
enableHttpOnlySessionCookies: true,
1201+
});
1202+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1203+
expect.any(Object),
1204+
true
1205+
);
1206+
expect(accessToken.access_token).toBe('access_token');
1207+
expect(accessToken.refresh_token).toBe('refresh_token');
1208+
expect(accessToken.idp_access_token).toBe('idp');
1209+
});
1210+
1211+
test('loginIDPUser with public client extracts tokens from Set-Cookie headers', async () => {
1212+
const accessToken = await slasHelper.loginIDPUser({
1213+
slasClient: createMockSlasClientHttpOnlySessionCookies(),
1214+
credentials: {codeVerifier: 'code_verifier'},
1215+
parameters: {
1216+
redirectURI: 'redirect_uri',
1217+
code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o',
1218+
usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9',
1219+
dnt: false,
1220+
},
1221+
enableHttpOnlySessionCookies: true,
1222+
});
1223+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1224+
expect.any(Object),
1225+
true
1226+
);
1227+
expect(accessToken.access_token).toBe('access_token');
1228+
expect(accessToken.refresh_token).toBe('refresh_token');
1229+
});
1230+
1231+
test('loginRegisteredUserB2C with public client extracts tokens from Set-Cookie headers', async () => {
1232+
const mockSlasClient = createMockSlasClientHttpOnlySessionCookies();
1233+
const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters;
1234+
1235+
nock(`https://${shortCode}.api.commercecloud.salesforce.com`)
1236+
.post(`/shopper/auth/v1/organizations/${organizationId}/oauth2/login`)
1237+
.reply(303, {response_body: 'response_body'}, {location: url});
1238+
1239+
const accessToken = await slasHelper.loginRegisteredUserB2C({
1240+
slasClient: mockSlasClient,
1241+
credentials,
1242+
parameters: {
1243+
redirectURI: 'redirect_uri',
1244+
dnt: false,
1245+
},
1246+
enableHttpOnlySessionCookies: true,
1247+
});
1248+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1249+
expect.any(Object),
1250+
true
1251+
);
1252+
expect(accessToken.access_token).toBe('access_token');
1253+
expect(accessToken.refresh_token).toBe('refresh_token');
1254+
});
1255+
1256+
test('loginRegisteredUserB2C with private client extracts tokens from Set-Cookie headers', async () => {
1257+
const mockSlasClient = createMockSlasClientHttpOnlySessionCookies();
1258+
const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters;
1259+
1260+
nock(`https://${shortCode}.api.commercecloud.salesforce.com`)
1261+
.post(`/shopper/auth/v1/organizations/${organizationId}/oauth2/login`)
1262+
.reply(303, {response_body: 'response_body'}, {location: url});
1263+
1264+
const accessToken = await slasHelper.loginRegisteredUserB2C({
1265+
slasClient: mockSlasClient,
1266+
credentials: credentialsPrivate,
1267+
parameters: {
1268+
redirectURI: 'redirect_uri',
1269+
dnt: false,
1270+
},
1271+
enableHttpOnlySessionCookies: true,
1272+
});
1273+
expect(getAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1274+
expect.any(Object),
1275+
true
1276+
);
1277+
expect(accessToken.access_token).toBe('access_token');
1278+
expect(accessToken.refresh_token).toBe('refresh_token');
1279+
});
1280+
1281+
test('getPasswordLessAccessToken extracts tokens from Set-Cookie headers', async () => {
1282+
const token = await slasHelper.getPasswordLessAccessToken({
1283+
slasClient: createMockSlasClientHttpOnlySessionCookies(),
1284+
credentials: {clientSecret: 'slas_private_secret'},
1285+
parameters: {
1286+
pwdlessLoginToken: 'pwdless_token',
1287+
},
1288+
enableHttpOnlySessionCookies: true,
1289+
});
1290+
expect(getPasswordLessAccessTokenMockHttpOnlySessionCookies).toBeCalledWith(
1291+
expect.any(Object),
1292+
true
1293+
);
1294+
expect(token.access_token).toBe('access_token');
1295+
expect(token.refresh_token).toBe('refresh_token');
1296+
});
1297+
1298+
test('does not extract tokens from Set-Cookie headers on browser (isBrowser=true)', async () => {
1299+
// Re-import slasHelper with isBrowser mocked to true
1300+
jest.resetModules();
1301+
// Provide btoa for the browser mock since stringToBase64 uses it when isBrowser=true
1302+
global.btoa = (str: string) => Buffer.from(str).toString('base64');
1303+
jest.doMock('./environment', () => ({isBrowser: true}));
1304+
1305+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1306+
const browserSlasHelper = require('./slasHelper') as typeof slasHelper;
1307+
1308+
// Mock client that returns stripped body + Set-Cookie headers
1309+
const mockGetAccessToken = jest.fn(() =>
1310+
createMockResponse(strippedTokenResponse, mockSetCookieHeaders)
1311+
);
1312+
const mockSlasClient = {
1313+
clientConfig: {
1314+
parameters: {
1315+
shortCode: 'short_code',
1316+
organizationId: 'organization_id',
1317+
clientId: 'client_id',
1318+
siteId: 'site_id',
1319+
},
1320+
},
1321+
getAccessToken: mockGetAccessToken,
1322+
} as unknown as ShopperLogin<{
1323+
shortCode: string;
1324+
organizationId: string;
1325+
clientId: string;
1326+
siteId: string;
1327+
}>;
1328+
1329+
const accessToken = await browserSlasHelper.loginGuestUserPrivate({
1330+
slasClient: mockSlasClient,
1331+
parameters: {},
1332+
credentials: {clientSecret: 'slas_private_secret'},
1333+
enableHttpOnlySessionCookies: true,
1334+
});
1335+
1336+
// On browser: tokens should NOT be extracted from Set-Cookie headers
1337+
// The stripped body is returned as-is (browser relies on httpOnly cookies)
1338+
expect(accessToken.access_token).toBeUndefined();
1339+
expect(accessToken.refresh_token).toBeUndefined();
1340+
expect(accessToken.customer_id).toBe('customer_id');
1341+
1342+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
1343+
// @ts-ignore
1344+
delete global.btoa;
1345+
jest.restoreAllMocks();
1346+
});
1347+
});

0 commit comments

Comments
 (0)