Skip to content

Commit 9b304ef

Browse files
authored
@W-20224082 - [Webauthn] Create passkey in browser and register in SLAS (#3584)
1 parent 22df1c1 commit 9b304ef

File tree

10 files changed

+584
-10
lines changed

10 files changed

+584
-10
lines changed

packages/template-retail-react-app/app/components/otp-auth/index.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ const OtpAuth = ({
145145
resendAttempt: true
146146
})
147147
await handleSendEmailOtp(form.getValues('email'))
148+
otpInputs.clear()
148149
} catch (error) {
149150
setResendTimer(0)
150151
await track('/otp-resend-failed', {

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

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => {
5252
const config = getConfig()
5353
const webauthnConfig = config.app.login.passkey
5454
const authorizeWebauthnRegistration = useAuthHelper(AuthHelpers.AuthorizeWebauthnRegistration)
55+
const startWebauthnUserRegistration = useAuthHelper(AuthHelpers.StartWebauthnUserRegistration)
56+
const finishWebauthnUserRegistration = useAuthHelper(AuthHelpers.FinishWebauthnUserRegistration)
5557

5658
const handleRegisterPasskey = async () => {
5759
setIsLoading(true)
@@ -82,9 +84,109 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => {
8284
}
8385
}
8486

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+
85100
const handleOtpVerification = async (code) => {
86-
// TODO: Implement OTP verification
87-
return {success: true}
101+
setIsLoading(true)
102+
setError(null)
103+
104+
try {
105+
// Step 1: Start WebAuthn registration
106+
const response = await startWebauthnUserRegistration.mutateAsync({
107+
user_id: customer.email,
108+
pwd_action_token: code,
109+
...(passkeyNickname && {nick_name: passkeyNickname})
110+
})
111+
112+
// Step 2: Convert response to WebAuthn PublicKeyCredentialCreationOptions format
113+
const publicKey = window.PublicKeyCredential.parseCreationOptionsFromJSON(response)
114+
115+
// Step 3: Call navigator.credentials.create()
116+
if (!navigator.credentials || !navigator.credentials.create) {
117+
throw new Error('WebAuthn API not available in this browser')
118+
}
119+
120+
// navigator.credentials.create() will show a browser/system prompt
121+
// This may appear to hang if the user doesn't interact with the prompt
122+
let credential
123+
try {
124+
credential = await navigator.credentials.create({
125+
publicKey
126+
})
127+
} catch (createError) {
128+
// Handle user cancellation or other errors from the WebAuthn API
129+
if (createError.name === 'NotAllowedError' || createError.name === 'AbortError') {
130+
throw new Error('Passkey registration was cancelled or timed out')
131+
}
132+
throw createError
133+
}
134+
135+
if (!credential) {
136+
throw new Error('Failed to create credential: user cancelled or operation failed')
137+
}
138+
139+
// Step 4: Convert credential to JSON format before sending to SLAS
140+
// https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/toJSON
141+
let credentialJson
142+
try {
143+
credentialJson = credential.toJSON()
144+
} catch (error) {
145+
// Fallback to manual encoding if toJSON() fails
146+
// Some passkey providers (e.g., 1Password) may not support the toJSON() method and return an error
147+
const clientExtensionResults = credential.getClientExtensionResults?.() || {}
148+
credentialJson = {
149+
type: credential.type,
150+
id: credential.id,
151+
rawId: arrayBufferToBase64Url(credential.rawId),
152+
response: {
153+
attestationObject: arrayBufferToBase64Url(
154+
credential.response.attestationObject
155+
),
156+
clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON)
157+
},
158+
...(Object.keys(clientExtensionResults).length > 0 && {clientExtensionResults})
159+
}
160+
}
161+
162+
// Step 5: Finish WebAuthn registration
163+
await finishWebauthnUserRegistration.mutateAsync({
164+
username: customer.email,
165+
credential: credentialJson,
166+
pwd_action_token: code
167+
})
168+
169+
// Step 6: Close OTP modal and main modal on success
170+
setIsOtpAuthOpen(false)
171+
onClose()
172+
173+
return {success: true}
174+
} catch (err) {
175+
const errorMessage =
176+
err.message ||
177+
formatMessage({
178+
id: 'passkey_registration.modal.error.registration_failed',
179+
defaultMessage: 'Failed to register passkey'
180+
})
181+
182+
// Return error result for OTP component to display
183+
return {
184+
success: false,
185+
error: errorMessage
186+
}
187+
} finally {
188+
setIsLoading(false)
189+
}
88190
}
89191

90192
const resetState = () => {

0 commit comments

Comments
 (0)