Skip to content

Commit 4786611

Browse files
authored
Merge branch 'feature/webauthn-login' into W-20224220-passkey-in-auth-modal
Signed-off-by: jeremy-jung1 <140001271+jeremy-jung1@users.noreply.github.com>
2 parents 1776ebb + 6c26eba commit 4786611

File tree

8 files changed

+91
-44
lines changed

8 files changed

+91
-44
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,7 @@ class Auth {
14001400
if (authHeader) {
14011401
options.headers.Authorization = authHeader
14021402
}
1403+
14031404
const res = await this.client.resetPassword(options)
14041405
return res
14051406
}

packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur
3232

3333
// Utils
3434
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
35+
import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils'
3536

3637
// SDK
3738
import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react'
@@ -84,19 +85,6 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => {
8485
}
8586
}
8687

87-
/**
88-
* Convert ArrayBuffer to base64url string
89-
*/
90-
const arrayBufferToBase64Url = (buffer) => {
91-
const bytes = new Uint8Array(buffer)
92-
let binary = ''
93-
for (let i = 0; i < bytes.length; i++) {
94-
binary += String.fromCharCode(bytes[i])
95-
}
96-
const base64 = btoa(binary)
97-
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
98-
}
99-
10088
const handleOtpVerification = async (code) => {
10189
setIsLoading(true)
10290
setError(null)

packages/template-retail-react-app/app/hooks/use-passkey-login.js

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/*
2-
* Copyright (c) 2024, Salesforce, Inc.
2+
* Copyright (c) 2026, Salesforce, Inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
88
import {useAuthHelper, AuthHelpers, useUsid} from '@salesforce/commerce-sdk-react'
9-
import {encode as base64Encode} from 'base64-arraybuffer'
9+
import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils'
1010

1111
/**
1212
* This hook provides commerce-react-sdk hooks to simplify the passkey login flow.
@@ -16,12 +16,6 @@ export const usePasskeyLogin = () => {
1616
const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication)
1717
const {usid} = useUsid()
1818

19-
const uint8arrayToBase64url = (input) => {
20-
const uint8array = new Uint8Array(input)
21-
const base64 = base64Encode(uint8array.buffer)
22-
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
23-
}
24-
2519
const loginWithPasskey = async () => {
2620
const config = getConfig()
2721

@@ -83,14 +77,16 @@ export const usePasskeyLogin = () => {
8377
// In this case, we manually encode the credential.
8478
encodedCredential = {
8579
id: credential.id,
86-
rawId: uint8arrayToBase64url(credential.rawId),
80+
rawId: arrayBufferToBase64Url(credential.rawId),
8781
type: credential.type,
8882
clientExtensionResults: credential.getClientExtensionResults(),
8983
response: {
90-
authenticatorData: uint8arrayToBase64url(credential.response.authenticatorData),
91-
clientDataJSON: uint8arrayToBase64url(credential.response.clientDataJSON),
92-
signature: uint8arrayToBase64url(credential.response.signature),
93-
userHandle: uint8arrayToBase64url(credential.response.userHandle)
84+
authenticatorData: arrayBufferToBase64Url(
85+
credential.response.authenticatorData
86+
),
87+
clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON),
88+
signature: arrayBufferToBase64Url(credential.response.signature),
89+
userHandle: arrayBufferToBase64Url(credential.response.userHandle)
9490
}
9591
}
9692
}

packages/template-retail-react-app/app/hooks/use-passkey-login.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024, salesforce.com, inc.
2+
* Copyright (c) 2026, salesforce.com, inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause

packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,13 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur
4343
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
4444
import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react'
4545
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
46-
import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
47-
import {getPasswordlessErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils'
48-
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
46+
import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
47+
import {usePasskeyLogin} from '@salesforce/retail-react-app/app/hooks/use-passkey-login'
48+
import {
49+
API_ERROR_MESSAGE,
50+
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
51+
PASSWORDLESS_ERROR_MESSAGES
52+
} from '@salesforce/retail-react-app/app/constants'
4953

5054
const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, idps = []}) => {
5155
const {formatMessage} = useIntl()
@@ -75,10 +79,11 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
7579

7680
const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW)
7781
const authModal = useAuthModal(authModalView)
78-
const passwordlessConfig = getConfig().app.login?.passwordless
79-
const passwordlessConfigMode = passwordlessConfig?.mode
80-
const passwordlessConfigCallback = passwordlessConfig?.callbackURI
81-
const callbackURL = absoluteUrl(passwordlessConfigCallback)
82+
const config = getConfig()
83+
const passwordlessConfigCallback = config.app.login?.passwordless?.callbackURI
84+
const callbackURL = isAbsoluteURL(passwordlessConfigCallback)
85+
? passwordlessConfigCallback
86+
: `${appOrigin}${getEnvBasePath()}${passwordlessConfigCallback}`
8287

8388
const handlePasswordlessLogin = async (email) => {
8489
try {

packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,26 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => {
5757
}
5858
})
5959

60-
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
61-
getConfig: jest.fn()
62-
}))
60+
const mockLoginWithPasskey = jest.fn().mockResolvedValue(undefined)
6361

64-
beforeEach(() => {
65-
getConfig.mockImplementation(() => mockConfig)
66-
global.server.use(
67-
rest.post('*/oauth2/login', (req, res, ctx) => {
68-
return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer))
69-
})
70-
)
62+
jest.mock('@salesforce/retail-react-app/app/hooks/use-passkey-login', () => {
63+
return {
64+
__esModule: true,
65+
usePasskeyLogin: jest.fn(() => ({
66+
loginWithPasskey: mockLoginWithPasskey
67+
}))
68+
}
7169
})
7270

71+
const mockUseCurrentCustomer = jest.fn(() => ({
72+
data: {
73+
isRegistered: false
74+
}
75+
}))
76+
jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
77+
useCurrentCustomer: () => mockUseCurrentCustomer()
78+
}))
79+
7380
afterEach(() => {
7481
jest.resetModules()
7582
jest.restoreAllMocks()

packages/template-retail-react-app/app/utils/utils.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
9+
import {encode as base64Encode} from 'base64-arraybuffer'
910

1011
/**
1112
* Call requestIdleCallback in supported browsers.
@@ -222,3 +223,16 @@ export const buildRedirectURI = (appOrigin = '', redirectPath = '') => {
222223
return ''
223224
}
224225
}
226+
227+
/**
228+
* Converts an ArrayBuffer or Uint8Array to a base64url-encoded string.
229+
* Base64url encoding is URL-safe (uses '-' and '_' instead of '+' and '/', and omits padding).
230+
*
231+
* @param {ArrayBuffer|Uint8Array} input - The buffer to encode
232+
* @returns {string} Base64url-encoded string
233+
*/
234+
export const arrayBufferToBase64Url = (input) => {
235+
const uint8array = new Uint8Array(input)
236+
const base64 = base64Encode(uint8array.buffer)
237+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
238+
}

packages/template-retail-react-app/app/utils/utils.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,39 @@ describe('buildRedirectURI', function () {
203203
expect(result).toBe('')
204204
})
205205
})
206+
207+
describe('arrayBufferToBase64Url', () => {
208+
test.each([
209+
['empty ArrayBuffer', new ArrayBuffer(0), ''],
210+
['single byte', new Uint8Array([65]).buffer, 'QQ'],
211+
['multiple bytes', new Uint8Array([72, 101, 108, 108, 111]).buffer, 'SGVsbG8'],
212+
['bytes with padding', new Uint8Array([77, 97, 110]).buffer, 'TWFu'],
213+
['binary data', new Uint8Array([0, 1, 2, 255, 254, 253]).buffer, 'AAEC__79']
214+
])('converts %s to base64url', (_, input, expected) => {
215+
const result = utils.arrayBufferToBase64Url(input)
216+
expect(result).toBe(expected)
217+
})
218+
219+
test.each([
220+
['Uint8Array', new Uint8Array([72, 101, 108, 108, 111])],
221+
['ArrayBuffer', new Uint8Array([72, 101, 108, 108, 111]).buffer]
222+
])('accepts %s input type', (_, input) => {
223+
const result = utils.arrayBufferToBase64Url(input)
224+
expect(result).toBe('SGVsbG8')
225+
})
226+
227+
test('produces URL-safe base64 (no +, /, or = characters)', () => {
228+
// Use data that would produce +, /, or = in standard base64
229+
const input = new Uint8Array([251, 239, 191]) // Produces base64 with + and /
230+
const result = utils.arrayBufferToBase64Url(input)
231+
expect(result).not.toMatch(/[+/=]/)
232+
expect(result).toMatch(/^[A-Za-z0-9_-]*$/)
233+
})
234+
235+
test('handles large buffers', () => {
236+
const largeArray = new Uint8Array(1000).fill(65) // 1000 'A' characters
237+
const result = utils.arrayBufferToBase64Url(largeArray)
238+
expect(result.length).toBeGreaterThan(0)
239+
expect(result).not.toMatch(/[+/=]/)
240+
})
241+
})

0 commit comments

Comments
 (0)