Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## v5.1.0-dev
- Add custom fetch for `authorizePasswordless` when `turnstileResponse` is present (Cloudflare Turnstile support). 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.
- Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
- Add Shopper Consents API support [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674)

Expand Down
48 changes: 47 additions & 1 deletion packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,7 +989,7 @@ describe('Auth', () => {

const mockErrorResponse = {
status: 400,
json: jest.fn().mockResolvedValue({message: 'Invalid request'})
text: jest.fn().mockResolvedValue(JSON.stringify({message: 'Invalid request'}))
}
;(helpers.authorizePasswordless as jest.Mock).mockResolvedValueOnce(mockErrorResponse)

Expand All @@ -998,6 +998,52 @@ describe('Auth', () => {
)
})

test('authorizePasswordless with turnstileResponse uses custom fetch instead of helper', async () => {
const mockFetch = jest
.fn()
.mockResolvedValue({status: 200, text: () => Promise.resolve('')})
global.fetch = mockFetch

const auth = new Auth(configSLASPrivate)
// @ts-expect-error private method
auth.set('usid', 'test-usid')

await auth.authorizePasswordless({
userid: 'user@example.com',
turnstileResponse: 'turnstile-token-123'
})

expect(helpers.authorizePasswordless).not.toHaveBeenCalled()
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe(
'proxy/shopper/auth/v1/organizations/organizationId/oauth2/passwordless/login'
)
expect(options.method).toBe('POST')
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
const body = new URLSearchParams(options.body)
expect(body.get('user_id')).toBe('user@example.com')
expect(body.get('turnstileResponse')).toBe('turnstile-token-123')
expect(body.get('usid')).toBe('test-usid')
})

test('authorizePasswordless with turnstileResponse throws on non-200 fetch response', async () => {
const mockFetch = jest.fn().mockResolvedValue({
status: 404,
text: () => Promise.resolve('')
})
global.fetch = mockFetch

const auth = new Auth(configSLASPrivate)

await expect(
auth.authorizePasswordless({
userid: 'guest@example.com',
turnstileResponse: 'token'
})
).rejects.toThrow('404')
})

test('getPasswordLessAccessToken calls isomorphic getPasswordLessAccessToken', async () => {
const auth = new Auth(config)
await auth.getPasswordLessAccessToken({pwdlessLoginToken: '12345678'})
Expand Down
134 changes: 105 additions & 29 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ type AuthorizePasswordlessParams = {
last_name?: string
email?: string
phone_number?: string
/** Cloudflare Turnstile response token for bot protection. Backend/MRT should verify via Siteverify and strip before forwarding to SLAS. */
turnstileResponse?: string
}

type GetPasswordLessAccessTokenParams = {
Expand Down Expand Up @@ -284,14 +286,18 @@ class Auth {
| undefined

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

constructor(config: AuthConfig) {
// Special proxy endpoint for injecting SLAS private client secret.
// We prioritize config.privateClientProxyEndpoint since that allows us to use the new envBasePath feature
this.client = new ShopperLogin({
proxy: config.enablePWAKitPrivateClient
this.slasClientBaseUrl =
config.enablePWAKitPrivateClient && config.privateClientProxyEndpoint
? config.privateClientProxyEndpoint
: config.proxy,
: config.proxy
this.client = new ShopperLogin({
proxy: this.slasClientBaseUrl,
headers: config.headers || {},
parameters: {
clientId: config.clientId,
Expand Down Expand Up @@ -1276,6 +1282,8 @@ class Auth {

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

const res = await helpers.authorizePasswordless({
slasClient: this.client,
credentials: {
clientSecret: this.clientSecret
},
parameters: {
...(callbackURI && {callbackURI}),
...(usid && {usid}),
...(parameters.locale && {locale: parameters.locale}),
userid: parameters.userid,
mode,
...(parameters.register_customer !== undefined && {
registerCustomer:
typeof parameters.register_customer === 'boolean'
? parameters.register_customer
: parameters.register_customer === 'true'
? true
: false
}),
...(parameters.last_name && {lastName: parameters.last_name}),
...(parameters.email && {email: parameters.email}),
...(parameters.first_name && {firstName: parameters.first_name}),
...(parameters.phone_number && {phoneNumber: parameters.phone_number})
const {turnstileResponse, ...restParams} = parameters

let res: Response

if (turnstileResponse) {
// commerce-sdk-isomorphic helper does not include turnstileResponse in the POST body.
// Perform the request ourselves so the Turnstile token reaches the server.
const clientConfig = (
this.client as {
clientConfig?: {
parameters?: {organizationId?: string; siteId?: string}
fetchOptions?: RequestInit
}
}
).clientConfig
const organizationId = clientConfig?.parameters?.organizationId
const channelId = clientConfig?.parameters?.siteId
if (!organizationId || !channelId) {
throw new Error('Missing organizationId or siteId for passwordless login')
}
})
// Use SLAS proxy base URL (same-origin) so the request is allowed by CSP and reaches the app server
const url = `${this.slasClientBaseUrl}/shopper/auth/v1/organizations/${organizationId}/oauth2/passwordless/login`
const bodyEntries: Array<[string, string]> = [
['user_id', restParams.userid],
['mode', mode],
['channel_id', channelId]
]
if (restParams.locale) bodyEntries.push(['locale', restParams.locale])
if (usid) bodyEntries.push(['usid', usid])
if (callbackURI) bodyEntries.push(['callback_uri', callbackURI])
if (restParams.register_customer !== undefined) {
const val =
typeof restParams.register_customer === 'boolean'
? restParams.register_customer
: restParams.register_customer === 'true'
bodyEntries.push(['register_customer', val ? 'true' : 'false'])
}
if (restParams.last_name) bodyEntries.push(['last_name', restParams.last_name])
if (restParams.email) bodyEntries.push(['email', restParams.email])
if (restParams.first_name) bodyEntries.push(['first_name', restParams.first_name])
if (restParams.phone_number) bodyEntries.push(['phone_number', restParams.phone_number])
bodyEntries.push(['turnstileResponse', turnstileResponse])

const body = new URLSearchParams(bodyEntries).toString()
const fetchOptions = clientConfig?.fetchOptions ?? {}
res = await fetch(url, {
...fetchOptions,
method: 'POST',
headers: {
...(fetchOptions.headers as Record<string, string>),
'Content-Type': 'application/x-www-form-urlencoded'
},
body
})
} else {
res = await helpers.authorizePasswordless({
slasClient: this.client,
credentials: {
clientSecret: this.clientSecret
},
parameters: {
...(callbackURI && {callbackURI}),
...(usid && {usid}),
...(restParams.locale && {locale: restParams.locale}),
userid: restParams.userid,
mode,
...(restParams.register_customer !== undefined && {
registerCustomer:
typeof restParams.register_customer === 'boolean'
? restParams.register_customer
: restParams.register_customer === 'true'
? true
: false
}),
...(restParams.last_name && {lastName: restParams.last_name}),
...(restParams.email && {email: restParams.email}),
...(restParams.first_name && {firstName: restParams.first_name}),
...(restParams.phone_number && {phoneNumber: restParams.phone_number})
}
})
}

if (res && res.status !== 200) {
const errorData = await res.json()
throw new Error(`${res.status} ${String(errorData.message)}`)
let errorMessage = ''
try {
const text = await res.text()
if (text.trim()) {
const errorData = JSON.parse(text) as {message?: string}
errorMessage = String(errorData?.message ?? '').trim()
}
} catch {
// Empty or invalid JSON body (e.g. 404 for guest user)
}
throw new Error(
[res.status, errorMessage].filter(Boolean).join(' ') || String(res.status)
)
}
return res
}
Expand Down
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## v9.1.0-dev
- Increase vendor.js bundlesize limit to 367 kB
- Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
- [Feature] Subscribe to marketing communications. Email capture component updated in footer section to use Shopper Consents API. [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674)
- [Bugfix] Fix for custom billing address as returning shoppers in 1CC [#3693](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3693)
Expand Down
2 changes: 1 addition & 1 deletion packages/template-retail-react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
},
{
"path": "build/vendor.js",
"maxSize": "366 kB"
"maxSize": "367 kB"
}
]
}
Loading