diff --git a/e2e/scripts/pageHelpers.js b/e2e/scripts/pageHelpers.js index 1269e427e0..3bcec94e73 100644 --- a/e2e/scripts/pageHelpers.js +++ b/e2e/scripts/pageHelpers.js @@ -7,6 +7,7 @@ const {expect} = require('@playwright/test') const config = require('../config') const {getCreditCardExpiry, runAccessibilityTest} = require('../scripts/utils.js') +const crypto = require('crypto') /** * Note: As a best practice, we should await the network call and assert on the network response rather than waiting for pageLoadState() * to avoid race conditions from lock in pageLoadState being released before network call resolves. @@ -791,3 +792,85 @@ export const selectStoreFromPLP = async ({page}) => { await page.getByRole('button', {name: 'Close'}).click() } } + +/** + * Validates that a passkey login request is made to the /webAuthn/authenticate/finish endpoint. + * We can't register an actual passkey in the E2E environment because registration requires a token verification. + * Instead,we add a mock credential to the virtual authenticator to bypass the registration flow and verify the + * request to the /webAuthn/authenticate/finish endpoint. + * + * @param {Object} options.page - Playwright page object representing a browser tab/window + */ +export const validatePasskeyLogin = async ({page}) => { + // Start a CDP session to interact with WebAuthn + const client = await page.context().newCDPSession(page) + await client.send('WebAuthn.enable') + // Create a virtual authenticator to simulate a hardware authenticator for testing + const {authenticatorId} = await client.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + // Enabling automaticPresenceSimulation automatically completes the device's passkey prompt without user interaction + automaticPresenceSimulation: true + } + }) + + // Preload mock credential into the virtual authenticator + const rpId = new URL(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME).hostname + // Generate a valid EC key pair for WebAuthn (ES256/P-256) + const {privateKey} = crypto.generateKeyPairSync('ec', {namedCurve: 'P-256'}) + const privateKeyBase64 = privateKey.export({format: 'der', type: 'pkcs8'}).toString('base64') + + console.log('privateKeyBase64', privateKeyBase64) + const credentialIdBuffer = Buffer.from('mock-credential-id-' + Date.now()) + const credentialIdBase64 = credentialIdBuffer.toString('base64') // For mock credential + const credentialId = credentialIdBuffer.toString('base64url') // For verifying the request + await client.send('WebAuthn.addCredential', { + authenticatorId, + credential: { + credentialId: credentialIdBase64, + isResidentCredential: true, + rpId, + privateKey: privateKeyBase64, + userHandle: Buffer.from('test-user-handle').toString('base64'), + signCount: 0, + transports: ['internal'] + } + }) + + let interceptedRequest = null + + // Intercept the WebAuthn authenticate/finish endpoint to verify the request + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/webauthn/authenticate/finish', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') + + // Wait for the WebAuthn authenticate/finish request + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/webauthn/authenticate/finish' + ) + + // Verify the /webAuthn/authenticate/finish request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + const postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + const requestBody = JSON.parse(postData) + expect(requestBody).toBeTruthy() + + // Verify the request body structure matches expected format + expect(requestBody.client_id).toBeTruthy() + expect(requestBody.channel_id).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + expect(requestBody.credential.id).toBe(credentialId) + expect(requestBody.credential.clientExtensionResults).toBeTruthy() + expect(requestBody.credential.response).toBeTruthy() +} diff --git a/e2e/tests/desktop/extra-features.spec.js b/e2e/tests/desktop/extra-features.spec.js index b6f7b1e993..c8928f7066 100644 --- a/e2e/tests/desktop/extra-features.spec.js +++ b/e2e/tests/desktop/extra-features.spec.js @@ -5,192 +5,209 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -const {test, expect} = require('@playwright/test') +const {test, expect, describe} = require('@playwright/test') const config = require('../../config.js') const {generateUserCredentials} = require('../../scripts/utils.js') -const {answerConsentTrackingForm} = require('../../scripts/pageHelpers.js') +const {answerConsentTrackingForm, validatePasskeyLogin} = require('../../scripts/pageHelpers.js') const GUEST_USER_CREDENTIALS = generateUserCredentials() -/** - * Test that a user can login with passwordless login on mobile. There is no programmatic way to check the email, - * so we will check that the necessary API call is being made and expected UI is shown - */ -test('Verify passwordless login request', async ({page}) => { - let interceptedRequest = null - - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) - - await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') - await answerConsentTrackingForm(page) - - await page.locator('#email').scrollIntoViewIfNeeded() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) - - await page.getByRole('button', {name: 'Continue'}).click() - - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login' - ) - - // Verify the passwordless login request - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') - - let postData = interceptedRequest.postData() - expect(postData).toBeTruthy() - - let params = new URLSearchParams(postData) - - expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) - expect(params.get('mode')).toBe('email') - expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) - - // Wait for OTP input fields to appear and fill the 8-digit code - const otpCode = '12345678' // Replace with actual OTP code - const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]') - await otpInputs.first().waitFor() - - // Fill each input field with one digit - for (let i = 0; i < 8; i++) { - await otpInputs.nth(i).fill(otpCode[i]) - } - - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token' - ) - - // Verify the passwordless login token request - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') - postData = interceptedRequest.postData() - expect(postData).toBeTruthy() - params = new URLSearchParams(postData) - expect(params.get('pwdless_login_token')).toBe(otpCode) - expect(params.get('hint')).toBe('pwdless_login') +describe('Passkey Login', () => { + test('Verify passkey login', async ({page}) => { + // Override the global test timeout to 70s to ensure the test can complete + test.setTimeout(70000) + // webauthn/authenticate/start has a 60s cooldown; wait 1 min to ensure it can be called again + await page.waitForTimeout(60000) + await validatePasskeyLogin({page}) + }) }) -test('Verify password reset request', async ({page}) => { - let interceptedRequest = null - - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset', - (route) => { - interceptedRequest = route.request() - route.continue() +describe('Passwordless login', () => { + /** + * Test that a user can login with passwordless login. There is no programmatic way to check the email, + * so we will check that the necessary API call is being made and expected UI is shown + */ + test('Verify passwordless login request', async ({page}) => { + let interceptedRequest = null + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') + await answerConsentTrackingForm(page) + + await page.locator('#email').scrollIntoViewIfNeeded() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) + + await page.getByRole('button', {name: 'Continue'}).click() + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login' + ) + + // Verify the passwordless login request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + + let postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + + let params = new URLSearchParams(postData) + + expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) + expect(params.get('mode')).toBe('email') + expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + // Wait for OTP input fields to appear and fill the 8-digit code + const otpCode = '12345678' // Replace with actual OTP code + const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]') + await otpInputs.first().waitFor() + + // Fill each input field with one digit + for (let i = 0; i < 8; i++) { + await otpInputs.nth(i).fill(otpCode[i]) } - ) - - await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') - await answerConsentTrackingForm(page) - - await page.locator('#email').scrollIntoViewIfNeeded() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) - - await page.getByRole('button', {name: 'Password'}).click() - await page.getByRole('button', {name: 'Forgot password?'}).click() - - await page.fill('#email', config.PWA_E2E_USER_EMAIL) - await page.getByRole('button', {name: 'Reset Password'}).click() - - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset' - ) - - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') - - const postData = interceptedRequest.postData() - expect(postData).toBeTruthy() - const params = new URLSearchParams(postData) - - expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) - expect(params.get('mode')).toBe('email') - expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) - expect(params.get('hint')).toBe('cross_device') -}) - -// Verify on the login UI that looks different when extra login features are not enabled -test('Verify password reset request when extra login features are not enabled', async ({page}) => { - let interceptedRequest = null - - await page.route( - '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) - - await page.goto(config.RETAIL_APP_HOME + '/login') - await answerConsentTrackingForm(page) - - await page.locator('#email').scrollIntoViewIfNeeded() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) - - await page.getByRole('button', {name: 'Forgot password?'}).click() - - await page.waitForSelector('form[data-testid="sf-auth-modal-form"] >> text=Reset Password') - await page.fill('form[data-testid="sf-auth-modal-form"] #email', config.PWA_E2E_USER_EMAIL) - await page.getByRole('button', {name: /reset password/i}).click() - await page.waitForResponse( - '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset' - ) - - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') - - const postData = interceptedRequest.postData() - expect(postData).toBeTruthy() - - const params = new URLSearchParams(postData) - - expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) - expect(params.get('mode')).toBe('email') - expect(params.get('channel_id')).toBe(config.RETAIL_APP_HOME_SITE) - expect(params.get('hint')).toBe('cross_device') + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token' + ) + + // Verify the passwordless login token request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + params = new URLSearchParams(postData) + expect(params.get('pwdless_login_token')).toBe(otpCode) + expect(params.get('hint')).toBe('pwdless_login') + }) }) -test('Verify password reset action request', async ({page}) => { - let interceptedRequest = null - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) - - await page.goto( - config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + - `/reset-password-landing?token=1234567&email=${GUEST_USER_CREDENTIALS.email}` - ) - await answerConsentTrackingForm(page) - - await page.fill('#password', GUEST_USER_CREDENTIALS.password) - await page.fill('#confirmPassword', GUEST_USER_CREDENTIALS.password) - - expect(await page.inputValue('#password')).toBe(GUEST_USER_CREDENTIALS.password) - expect(await page.inputValue('#confirmPassword')).toBe(GUEST_USER_CREDENTIALS.password) - await page.getByRole('button', {name: 'Reset Password'}).click() - - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action' - ) +describe('Password reset', () => { + test('Verify password reset request', async ({page}) => { + let interceptedRequest = null + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') + await answerConsentTrackingForm(page) + + await page.locator('#email').scrollIntoViewIfNeeded() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) + + await page.getByRole('button', {name: 'Password'}).click() + await page.getByRole('button', {name: 'Forgot password?'}).click() + + await page.fill('#email', config.PWA_E2E_USER_EMAIL) + await page.getByRole('button', {name: 'Reset Password'}).click() + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset' + ) + + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + + const postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + + const params = new URLSearchParams(postData) + + expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) + expect(params.get('mode')).toBe('email') + expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + expect(params.get('hint')).toBe('cross_device') + }) + + // Verify on the login UI that looks different when extra login features are not enabled + test('Verify password reset request when extra login features are not enabled', async ({ + page + }) => { + let interceptedRequest = null + + await page.route( + '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + await page.goto(config.RETAIL_APP_HOME + '/login') + await answerConsentTrackingForm(page) + + await page.locator('#email').scrollIntoViewIfNeeded() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) + + await page.getByRole('button', {name: 'Forgot password?'}).click() + + await page.waitForSelector('form[data-testid="sf-auth-modal-form"] >> text=Reset Password') + await page.fill('form[data-testid="sf-auth-modal-form"] #email', config.PWA_E2E_USER_EMAIL) + await page.getByRole('button', {name: /reset password/i}).click() + await page.waitForResponse( + '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset' + ) + + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + + const postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + + const params = new URLSearchParams(postData) + + expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) + expect(params.get('mode')).toBe('email') + expect(params.get('channel_id')).toBe(config.RETAIL_APP_HOME_SITE) + expect(params.get('hint')).toBe('cross_device') + }) + + test('Verify password reset action request', async ({page}) => { + let interceptedRequest = null + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + await page.goto( + config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + + `/reset-password-landing?token=1234567&email=${GUEST_USER_CREDENTIALS.email}` + ) + await answerConsentTrackingForm(page) + + await page.fill('#password', GUEST_USER_CREDENTIALS.password) + await page.fill('#confirmPassword', GUEST_USER_CREDENTIALS.password) + + expect(await page.inputValue('#password')).toBe(GUEST_USER_CREDENTIALS.password) + expect(await page.inputValue('#confirmPassword')).toBe(GUEST_USER_CREDENTIALS.password) + await page.getByRole('button', {name: 'Reset Password'}).click() + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action' + ) - expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest).toBeTruthy() + }) }) diff --git a/e2e/tests/mobile/extra-features.spec.js b/e2e/tests/mobile/extra-features.spec.js index e521f9bd4d..29be2e41db 100644 --- a/e2e/tests/mobile/extra-features.spec.js +++ b/e2e/tests/mobile/extra-features.spec.js @@ -5,201 +5,215 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -const {test, expect} = require('@playwright/test') +const {test, expect, describe} = require('@playwright/test') const config = require('../../config.js') const {generateUserCredentials} = require('../../scripts/utils.js') -const {answerConsentTrackingForm} = require('../../scripts/pageHelpers.js') +const {answerConsentTrackingForm, validatePasskeyLogin} = require('../../scripts/pageHelpers.js') const GUEST_USER_CREDENTIALS = generateUserCredentials() -/** - * Test that a user can login with passwordless login on mobile. There is no programmatic way to check the email, - * so we will check that the necessary API call is being made and expected UI is shown - */ -test('Verify passwordless login request on mobile', async ({page}) => { - let interceptedRequest = null - - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) - - await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') - await answerConsentTrackingForm(page) - - await page.locator('#email').scrollIntoViewIfNeeded() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) - - await page.getByRole('button', {name: 'Continue'}).scrollIntoViewIfNeeded() - await page.getByRole('button', {name: 'Continue'}).click() - - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login' - ) - - // Verify the passwordless login request - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') - - let postData = interceptedRequest.postData() - expect(postData).toBeTruthy() - - let params = new URLSearchParams(postData) - - expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) - expect(params.get('mode')).toBe('email') - expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) +describe('Passkey Login', () => { + test('Verify passkey login', async ({page}) => { + // Override the global test timeout to 70s to ensure the test can complete + test.setTimeout(70000) + // webauthn/authenticate/start has a 60s cooldown; wait 1 min to ensure it can be called again + await page.waitForTimeout(60000) + await validatePasskeyLogin({page}) + }) +}) - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token', - (route) => { - interceptedRequest = route.request() - route.continue() +describe('Passwordless login', () => { + /** + * Test that a user can login with passwordless login on mobile. There is no programmatic way to check the email, + * so we will check that the necessary API call is being made and expected UI is shown + */ + test('Verify passwordless login request on mobile', async ({page}) => { + let interceptedRequest = null + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') + await answerConsentTrackingForm(page) + + await page.locator('#email').scrollIntoViewIfNeeded() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) + + await page.getByRole('button', {name: 'Continue'}).scrollIntoViewIfNeeded() + await page.getByRole('button', {name: 'Continue'}).click() + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login' + ) + + // Verify the passwordless login request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + + let postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + + let params = new URLSearchParams(postData) + + expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) + expect(params.get('mode')).toBe('email') + expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + // Wait for OTP input fields to appear and fill the 8-digit code + const otpCode = '12345678' // Replace with actual OTP code + const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]') + await otpInputs.first().waitFor() + + // Fill each input field with one digit + for (let i = 0; i < 8; i++) { + await otpInputs.nth(i).fill(otpCode[i]) } - ) - - // Wait for OTP input fields to appear and fill the 8-digit code - const otpCode = '12345678' // Replace with actual OTP code - const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]') - await otpInputs.first().waitFor() - - // Fill each input field with one digit - for (let i = 0; i < 8; i++) { - await otpInputs.nth(i).fill(otpCode[i]) - } - - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token' - ) - - // Verify the passwordless login token request - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') - postData = interceptedRequest.postData() - expect(postData).toBeTruthy() - params = new URLSearchParams(postData) - expect(params.get('pwdless_login_token')).toBe(otpCode) - expect(params.get('hint')).toBe('pwdless_login') + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token' + ) + + // Verify the passwordless login token request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + params = new URLSearchParams(postData) + expect(params.get('pwdless_login_token')).toBe(otpCode) + expect(params.get('hint')).toBe('pwdless_login') + }) }) -test('Verify password reset request on mobile (extra features enabled)', async ({page}) => { - let interceptedRequest = null +describe('Password reset', () => { + test('Verify password reset request on mobile (extra features enabled)', async ({page}) => { + let interceptedRequest = null - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) - await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') - await answerConsentTrackingForm(page) + await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login') + await answerConsentTrackingForm(page) - await page.locator('#email').scrollIntoViewIfNeeded() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) + await page.locator('#email').scrollIntoViewIfNeeded() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) - await page.getByRole('button', {name: 'Password'}).click() - await page.getByRole('button', {name: 'Forgot password?'}).click() + await page.getByRole('button', {name: 'Password'}).click() + await page.getByRole('button', {name: 'Forgot password?'}).click() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) - await page.getByRole('button', {name: /reset password/i}).click() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) + await page.getByRole('button', {name: /reset password/i}).click() - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset' - ) + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/reset' + ) - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') - const postData = interceptedRequest.postData() - expect(postData).toBeTruthy() + const postData = interceptedRequest.postData() + expect(postData).toBeTruthy() - const params = new URLSearchParams(postData) + const params = new URLSearchParams(postData) - expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) - expect(params.get('mode')).toBe('email') - expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) - expect(params.get('hint')).toBe('cross_device') -}) + expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) + expect(params.get('mode')).toBe('email') + expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + expect(params.get('hint')).toBe('cross_device') + }) -test('Verify password reset request on mobile when extra login features are not enabled', async ({ - page -}) => { - let interceptedRequest = null + test('Verify password reset request on mobile when extra login features are not enabled', async ({ + page + }) => { + let interceptedRequest = null - await page.route( - '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) + await page.route( + '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) - await page.goto(config.RETAIL_APP_HOME + '/login') - await answerConsentTrackingForm(page) + await page.goto(config.RETAIL_APP_HOME + '/login') + await answerConsentTrackingForm(page) - await page.locator('#email').scrollIntoViewIfNeeded() - await page.fill('#email', config.PWA_E2E_USER_EMAIL) + await page.locator('#email').scrollIntoViewIfNeeded() + await page.fill('#email', config.PWA_E2E_USER_EMAIL) - await page.getByRole('button', {name: 'Forgot password?'}).click() + await page.getByRole('button', {name: 'Forgot password?'}).click() - await page.waitForSelector('form[data-testid="sf-auth-modal-form"] >> text=Reset Password') - await page.fill('form[data-testid="sf-auth-modal-form"] #email', config.PWA_E2E_USER_EMAIL) - await page.getByRole('button', {name: /reset password/i}).click() + await page.waitForSelector('form[data-testid="sf-auth-modal-form"] >> text=Reset Password') + await page.fill('form[data-testid="sf-auth-modal-form"] #email', config.PWA_E2E_USER_EMAIL) + await page.getByRole('button', {name: /reset password/i}).click() - await page.waitForResponse( - '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset' - ) + await page.waitForResponse( + '**/mobify/proxy/api/shopper/auth/v1/organizations/*/oauth2/password/reset' + ) - expect(interceptedRequest).toBeTruthy() - expect(interceptedRequest.method()).toBe('POST') + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') - const postData = interceptedRequest.postData() - expect(postData).toBeTruthy() + const postData = interceptedRequest.postData() + expect(postData).toBeTruthy() - const params = new URLSearchParams(postData) + const params = new URLSearchParams(postData) - expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) - expect(params.get('mode')).toBe('email') - expect(params.get('channel_id')).toBe(config.RETAIL_APP_HOME_SITE) - expect(params.get('hint')).toBe('cross_device') -}) + expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) + expect(params.get('mode')).toBe('email') + expect(params.get('channel_id')).toBe(config.RETAIL_APP_HOME_SITE) + expect(params.get('hint')).toBe('cross_device') + }) -test('Verify password reset action request on mobile', async ({page}) => { - let interceptedRequest = null - await page.route( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action', - (route) => { - interceptedRequest = route.request() - route.continue() - } - ) + test('Verify password reset action request on mobile', async ({page}) => { + let interceptedRequest = null + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) - await page.goto( - config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + - `/reset-password-landing?token=1234567&email=${GUEST_USER_CREDENTIALS.email}` - ) - await answerConsentTrackingForm(page) + await page.goto( + config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + + `/reset-password-landing?token=1234567&email=${GUEST_USER_CREDENTIALS.email}` + ) + await answerConsentTrackingForm(page) - await page.locator('#password').scrollIntoViewIfNeeded() - await page.fill('#password', GUEST_USER_CREDENTIALS.password) + await page.locator('#password').scrollIntoViewIfNeeded() + await page.fill('#password', GUEST_USER_CREDENTIALS.password) - await page.locator('#confirmPassword').scrollIntoViewIfNeeded() - await page.fill('#confirmPassword', GUEST_USER_CREDENTIALS.password) + await page.locator('#confirmPassword').scrollIntoViewIfNeeded() + await page.fill('#confirmPassword', GUEST_USER_CREDENTIALS.password) - expect(await page.inputValue('#password')).toBe(GUEST_USER_CREDENTIALS.password) - expect(await page.inputValue('#confirmPassword')).toBe(GUEST_USER_CREDENTIALS.password) + expect(await page.inputValue('#password')).toBe(GUEST_USER_CREDENTIALS.password) + expect(await page.inputValue('#confirmPassword')).toBe(GUEST_USER_CREDENTIALS.password) - await page.getByRole('button', {name: 'Reset Password'}).scrollIntoViewIfNeeded() - await page.getByRole('button', {name: 'Reset Password'}).click() + await page.getByRole('button', {name: 'Reset Password'}).scrollIntoViewIfNeeded() + await page.getByRole('button', {name: 'Reset Password'}).click() - await page.waitForResponse( - '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action' - ) + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/password/action' + ) - expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest).toBeTruthy() + }) }) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index 4495c35674..d2dba8386c 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "5.1.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "5.0.0", + "commerce-sdk-isomorphic": "5.0.0-unstable-20260202081607", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -920,9 +920,9 @@ "license": "MIT" }, "node_modules/commerce-sdk-isomorphic": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0.tgz", - "integrity": "sha512-9E0wEKq3pBoAdmjLByBdDVfcNbmo+G61WdxpMpHKtRzIlzbVhjLTNIrxxzRGP+267iTCbJg9sgB8SJLjG2hxTg==", + "version": "5.0.0-unstable-20260202081607", + "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0-unstable-20260202081607.tgz", + "integrity": "sha512-Q7RCT8QIIgqy7vHzKvAGaOBAURZ3JkdfG1KvhE9/zNfrgMTKx63QvyICVsaStOOkvKZML5hTOrgQ/Q4jqZubBA==", "license": "BSD-3-Clause", "dependencies": { "nanoid": "^3.3.8", diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index c82d251d10..20e6f50ad9 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "5.0.0", + "commerce-sdk-isomorphic": "5.0.0-unstable-20260202081607", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index d8331ea5a2..cc06da04b9 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -66,6 +66,13 @@ jest.mock('commerce-sdk-isomorphic', () => { credentials: config?.fetchOptions?.credentials || 'same-origin' } }, + authorizeWebauthnRegistration: jest.fn().mockResolvedValue({}), + startWebauthnUserRegistration: jest.fn().mockResolvedValue({}), + finishWebauthnUserRegistration: jest.fn().mockResolvedValue({}), + startWebauthnAuthentication: jest.fn().mockResolvedValue({}), + finishWebauthnAuthentication: jest + .fn() + .mockResolvedValue({tokenResponse: TOKEN_RESPONSE}), getPasswordResetToken: jest.fn().mockResolvedValue({}), resetPassword: jest.fn().mockResolvedValue({}) })) @@ -1502,3 +1509,286 @@ describe('hybridAuthEnabled property toggles clearECOMSession', () => { expect(auth.get('dwsid')).toBe('test-dwsid-value') }) }) + +describe('Webauthn', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const PUBLIC_KEY_CREDENTIAL_JSON: ShopperLoginTypes.PublicKeyCredentialJson = { + id: 'credential-id', + rawId: 'raw-credential-id', + type: 'public-key', + response: { + authenticatorData: [], + clientDataJSON: [], + signature: [], + userHandle: null + } as ShopperLoginTypes.AuthenticatorAssertionResponseJson + } + + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + mode: 'email', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + locale: 'en-GB', + code_challenge: 'test-code-challenge', + callback_uri: 'https://example.com/callback', + idp_name: 'customIdp', + hint: 'custom_hint' + }, + { + user_id: 'user@example.com', + mode: 'email', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + locale: 'en-GB', + code_challenge: 'test-code-challenge', + callback_uri: 'https://example.com/callback', + idp_name: 'customIdp', + hint: 'custom_hint' + } + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + user_id: 'user@example.com', + mode: 'email' + }, + { + user_id: 'user@example.com', + mode: 'email', + channel_id: config.siteId + } + ] + ])( + 'authorizeWebauthnRegistration %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.authorizeWebauthnRegistration( + input as ShopperLoginTypes.authorizeWebauthnRegistrationBodyType + ) + + expect((auth as any).client.authorizeWebauthnRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) + + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + display_name: 'Test Display Name', + nick_name: 'Test Nick Name' + }, + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + display_name: 'Test Display Name', + nick_name: 'Test Nick Name' + } + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token' + }, + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token', + channel_id: config.siteId + } + ] + ])( + 'startWebauthnUserRegistration %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.startWebauthnUserRegistration( + input as ShopperLoginTypes.startWebauthnUserRegistrationBodyType + ) + + expect((auth as any).client.startWebauthnUserRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) + + test.each([ + [ + 'with all parameters specified', + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' + }, + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' + } + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token' + }, + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token', + channel_id: config.siteId, + client_id: config.clientId + } + ] + ])( + 'finishWebauthnUserRegistration %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.finishWebauthnUserRegistration( + input as ShopperLoginTypes.RegistrationFinishRequest + ) + + expect((auth as any).client.finishWebauthnUserRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) + + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + tenant_id: 'tenant-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' + }, + { + user_id: 'user@example.com', + tenant_id: 'tenant-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' + } + ], + [ + 'defaults optional parameters when empty object is provided', + {}, + { + channel_id: config.siteId, + client_id: config.clientId + } + ] + ])( + 'startWebauthnAuthentication %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.startWebauthnAuthentication( + input as ShopperLoginTypes.startWebauthnAuthenticationBodyType + ) + + expect((auth as any).client.startWebauthnAuthentication).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) + + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + email: 'user@example.com', + tenant_id: 'tenant-123', + usid: 'usid-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + credential: PUBLIC_KEY_CREDENTIAL_JSON + }, + { + user_id: 'user@example.com', + email: 'user@example.com', + tenant_id: 'tenant-123', + usid: 'usid-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + credential: PUBLIC_KEY_CREDENTIAL_JSON + } + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + credential: PUBLIC_KEY_CREDENTIAL_JSON + }, + { + channel_id: config.siteId, + client_id: config.clientId, + credential: PUBLIC_KEY_CREDENTIAL_JSON + } + ] + ])( + 'finishWebauthnAuthentication %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.finishWebauthnAuthentication( + input as ShopperLoginTypes.AuthenticateFinishRequest + ) + + expect((auth as any).client.finishWebauthnAuthentication).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) +}) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index a9712a9373..a40aaa52aa 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1348,6 +1348,19 @@ class Auth { return token } + /** + * Get Basic auth header for private client requests. + * Returns undefined if not using a private client. + */ + private getBasicAuthHeader(client: ShopperLogin): string | undefined { + return ( + this.clientSecret && + `Basic ${stringToBase64( + `${client.clientConfig.parameters.clientId}:${this.clientSecret}` + )}` + ) + } + /** * A wrapper method for the SLAS endpoint: getPasswordResetToken. * @@ -1373,10 +1386,9 @@ class Auth { } // Only set authorization header if using private client - if (this.clientSecret) { - options.headers.Authorization = `Basic ${stringToBase64( - `${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}` - )}` + const authHeader = this.getBasicAuthHeader(slasClient) + if (authHeader) { + options.headers.Authorization = authHeader } // Set rawResponse to true to access the response body message for error handling @@ -1415,10 +1427,9 @@ class Auth { } // Only set authorization header if using private client - if (this.clientSecret) { - options.headers.Authorization = `Basic ${stringToBase64( - `${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}` - )}` + const authHeader = this.getBasicAuthHeader(slasClient) + if (authHeader) { + options.headers.Authorization = authHeader } const res = await this.client.resetPassword(options) return res @@ -1464,6 +1475,146 @@ class Auth { uido } } -} + /** + * A wrapper method for the SLAS endpoint: authorizeWebauthnRegistration. + */ + async authorizeWebauthnRegistration( + parameters: ShopperLoginTypes.authorizeWebauthnRegistrationBodyType + ) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + user_id: parameters.user_id, + mode: parameters.mode, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + // Optional params + ...(parameters.locale && {locale: parameters.locale}), + ...(parameters.client_id && {client_id: parameters.client_id}), + ...(parameters.code_challenge && {code_challenge: parameters.code_challenge}), + ...(parameters.callback_uri && {callback_uri: parameters.callback_uri}), + ...(parameters.idp_name && {idp_name: parameters.idp_name}), + ...(parameters.hint && {hint: parameters.hint}) + } + } + + return await slasClient.authorizeWebauthnRegistration(options) + } + + /** + * A wrapper method for the SLAS endpoint: startWebauthnUserRegistration. + */ + async startWebauthnUserRegistration( + parameters: ShopperLoginTypes.startWebauthnUserRegistrationBodyType + ) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + pwd_action_token: parameters.pwd_action_token, + user_id: parameters.user_id, + // Optional params + ...(parameters.display_name && {display_name: parameters.display_name}), + ...(parameters.nick_name && {nick_name: parameters.nick_name}), + ...(parameters.client_id && {client_id: parameters.client_id}) + } + } + + return await slasClient.startWebauthnUserRegistration(options) + } + + /** + * A wrapper method for the SLAS endpoint: finishWebauthnUserRegistration. + */ + async finishWebauthnUserRegistration(parameters: ShopperLoginTypes.RegistrationFinishRequest) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, + username: parameters.username, + credential: parameters.credential, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + pwd_action_token: parameters.pwd_action_token + } + } + + return await slasClient.finishWebauthnUserRegistration(options) + } + + /** + * A wrapper method for the SLAS endpoint: startWebauthnAuthentication. + */ + async startWebauthnAuthentication( + parameters: ShopperLoginTypes.startWebauthnAuthenticationBodyType + ) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + // Optional params + ...(parameters.user_id && {user_id: parameters.user_id}), + ...(parameters.tenant_id && {tenant_id: parameters.tenant_id}) + } + } + + return await slasClient.startWebauthnAuthentication(options) + } + + /** + * A wrapper method for the SLAS endpoint: finishWebauthnAuthentication. + */ + async finishWebauthnAuthentication(parameters: ShopperLoginTypes.AuthenticateFinishRequest) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + credential: parameters.credential, + // Optional params + ...(parameters.user_id && {user_id: parameters.user_id}), + ...(parameters.email && {email: parameters.email}), + ...(parameters.tenant_id && {tenant_id: parameters.tenant_id}), + ...(parameters.usid && {usid: parameters.usid}) + } + } + + const res = await slasClient.finishWebauthnAuthentication(options) + + const tokenResponse = res.tokenResponse + if (!tokenResponse) { + throw new Error('finishWebauthnAuthentication did not return a tokenResponse.') + } + + this.handleTokenResponse(tokenResponse, false) + + return tokenResponse + } +} export default Auth diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts index 23c3d86c5c..e8ca0df4c1 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts @@ -28,5 +28,11 @@ export const cacheUpdateMatrix: CacheUpdateMatrix = { resetPassword: noop, getPasswordLessAccessToken: noop, revokeToken: noop, - introspectToken: noop + introspectToken: noop, + // WebAuthn methods - these will be available when commerce-sdk-isomorphic is updated + startWebauthnUserRegistration: noop, + finishWebauthnUserRegistration: noop, + authorizeWebauthnRegistration: noop, + startWebauthnAuthentication: noop, + finishWebauthnAuthentication: noop } diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts index cf1ce4712d..65dd7b8967 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts @@ -20,12 +20,7 @@ describe('Shopper Login hooks', () => { // These endpoints all return data in the response headers, rather than body, so they // don't work well with the current implementation of mutation hooks. 'authenticateCustomer', - 'authorizeWebauthnRegistration', - 'finishWebauthnAuthentication', - 'finishWebauthnUserRegistration', - 'getTrustedAgentAuthorizationToken', - 'startWebauthnAuthentication', - 'startWebauthnUserRegistration' + 'getTrustedAgentAuthorizationToken' ]) }) test('all mutations have cache update logic', () => { diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts index dd14131491..dd00b3e640 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts @@ -28,6 +28,19 @@ const CLIENT_KEY = CLIENT_KEYS.SHOPPER_LOGIN type Client = NonNullable const loginEndpoint = '/shopper/auth/' + +const PUBLIC_KEY_CREDENTIAL_JSON: ShopperLoginTypes.PublicKeyCredentialJson = { + id: 'credential-id', + rawId: 'raw-credential-id', + type: 'public-key', + response: { + authenticatorData: [], + clientDataJSON: [], + signature: [], + userHandle: null + } as ShopperLoginTypes.AuthenticatorAssertionResponseJson +} + // Additional properties are ignored, so we can use this mega-options object for all endpoints const OPTIONS = { parameters: { @@ -46,6 +59,7 @@ const OPTIONS = { code: 'code', code_challenge: 'code_challenge', code_verifier: 'code_verifier', + credential: PUBLIC_KEY_CREDENTIAL_JSON, dwsid: 'dwsid', grant_type: 'client_credentials' as const, hint: 'hint', @@ -56,6 +70,7 @@ const OPTIONS = { pwd_action_token: 'pwd_action_token', pwdless_login_token: 'pwdless_login_token', redirect_uri: 'redirect_uri', + username: 'username', token: 'token', user_id: 'user_id' } @@ -73,6 +88,14 @@ const TOKEN_RESPONSE: ShopperLoginTypes.TokenResponse = { refresh_token_expires_in: 0 } +const PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS: ShopperLoginTypes.PublicKeyCredentialRequestOptions = { + challenge: 'challenge', + timeout: 60000, + rpId: 'rp-id', + allowCredentials: [], + userVerification: 'preferred' +} + // --- TEST CASES --- // type Implemented = ShopperLoginMutation // This is an object rather than an array to more easily ensure we cover all mutations @@ -91,7 +114,12 @@ const testMap: TestMap = { introspectToken: [OPTIONS, {token: 'token'}], resetPassword: [OPTIONS, undefined], revokeToken: [OPTIONS, {token: 'token'}], - logoutCustomer: [OPTIONS, TOKEN_RESPONSE] + logoutCustomer: [OPTIONS, TOKEN_RESPONSE], + startWebauthnUserRegistration: [OPTIONS, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS], + finishWebauthnUserRegistration: [OPTIONS, undefined], + authorizeWebauthnRegistration: [OPTIONS, undefined], + startWebauthnAuthentication: [OPTIONS, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS], + finishWebauthnAuthentication: [OPTIONS, TOKEN_RESPONSE] } // Type assertion is necessary because `Object.entries` is limited const testCases = Object.entries(testMap) as Array<[Implemented, TestMap[Implemented]]> diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts index 07acfc6fad..49d6b1614e 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts @@ -97,7 +97,32 @@ The value of the `_sfdc_client_auth` header must be a Base64-encoded string. The * Returns the token properties. A basic auth header with Base64-encoded `clientId:secret` is required in the Authorization header, as well as an access token or refresh token. Use `token_type_hint` to help identify the token. * @returns A TanStack Query mutation hook for interacting with the Shopper Login `introspectToken` endpoint. */ - IntrospectToken: 'introspectToken' + IntrospectToken: 'introspectToken', + /** + * Start WebAuthn passkey registration. Starts the WebAuthn registration process by generating credential creation options. Returns the challenge and other parameters needed by the authenticator to create a new credential. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `startWebauthnRegistration` endpoint. + */ + StartWebauthnUserRegistration: 'startWebauthnUserRegistration', + /** + * Finish WebAuthn passkey registration. Completes the WebAuthn registration process by verifying the credential created by the authenticator. Stores the public key and credential information for future authentication. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `finishWebauthnRegistration` endpoint. + */ + FinishWebauthnUserRegistration: 'finishWebauthnUserRegistration', + /** + * Authorize WebAuthn passkey registration. Authorizes a user to register a WebAuthn credential (passkey). This endpoint validates the user's credentials and creates a password action token that can be used to start the registration process. The token is sent to the user via the specified channel (email or SMS). + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `authorizeWebauthnRegistration` endpoint. + */ + AuthorizeWebauthnRegistration: 'authorizeWebauthnRegistration', + /** + * Start WebAuthn passkey authentication. Starts the WebAuthn authentication process by generating credential request options. Returns the challenge and allowed credentials for the user to authenticate with. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `startWebauthnAuthentication` endpoint. + */ + StartWebauthnAuthentication: 'startWebauthnAuthentication', + /** + * Finish WebAuthn passkey authentication. Completes the WebAuthn authentication process by verifying the assertion from the authenticator. Returns OAuth tokens upon successful authentication. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `finishWebauthnAuthentication` endpoint. + */ + FinishWebauthnAuthentication: 'finishWebauthnAuthentication' } as const /** diff --git a/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts b/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts index bd4a33f316..85f9c835f6 100644 --- a/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts +++ b/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts @@ -31,7 +31,12 @@ export const AuthHelpers = { Logout: 'logout', Register: 'register', ResetPassword: 'resetPassword', - UpdateCustomerPassword: 'updateCustomerPassword' + UpdateCustomerPassword: 'updateCustomerPassword', + StartWebauthnUserRegistration: 'startWebauthnUserRegistration', + FinishWebauthnUserRegistration: 'finishWebauthnUserRegistration', + AuthorizeWebauthnRegistration: 'authorizeWebauthnRegistration', + StartWebauthnAuthentication: 'startWebauthnAuthentication', + FinishWebauthnAuthentication: 'finishWebauthnAuthentication' } as const /** * @group Helpers diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index c59809fc7b..4e337ec648 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -102,6 +102,16 @@ module.exports = { // callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', // The landing path for reset password landingPath: '/reset-password-landing' + }, + passkey: { + // Enables or disables passkey login for the site. Defaults to: false + {{#if answers.project.demo.enableDemoSettings}} + enabled: true, + {{else}} + enabled: false, + {{/if}} + // The callback URI must be an absolute URL (i.e. third-party URI) set up by the developer. + callbackURI: process.env.PASSKEY_CALLBACK_URI } }, // The default site for your app. This value will be used when a siteRef could not be determined from the url diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 59b6bcb016..013179a9a6 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -102,6 +102,16 @@ module.exports = { // callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', // The landing path for reset password landingPath: '/reset-password-landing' + }, + passkey: { + // Enables or disables passkey login for the site. Defaults to: false + {{#if answers.project.demo.enableDemoSettings}} + enabled: true, + {{else}} + enabled: false, + {{/if}} + // The callback URI must be an absolute URL (i.e. third-party URI) set up by the developer. + callbackURI: process.env.PASSKEY_CALLBACK_URI } }, // The default site for your app. This value will be used when a siteRef could not be determined from the url diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index 565418dd43..f03cbc72a6 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -33,7 +33,10 @@ import { import {SkipNavLink, SkipNavContent} from '@chakra-ui/skip-nav' // Contexts -import {CurrencyProvider} from '@salesforce/retail-react-app/app/contexts' +import { + CurrencyProvider, + PasskeyRegistrationProvider +} from '@salesforce/retail-react-app/app/contexts' // Local Project Components import Header from '@salesforce/retail-react-app/app/components/header' @@ -328,145 +331,156 @@ const App = (props) => { defaultLocale={DEFAULT_LOCALE} > - - - - - - - {/* Urls for all localized versions of this page (including current page) - For more details on hrefLang, see https://developers.google.com/search/docs/advanced/crawling/localized-versions */} - {site.l10n?.supportedLocales.map((locale) => ( + + + + + + + + {/* Urls for all localized versions of this page (including current page) + For more details on hrefLang, see https://developers.google.com/search/docs/advanced/crawling/localized-versions */} + {site.l10n?.supportedLocales.map((locale) => ( + + ))} + {/* A general locale as fallback. For example: "en" if default locale is "en-GB" */} - ))} - {/* A general locale as fallback. For example: "en" if default locale is "en-GB" */} - - {/* A wider fallback for user locales that the app does not support */} - - - - {commerceAgentConfiguration?.enabled === 'true' && ( - 0} - /> - )} - - - - - Skip to Content - {storeLocatorEnabled && ( - + + + {commerceAgentConfiguration?.enabled === 'true' && ( + 0} /> )} - - - {!isCheckout ? ( - <> - -
- - - - - - - -
- - ) : ( - - )} -
-
- {!isOnline && } - - - - + + + Skip to Content + {storeLocatorEnabled && ( + + )} + + + {!isCheckout ? ( + <> + +
+ + + + + + + +
+ + ) : ( + + )} +
+
+ {!isOnline && } + + + - - {children} - -
-
- - - {!isCheckout ?