Skip to content

Commit 79eedb2

Browse files
TUrnstile in SDK
1 parent 414cb66 commit 79eedb2

File tree

4 files changed

+155
-35
lines changed

4 files changed

+155
-35
lines changed

packages/commerce-sdk-react/CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
## v5.0.0-turnstile-preview.0 (Jan 30, 2026)
2+
3+
- [Feature] Add custom fetch for `authorizePasswordless` when `turnstileResponse` is present. The commerce-sdk-isomorphic helper does not forward the Turnstile token; this change performs the request via custom fetch so the token reaches the BFF/MRT for server-side verification via Cloudflare Siteverify.
4+
15
## v5.0.0-dev (Jan 28, 2026)
26
- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
3-
4-
## v4.4.0-dev (Dec 17, 2025)
5-
- [Bugfix]Ensure code_verifier can be optional in resetPassword call [#3567](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3567)
7+
- [Bugfix] Ensure code_verifier can be optional in resetPassword call [#3567](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3567)
68
- [Improvement] Strengthening typescript types on custom endpoint options and fetchOptions types [#3589](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3589)
7-
- [Feature] update `authorizePasswordless`, `getPasswordResetToken`, and `resetPassword` to support use of `email` mode [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525)
9+
- [Feature] Update `authorizePasswordless`, `getPasswordResetToken`, and `resetPassword` to support use of `email` mode [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525)
810

911
## v4.3.0 (Dec 17, 2025)
1012

packages/commerce-sdk-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@salesforce/commerce-sdk-react",
3-
"version": "5.0.0-dev",
3+
"version": "5.0.0-turnstile-preview.0",
44
"description": "A library that provides react hooks for fetching data from Commerce Cloud",
55
"homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/ecom-react-hooks#readme",
66
"bugs": {

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ describe('Auth', () => {
989989

990990
const mockErrorResponse = {
991991
status: 400,
992-
json: jest.fn().mockResolvedValue({message: 'Invalid request'})
992+
text: jest.fn().mockResolvedValue(JSON.stringify({message: 'Invalid request'}))
993993
}
994994
;(helpers.authorizePasswordless as jest.Mock).mockResolvedValueOnce(mockErrorResponse)
995995

@@ -998,6 +998,50 @@ describe('Auth', () => {
998998
)
999999
})
10001000

1001+
test('authorizePasswordless with turnstileResponse uses custom fetch instead of helper', async () => {
1002+
const mockFetch = jest.fn().mockResolvedValue({status: 200, text: () => Promise.resolve('')})
1003+
global.fetch = mockFetch
1004+
1005+
const auth = new Auth(configSLASPrivate)
1006+
// @ts-expect-error private method
1007+
auth.set('usid', 'test-usid')
1008+
1009+
await auth.authorizePasswordless({
1010+
userid: 'user@example.com',
1011+
turnstileResponse: 'turnstile-token-123'
1012+
})
1013+
1014+
expect(helpers.authorizePasswordless).not.toHaveBeenCalled()
1015+
expect(mockFetch).toHaveBeenCalledTimes(1)
1016+
const [url, options] = mockFetch.mock.calls[0]
1017+
expect(url).toBe(
1018+
'proxy/shopper/auth/v1/organizations/organizationId/oauth2/passwordless/login'
1019+
)
1020+
expect(options.method).toBe('POST')
1021+
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
1022+
const body = new URLSearchParams(options.body)
1023+
expect(body.get('user_id')).toBe('user@example.com')
1024+
expect(body.get('turnstileResponse')).toBe('turnstile-token-123')
1025+
expect(body.get('usid')).toBe('test-usid')
1026+
})
1027+
1028+
test('authorizePasswordless with turnstileResponse throws on non-200 fetch response', async () => {
1029+
const mockFetch = jest.fn().mockResolvedValue({
1030+
status: 404,
1031+
text: () => Promise.resolve('')
1032+
})
1033+
global.fetch = mockFetch
1034+
1035+
const auth = new Auth(configSLASPrivate)
1036+
1037+
await expect(
1038+
auth.authorizePasswordless({
1039+
userid: 'guest@example.com',
1040+
turnstileResponse: 'token'
1041+
})
1042+
).rejects.toThrow('404')
1043+
})
1044+
10011045
test('getPasswordLessAccessToken calls isomorphic getPasswordLessAccessToken', async () => {
10021046
const auth = new Auth(config)
10031047
await auth.getPasswordLessAccessToken({pwdlessLoginToken: '12345678'})

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

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ type AuthorizePasswordlessParams = {
103103
last_name?: string
104104
email?: string
105105
phone_number?: string
106+
/** Cloudflare Turnstile response token for bot protection. Backend/MRT should verify via Siteverify and strip before forwarding to SLAS. */
107+
turnstileResponse?: string
106108
}
107109

108110
type GetPasswordLessAccessTokenParams = {
@@ -284,14 +286,18 @@ class Auth {
284286
| undefined
285287

286288
private hybridAuthEnabled: boolean
289+
/** Base URL for SLAS client (same as proxy passed to ShopperLogin). Used for custom fetch when turnstileResponse is present. */
290+
private slasClientBaseUrl: string
287291

288292
constructor(config: AuthConfig) {
289293
// Special proxy endpoint for injecting SLAS private client secret.
290294
// We prioritize config.privateClientProxyEndpoint since that allows us to use the new envBasePath feature
291-
this.client = new ShopperLogin({
292-
proxy: config.enablePWAKitPrivateClient
295+
this.slasClientBaseUrl =
296+
config.enablePWAKitPrivateClient && config.privateClientProxyEndpoint
293297
? config.privateClientProxyEndpoint
294-
: config.proxy,
298+
: config.proxy
299+
this.client = new ShopperLogin({
300+
proxy: this.slasClientBaseUrl,
295301
headers: config.headers || {},
296302
parameters: {
297303
clientId: config.clientId,
@@ -1276,6 +1282,8 @@ class Auth {
12761282

12771283
/**
12781284
* A wrapper method for commerce-sdk-isomorphic helper: authorizePasswordless.
1285+
* When turnstileResponse is provided, we perform the request ourselves so the token
1286+
* is included in the body (commerce-sdk-isomorphic helper does not forward turnstileResponse).
12791287
*/
12801288
async authorizePasswordless(parameters: AuthorizePasswordlessParams) {
12811289
const usid = this.get('usid')
@@ -1284,34 +1292,100 @@ class Auth {
12841292
const mode = parameters.mode || 'callback'
12851293
const callbackURI = parameters.callbackURI || this.passwordlessLoginCallbackURI
12861294

1287-
const res = await helpers.authorizePasswordless({
1288-
slasClient: this.client,
1289-
credentials: {
1290-
clientSecret: this.clientSecret
1291-
},
1292-
parameters: {
1293-
...(callbackURI && {callbackURI}),
1294-
...(usid && {usid}),
1295-
...(parameters.locale && {locale: parameters.locale}),
1296-
userid: parameters.userid,
1297-
mode,
1298-
...(parameters.register_customer !== undefined && {
1299-
registerCustomer:
1300-
typeof parameters.register_customer === 'boolean'
1301-
? parameters.register_customer
1302-
: parameters.register_customer === 'true'
1303-
? true
1304-
: false
1305-
}),
1306-
...(parameters.last_name && {lastName: parameters.last_name}),
1307-
...(parameters.email && {email: parameters.email}),
1308-
...(parameters.first_name && {firstName: parameters.first_name}),
1309-
...(parameters.phone_number && {phoneNumber: parameters.phone_number})
1295+
const {turnstileResponse, ...restParams} = parameters
1296+
1297+
let res: Response
1298+
1299+
if (turnstileResponse) {
1300+
// commerce-sdk-isomorphic helper does not include turnstileResponse in the POST body.
1301+
// Perform the request ourselves so the Turnstile token reaches the server.
1302+
const clientConfig = (this.client as {
1303+
clientConfig?: {
1304+
parameters?: { organizationId?: string; siteId?: string }
1305+
fetchOptions?: RequestInit
1306+
}
1307+
}).clientConfig
1308+
const organizationId = clientConfig?.parameters?.organizationId
1309+
const channelId = clientConfig?.parameters?.siteId
1310+
if (!organizationId || !channelId) {
1311+
throw new Error('Missing organizationId or siteId for passwordless login')
13101312
}
1311-
})
1313+
// Use SLAS proxy base URL (same-origin) so the request is allowed by CSP and reaches the app server
1314+
const url = `${this.slasClientBaseUrl}/shopper/auth/v1/organizations/${organizationId}/oauth2/passwordless/login`
1315+
const bodyEntries: Array<[string, string]> = [
1316+
['user_id', restParams.userid],
1317+
['mode', mode],
1318+
['channel_id', channelId]
1319+
]
1320+
if (restParams.locale) bodyEntries.push(['locale', restParams.locale])
1321+
if (usid) bodyEntries.push(['usid', usid])
1322+
if (callbackURI) bodyEntries.push(['callback_uri', callbackURI])
1323+
if (restParams.register_customer !== undefined) {
1324+
const val =
1325+
typeof restParams.register_customer === 'boolean'
1326+
? restParams.register_customer
1327+
: restParams.register_customer === 'true'
1328+
bodyEntries.push(['register_customer', val ? 'true' : 'false'])
1329+
}
1330+
if (restParams.last_name) bodyEntries.push(['last_name', restParams.last_name])
1331+
if (restParams.email) bodyEntries.push(['email', restParams.email])
1332+
if (restParams.first_name) bodyEntries.push(['first_name', restParams.first_name])
1333+
if (restParams.phone_number) bodyEntries.push(['phone_number', restParams.phone_number])
1334+
bodyEntries.push(['turnstileResponse', turnstileResponse])
1335+
1336+
const body = new URLSearchParams(bodyEntries).toString()
1337+
const fetchOptions = clientConfig?.fetchOptions ?? {}
1338+
res = await fetch(url, {
1339+
...fetchOptions,
1340+
method: 'POST',
1341+
headers: {
1342+
...(fetchOptions.headers as Record<string, string>),
1343+
'Content-Type': 'application/x-www-form-urlencoded'
1344+
},
1345+
body
1346+
})
1347+
} else {
1348+
res = await helpers.authorizePasswordless({
1349+
slasClient: this.client,
1350+
credentials: {
1351+
clientSecret: this.clientSecret
1352+
},
1353+
parameters: {
1354+
...(callbackURI && {callbackURI}),
1355+
...(usid && {usid}),
1356+
...(restParams.locale && {locale: restParams.locale}),
1357+
userid: restParams.userid,
1358+
mode,
1359+
...(restParams.register_customer !== undefined && {
1360+
registerCustomer:
1361+
typeof restParams.register_customer === 'boolean'
1362+
? restParams.register_customer
1363+
: restParams.register_customer === 'true'
1364+
? true
1365+
: false
1366+
}),
1367+
...(restParams.last_name && {lastName: restParams.last_name}),
1368+
...(restParams.email && {email: restParams.email}),
1369+
...(restParams.first_name && {firstName: restParams.first_name}),
1370+
...(restParams.phone_number && {phoneNumber: restParams.phone_number})
1371+
}
1372+
})
1373+
}
1374+
13121375
if (res && res.status !== 200) {
1313-
const errorData = await res.json()
1314-
throw new Error(`${res.status} ${String(errorData.message)}`)
1376+
let errorMessage = ''
1377+
try {
1378+
const text = await res.text()
1379+
if (text.trim()) {
1380+
const errorData = JSON.parse(text) as { message?: string }
1381+
errorMessage = String(errorData?.message ?? '').trim()
1382+
}
1383+
} catch {
1384+
// Empty or invalid JSON body (e.g. 404 for guest user)
1385+
}
1386+
throw new Error(
1387+
[res.status, errorMessage].filter(Boolean).join(' ') || String(res.status)
1388+
)
13151389
}
13161390
return res
13171391
}

0 commit comments

Comments
 (0)