From f8dba5db5476f2d930c9b60ae590a882288d5b99 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 20 Nov 2025 08:55:43 -0700 Subject: [PATCH 1/5] chore: initial-jc-tests --- e2e/am-mock-api/src/app/routes.auth.js | 29 ++++++ .../components/ping-protect-evaluation.ts | 51 ++++++++++- .../components/ping-protect-initialize.ts | 71 ++++++++++++++- e2e/journey-app/package.json | 1 + e2e/journey-app/tsconfig.app.json | 3 + e2e/journey-app/tsconfig.json | 3 + e2e/journey-suites/src/protect.test.ts | 89 ++++++++++++++----- pnpm-lock.yaml | 41 +++++++++ 8 files changed, 265 insertions(+), 23 deletions(-) diff --git a/e2e/am-mock-api/src/app/routes.auth.js b/e2e/am-mock-api/src/app/routes.auth.js index 3d13208ea8..41e285c832 100644 --- a/e2e/am-mock-api/src/app/routes.auth.js +++ b/e2e/am-mock-api/src/app/routes.auth.js @@ -50,6 +50,11 @@ import { newPiWellKnown, } from './responses.js'; import initialRegResponse from './response.registration.js'; +import { + webAuthnRegistrationInit, + getRecoveryCodesDisplay, + authSuccess as webAuthnSuccess, +} from './response.webauthn.js'; import wait from './wait.js'; console.log(`Your user password from 'env.config' file: ${USERS[0].pw}`); @@ -93,6 +98,8 @@ export default function (app) { res.json(MetadataMarketPlaceInitialize); } else if (req.query.authIndexValue === 'AMSocialLogin') { res.json(idpChoiceCallback); + } else if (req.query.authIndexValue === 'TEST_WebAuthnWithRecoveryCodes') { + res.json(webAuthnRegistrationInit); } else if (req.query.authIndexValue === 'RecaptchaEnterprise') { res.json(initialBasicLogin); } else { @@ -414,6 +421,28 @@ export default function (app) { // an additional auth callback would be sent, like OTP res.json(authFail); } + } else if ( + req.query.authIndexValue === 'TEST_WebAuthnWithRecoveryCodes' || + req.body.authId?.startsWith('webauthn-registration') || + req.body.authId?.startsWith('recovery-codes') + ) { + // Handle WebAuthn registration with recovery codes journey + // Identify by authIndexValue (initial) or authId (subsequent) + const metadataCb = req.body.callbacks.find((cb) => cb.type === 'MetadataCallback'); + const hiddenCb = req.body.callbacks.find((cb) => cb.type === 'HiddenValueCallback'); + const confirmationCb = req.body.callbacks.find((cb) => cb.type === 'ConfirmationCallback'); + + if (metadataCb && hiddenCb && hiddenCb.input[0].value) { + // WebAuthn credential has been submitted, show recovery codes + res.json(getRecoveryCodesDisplay()); + } else if (confirmationCb) { + // Recovery codes have been acknowledged, complete authentication + res.cookie('iPlanetDirectoryPro', 'mock-webauthn-session-' + Date.now(), { domain: 'localhost' }); + res.json(webAuthnSuccess); + } else { + // Invalid state + res.status(401).json(authFail); + } } }); diff --git a/e2e/journey-app/components/ping-protect-evaluation.ts b/e2e/journey-app/components/ping-protect-evaluation.ts index 90ce809036..7ebd11d9b4 100644 --- a/e2e/journey-app/components/ping-protect-evaluation.ts +++ b/e2e/journey-app/components/ping-protect-evaluation.ts @@ -5,7 +5,12 @@ * of the MIT license. See the LICENSE file for details. */ import type { PingOneProtectEvaluationCallback } from '@forgerock/journey-client/types'; +import { getProtectInstance } from './ping-protect-initialize.js'; +/** + * PingOne Protect Evaluation Component + * Automatically collects device and behavioral signals using the Protect SDK + */ export default function pingProtectEvaluationComponent( journeyEl: HTMLDivElement, callback: PingOneProtectEvaluationCallback, @@ -19,5 +24,49 @@ export default function pingProtectEvaluationComponent( journeyEl?.appendChild(message); - // TODO: Implement PingOne Protect module evaluation here + // Automatically trigger Protect data collection + setTimeout(async () => { + try { + // Get the protect instance created during initialization + const protectInstance = getProtectInstance(); + + if (!protectInstance) { + throw new Error('Protect instance not initialized. Initialize callback must be called first.'); + } + + console.log('Collecting Protect signals...'); + + // Collect device and behavioral data + const result = await protectInstance.getData(); + + // Check if result is an error object + if (typeof result !== 'string' && 'error' in result) { + console.error('Error collecting Protect data:', result.error); + callback.setClientError(result.error); + message.innerText = `Data collection failed: ${result.error}`; + message.style.color = 'red'; + return; + } + + // Set the collected data on the callback + console.log('Protect data collected successfully'); + callback.setData(result); + message.innerText = 'Risk assessment completed successfully!'; + message.style.color = 'green'; + + // Auto-submit the form after successful data collection + setTimeout(() => { + const submitButton = document.getElementById('submitButton') as HTMLButtonElement; + if (submitButton) { + submitButton.click(); + } + }, 500); + } catch (error) { + console.error('Protect evaluation failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + callback.setClientError(errorMessage); + message.innerText = `Evaluation failed: ${errorMessage}`; + message.style.color = 'red'; + } + }, 100); } diff --git a/e2e/journey-app/components/ping-protect-initialize.ts b/e2e/journey-app/components/ping-protect-initialize.ts index c45215c6a4..f8a1b20c9e 100644 --- a/e2e/journey-app/components/ping-protect-initialize.ts +++ b/e2e/journey-app/components/ping-protect-initialize.ts @@ -4,8 +4,24 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +import { protect } from '@forgerock/protect'; import type { PingOneProtectInitializeCallback } from '@forgerock/journey-client/types'; +// Global storage for protect instance to be used by evaluation component +let protectInstance: ReturnType | null = null; + +/** + * Gets the stored protect instance + * @returns The protect instance or null if not initialized + */ +export function getProtectInstance() { + return protectInstance; +} + +/** + * PingOne Protect Initialize Component + * Automatically initializes the Protect SDK using configuration from the callback + */ export default function pingProtectInitializeComponent( journeyEl: HTMLDivElement, callback: PingOneProtectInitializeCallback, @@ -19,5 +35,58 @@ export default function pingProtectInitializeComponent( journeyEl?.appendChild(message); - // TODO: Implement PingOne Protect module initialization here + // Automatically trigger Protect initialization + setTimeout(async () => { + try { + // Get configuration from callback + const config = callback.getConfig(); + console.log('Protect callback config:', config); + + if (!config?.envId) { + const error = 'Missing envId in Protect configuration'; + console.error(error); + callback.setClientError(error); + message.innerText = `Initialization failed: ${error}`; + message.style.color = 'red'; + return; + } + + console.log('Initializing Protect with envId:', config.envId); + + // Create and store protect instance + protectInstance = protect({ envId: config.envId }); + console.log('Protect instance created'); + + // Initialize the Protect SDK + console.log('Calling protect.start()...'); + const result = await protectInstance.start(); + console.log('protect.start() result:', result); + + if (result?.error) { + console.error('Error initializing Protect:', result.error); + callback.setClientError(result.error); + message.innerText = `Initialization failed: ${result.error}`; + message.style.color = 'red'; + return; + } + + console.log('Protect initialized successfully - no errors'); + message.innerText = 'PingOne Protect initialized successfully!'; + message.style.color = 'green'; + + // Auto-submit the form after successful initialization + setTimeout(() => { + const submitButton = document.getElementById('submitButton') as HTMLButtonElement; + if (submitButton) { + submitButton.click(); + } + }, 500); + } catch (error) { + console.error('Protect initialization failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + callback.setClientError(errorMessage); + message.innerText = `Initialization failed: ${errorMessage}`; + message.style.color = 'red'; + } + }, 100); } diff --git a/e2e/journey-app/package.json b/e2e/journey-app/package.json index 09ae71130a..7a83f030d8 100644 --- a/e2e/journey-app/package.json +++ b/e2e/journey-app/package.json @@ -16,6 +16,7 @@ "dependencies": { "@forgerock/journey-client": "workspace:*", "@forgerock/oidc-client": "workspace:*", + "@forgerock/protect": "workspace:*", "@forgerock/sdk-logger": "workspace:*" } } diff --git a/e2e/journey-app/tsconfig.app.json b/e2e/journey-app/tsconfig.app.json index 3000be2f63..5d19cb58cd 100644 --- a/e2e/journey-app/tsconfig.app.json +++ b/e2e/journey-app/tsconfig.app.json @@ -19,6 +19,9 @@ { "path": "../../packages/oidc-client/tsconfig.lib.json" }, + { + "path": "../../packages/protect/tsconfig.lib.json" + }, { "path": "../../packages/journey-client/tsconfig.lib.json" } diff --git a/e2e/journey-app/tsconfig.json b/e2e/journey-app/tsconfig.json index cc7b958851..a7028763ec 100644 --- a/e2e/journey-app/tsconfig.json +++ b/e2e/journey-app/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/oidc-client" }, + { + "path": "../../packages/protect" + }, { "path": "../../packages/journey-client" }, diff --git a/e2e/journey-suites/src/protect.test.ts b/e2e/journey-suites/src/protect.test.ts index bb7eb3ee11..a709c32a43 100644 --- a/e2e/journey-suites/src/protect.test.ts +++ b/e2e/journey-suites/src/protect.test.ts @@ -9,32 +9,79 @@ import { expect, test } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; -test.skip('Test happy paths on test page', async ({ page }) => { - const { clickButton, navigate } = asyncEvents(page); - await navigate('/?journey=TEST_Protect'); +test.describe('PingOne Protect Journey', () => { + test('should complete journey with Protect initialization and evaluation', async ({ page }) => { + const { clickButton } = asyncEvents(page); - const messageArray: string[] = []; + const messageArray: string[] = []; - // Listen for events on page - page.on('console', async (msg) => { - messageArray.push(msg.text()); - return Promise.resolve(true); - }); + // Listen for console messages + page.on('console', async (msg) => { + messageArray.push(msg.text()); + return Promise.resolve(true); + }); + + // Navigate to PingOne Protect journey + // Use 'load' instead of 'networkidle' because Protect SDK makes continuous requests + await page.goto('/?journey=TEST_LoginPingProtect&clientId=basic', { waitUntil: 'load' }); + + // Step 1: Wait for Protect initialization to display and complete + await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); + + // Wait for initialization success message + await expect(page.getByText('PingOne Protect initialized successfully!')).toBeVisible({ + timeout: 15000, + }); + + // Submit the form to proceed to next step + await page.getByRole('button', { name: 'Submit' }).click(); + + // Wait for the journey to progress + await page.waitForTimeout(2000); + + // Debug: Print console messages + console.log('Console messages so far:', messageArray); + + // Step 2: Perform login with username and password + await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 15000 }); + await page.getByLabel('User Name').fill(username); + await page.getByLabel('Password').fill(password); - // Perform basic login - await page.getByLabel('User Name').fill(username); - await page.getByLabel('Password').fill(password); - await clickButton('Submit', '/authenticate'); + // Wait a bit to ensure input events have been processed + await page.waitForTimeout(500); - await expect(page.getByText('Collecting protect data')).toBeVisible(); + await clickButton('Submit', '/authenticate'); - await expect(page.getByText('Complete')).toBeVisible(); + // Step 3: Wait for Protect evaluation to display and complete + await expect(page.getByText('Evaluating risk assessment...')).toBeVisible({ timeout: 10000 }); - // Perform logout - await clickButton('Logout', '/authenticate'); + // Wait for evaluation success message + await expect(page.getByText('Risk assessment completed successfully!')).toBeVisible({ + timeout: 15000, + }); - // Test assertions - expect(messageArray.includes('Protect data collected successfully')).toBe(true); - expect(messageArray.includes('Journey completed successfully')).toBe(true); - expect(messageArray.includes('Logout successful')).toBe(true); + // Submit the form to complete the journey + await page.getByRole('button', { name: 'Submit' }).click(); + + // Wait for the journey to complete + await page.waitForTimeout(2000); + + // Step 4: Verify journey completion + await expect(page.getByText('Complete')).toBeVisible({ timeout: 10000 }); + + // Verify session token is present + const sessionToken = await page.locator('#sessionToken').textContent(); + expect(sessionToken).toBeTruthy(); + + // Step 5: Perform logout + await clickButton('Logout', '/authenticate'); + + // Verify we're back at the beginning + await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); + + // Test console log assertions + expect(messageArray.some((msg) => msg.includes('Protect initialized successfully'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Protect data collected successfully'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c94d201093..d8a2d2fa25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,44 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@reduxjs/toolkit': + specifier: ^2.8.2 + version: 2.10.1 + immer: + specifier: ^10.1.1 + version: 10.2.0 + msw: + specifier: ^2.5.1 + version: 2.12.1 + effect: + '@effect/cli': + specifier: ^0.69.0 + version: 0.69.2 + '@effect/language-service': + specifier: ^0.35.2 + version: 0.35.2 + '@effect/opentelemetry': + specifier: ^0.56.1 + version: 0.56.6 + '@effect/platform': + specifier: ^0.90.0 + version: 0.90.10 + '@effect/platform-node': + specifier: 0.94.2 + version: 0.94.2 + '@effect/vitest': + specifier: ^0.23.9 + version: 0.23.13 + effect: + specifier: ^3.17.2 + version: 3.19.3 + vitest: + vitest: + specifier: ^3.0.4 + version: 3.2.4 + importers: .: @@ -278,6 +316,9 @@ importers: '@forgerock/oidc-client': specifier: workspace:* version: link:../../packages/oidc-client + '@forgerock/protect': + specifier: workspace:* + version: link:../../packages/protect '@forgerock/sdk-logger': specifier: workspace:* version: link:../../packages/sdk-effects/logger From 2e8bca0d2b310ef3c0d97dc97c26512ce20bb09e Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 3 Dec 2025 09:26:17 -0700 Subject: [PATCH 2/5] chore: add-jc-tests --- e2e/am-mock-api/src/app/response.webauthn.js | 171 ++++++++++++++++++ e2e/am-mock-api/src/app/responses.js | 86 +++++++++ e2e/am-mock-api/src/app/routes.auth.js | 85 ++++----- e2e/journey-app/components/device-profile.ts | 41 ++++- .../components/ping-protect-evaluation.ts | 4 +- e2e/journey-suites/src/device-profile.test.ts | 29 +-- e2e/journey-suites/src/protect.test.ts | 93 +++------- e2e/journey-suites/src/qr-code.test.ts | 49 +++++ e2e/journey-suites/src/recovery-codes.test.ts | 62 +++++++ 9 files changed, 496 insertions(+), 124 deletions(-) create mode 100644 e2e/am-mock-api/src/app/response.webauthn.js create mode 100644 e2e/journey-suites/src/qr-code.test.ts create mode 100644 e2e/journey-suites/src/recovery-codes.test.ts diff --git a/e2e/am-mock-api/src/app/response.webauthn.js b/e2e/am-mock-api/src/app/response.webauthn.js new file mode 100644 index 0000000000..0d1b841360 --- /dev/null +++ b/e2e/am-mock-api/src/app/response.webauthn.js @@ -0,0 +1,171 @@ +/* + * @forgerock/javascript-sdk + * + * response.webauthn.js + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +/** + * WebAuthn registration initialization response + * Contains MetadataCallback for WebAuthn and HiddenValueCallback for credential + */ +export const webAuthnRegistrationInit = { + authId: 'webauthn-registration-init', + callbacks: [ + { + type: 'MetadataCallback', + output: [ + { + name: 'data', + value: { + _type: 'WebAuthn', + _action: 'webauthn_registration', + challenge: 'dGVzdC1jaGFsbGVuZ2UtZm9yLXdlYmF1dGhu', + relyingPartyId: 'localhost', + relyingPartyName: 'ForgeRock', + userId: 'dGVzdC11c2VyLWlk', + userName: 'testuser', + displayName: 'Test User', + timeout: 60000, + attestationPreference: 'none', + authenticatorAttachment: 'platform', + requireResidentKey: false, + userVerification: 'preferred', + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + }, + }, + ], + }, + { + type: 'HiddenValueCallback', + output: [ + { + name: 'value', + value: '', + }, + { + name: 'id', + value: 'webAuthnOutcome', + }, + ], + input: [ + { + name: 'IDToken2', + value: '', + }, + ], + }, + ], +}; + +/** + * Returns the recovery codes display response + * This simulates the step after WebAuthn registration where recovery codes are shown + */ +export function getRecoveryCodesDisplay() { + const recoveryCodes = [ + 'ABC123DEF4', + 'GHI567JKL8', + 'MNO901PQR2', + 'STU345VWX6', + 'YZA789BCD0', + 'EFG123HIJ4', + 'KLM567NOP8', + 'QRS901TUV2', + 'WXY345ZAB6', + 'CDE789FGH0', + ]; + + // Build the recovery codes HTML similar to what AM generates + const codesHtml = recoveryCodes + .map((code) => `"
\\n" +\n "${code}\\n" +\n "
\\n" +`) + .join('\n '); + + const scriptValue = `/* + * Copyright 2018 ForgeRock AS. All Rights Reserved + * + * Use of this code requires a commercial software license with ForgeRock AS. + * or with one of its affiliates. All use shall be exclusively subject + * to such license between the licensee and ForgeRock AS. + */ + +var newLocation = document.getElementById("wrapper"); +var oldHtml = newLocation.getElementsByTagName("fieldset")[0].innerHTML; +newLocation.getElementsByTagName("fieldset")[0].innerHTML = "
\\n" + + "
\\n" + + "

Your Recovery Codes

\\n" + + "

You must make a copy of these recovery codes. They cannot be displayed again.

\\n" + + "
\\n" + + ${codesHtml} + "
\\n" + + "

Use one of these codes to authenticate if you lose your device, which has been named: New Security Key

\\n" + + "
\\n" + + "
" + oldHtml; +document.body.appendChild(newLocation); +`; + + return { + authId: 'recovery-codes-display', + callbacks: [ + { + type: 'TextOutputCallback', + output: [ + { + name: 'message', + value: scriptValue, + }, + { + name: 'messageType', + value: '4', + }, + ], + }, + { + type: 'ConfirmationCallback', + output: [ + { + name: 'prompt', + value: '', + }, + { + name: 'messageType', + value: 0, + }, + { + name: 'options', + value: ['I have saved my recovery codes'], + }, + { + name: 'optionType', + value: -1, + }, + { + name: 'defaultOption', + value: 0, + }, + ], + input: [ + { + name: 'IDToken2', + value: 0, + }, + ], + }, + ], + }; +} + +/** + * Auth success response for WebAuthn flow + */ +export const authSuccess = { + tokenId: 'webauthn-session-token', + successUrl: '/console', + realm: '/', +}; diff --git a/e2e/am-mock-api/src/app/responses.js b/e2e/am-mock-api/src/app/responses.js index ccd1052feb..d7c9e7af41 100644 --- a/e2e/am-mock-api/src/app/responses.js +++ b/e2e/am-mock-api/src/app/responses.js @@ -1347,3 +1347,89 @@ export const recaptchaEnterpriseCallback = { }, ], }; + +export const qrCodeCallbacksResponse = { + authId: 'qrcode-journey-confirmation', + callbacks: [ + { + type: 'TextOutputCallback', + output: [ + { + name: 'message', + value: + 'Scan the QR code image below with the ForgeRock Authenticator app to register your device with your login.', + }, + { + name: 'messageType', + value: '0', + }, + ], + }, + { + type: 'TextOutputCallback', + output: [ + { + name: 'message', + value: + // eslint-disable-next-line quotes + "window.QRCodeReader.createCode({\n id: 'callback_0',\n text: 'otpauth\\x3A\\x2F\\x2Ftotp\\x2FForgeRock\\x3Ajlowery\\x3Fperiod\\x3D30\\x26b\\x3D032b75\\x26digits\\x3D6\\x26secret\\QITSTC234FRIU8DD987DW3VPICFY\\x3D\\x3D\\x3D\\x3D\\x3D\\x3D\\x26issuer\\x3DForgeRock',\n version: '20',\n code: 'L'\n});", + }, + { + name: 'messageType', + value: '4', + }, + ], + }, + { + type: 'HiddenValueCallback', + output: [ + { + name: 'value', + value: + 'otpauth://totp/ForgeRock:jlowery?secret=QITSTC234FRIU8DD987DW3VPICFY======&issuer=ForgeRock&period=30&digits=6&b=032b75', + }, + { + name: 'id', + value: 'mfaDeviceRegistration', + }, + ], + input: [ + { + name: 'IDToken3', + value: 'mfaDeviceRegistration', + }, + ], + }, + { + type: 'ConfirmationCallback', + output: [ + { + name: 'prompt', + value: '', + }, + { + name: 'messageType', + value: 0, + }, + { + name: 'options', + value: ['Next'], + }, + { + name: 'optionType', + value: -1, + }, + { + name: 'defaultOption', + value: 0, + }, + ], + input: [ + { + name: 'IDToken4', + value: 0, + }, + ], + }, + ], +}; diff --git a/e2e/am-mock-api/src/app/routes.auth.js b/e2e/am-mock-api/src/app/routes.auth.js index 41e285c832..158a61e763 100644 --- a/e2e/am-mock-api/src/app/routes.auth.js +++ b/e2e/am-mock-api/src/app/routes.auth.js @@ -48,6 +48,7 @@ import { MetadataMarketPlaceInitialize, MetadataMarketPlacePingOneEvaluation, newPiWellKnown, + qrCodeCallbacksResponse, } from './responses.js'; import initialRegResponse from './response.registration.js'; import { @@ -91,7 +92,7 @@ export default function (app) { ) { res.json(nameCallback); } else if (req.query.authIndexValue === 'TEST_LoginPingProtect') { - res.json(pingProtectInitialize); + res.json({ ...pingProtectInitialize, authId: 'protect-journey-init' }); } else if (req.query.authIndexValue === 'IDMSocialLogin') { res.json(selectIdPCallback); } else if (req.query.authIndexValue === 'TEST_MetadataMarketPlace') { @@ -100,6 +101,10 @@ export default function (app) { res.json(idpChoiceCallback); } else if (req.query.authIndexValue === 'TEST_WebAuthnWithRecoveryCodes') { res.json(webAuthnRegistrationInit); + } else if (req.query.authIndexValue === 'QRCodeTest') { + res.json({ ...initialBasicLogin, authId: 'qrcode-journey-login' }); + } else if (req.query.authIndexValue === 'DeviceProfileCallbackTest') { + res.json({ ...initialBasicLogin, authId: 'device-profile-journey-login' }); } else if (req.query.authIndexValue === 'RecaptchaEnterprise') { res.json(initialBasicLogin); } else { @@ -173,13 +178,6 @@ export default function (app) { } } return res.json(MetadataMarketPlacePingOneEvaluation); - } else if (req.query.authIndexValue === 'QRCodeTest') { - // If QR Code callbacks are being returned, return success - if (req.body.callbacks.find((cb) => cb.type === 'HiddenValueCallback')) { - return res.json(authSuccess); - } - // Client is returning callbacks from username password, so return QR Code callbacks - res.json(otpQRCodeCallbacks); } else if (req.query.authIndexValue === 'SAMLTestFailure') { if (req.body.callbacks.find((cb) => cb.type === 'RedirectCallback')) { if ( @@ -339,19 +337,24 @@ export default function (app) { res.status(401).json(authFail); } } - } else if (req.query.authIndexValue === 'TEST_LoginPingProtect') { + } else if ( + req.query.authIndexValue === 'TEST_LoginPingProtect' || + req.body.authId?.startsWith('protect-journey') + ) { const protectInitCb = req.body.callbacks.find( (cb) => cb.type === 'PingOneProtectInitializeCallback', ); - const usernameCb = req.body.callbacks.find((cb) => cb.type === 'NameCallback'); + const passwordCb = req.body.callbacks.find((cb) => cb.type === 'PasswordCallback'); const protectEvalCb = req.body.callbacks.find( (cb) => cb.type === 'PingOneProtectEvaluationCallback', ); + if (protectInitCb) { - res.json(initialBasicLogin); - } else if (usernameCb && usernameCb.input[0].value) { - res.json(pingProtectEvaluate); - } else if (protectEvalCb && protectEvalCb.input[0].value) { + res.json({ ...initialBasicLogin, authId: 'protect-journey-login' }); + } else if (passwordCb && passwordCb.input[0].value === USERS[0].pw) { + res.json({ ...pingProtectEvaluate, authId: 'protect-journey-eval' }); + } else if (protectEvalCb) { + res.cookie('iPlanetDirectoryPro', 'protect-session-' + Date.now(), { domain: 'localhost' }); res.json(authSuccess); } else { res.status(401).json(authFail); @@ -361,8 +364,14 @@ export default function (app) { if (pwCb.input[0].value !== USERS[0].pw) { res.status(401).json(authFail); } else { - if (req.query.authIndexValue === 'DeviceProfileCallbackTest') { - res.json(requestDeviceProfile); + const authId = req.body.authId; + if ( + req.query.authIndexValue === 'DeviceProfileCallbackTest' || + authId === 'device-profile-journey-login' + ) { + res.json({ ...requestDeviceProfile, authId: 'device-profile-journey-collection' }); + } else if (req.query.authIndexValue === 'QRCodeTest' || authId === 'qrcode-journey-login') { + res.json(qrCodeCallbacksResponse); } else { if ( req.body.stage === 'TransactionAuthorization' || @@ -392,55 +401,47 @@ export default function (app) { const deviceCb = req.body.callbacks.find((cb) => cb.type === 'DeviceProfileCallback') || {}; const inputArr = deviceCb.input || []; const input = inputArr[0] || {}; - const value = JSON.parse(input.value); - const location = value.location || {}; + const value = JSON.parse(input.value || '{}'); const metadata = value.metadata || {}; - // location is not allowed in some browser automation - // const location = value.location || {}; - // We just need property existence to ensure profile is generated - // We don't care about values since they are unique per browser - if ( - location && - location.latitude && - location.longitude && + const hasMetadata = metadata.browser && metadata.browser.userAgent && metadata.platform && - metadata.platform.deviceName && - metadata.platform.fonts && - metadata.platform.fonts.length > 0 && - metadata.platform.timezone && - value.identifier && - value.identifier.length > 0 - ) { + metadata.platform.deviceName; + + const hasIdentifier = value.identifier && value.identifier.length > 0; + + if (hasMetadata && hasIdentifier) { res.cookie('iPlanetDirectoryPro', 'abcd1234', { domain: 'localhost' }); res.json(authSuccess); } else { - // Just failing the auth for testing, but in reality, - // an additional auth callback would be sent, like OTP res.json(authFail); } + } else if ( + (req.query.authIndexValue === 'QRCodeTest' || + req.body.authId === 'qrcode-journey-confirmation') && + req.body.callbacks.find((cb) => cb.type === 'ConfirmationCallback') + ) { + res.cookie('iPlanetDirectoryPro', 'abcd1234', { domain: 'localhost' }); + res.json(authSuccess); } else if ( req.query.authIndexValue === 'TEST_WebAuthnWithRecoveryCodes' || req.body.authId?.startsWith('webauthn-registration') || req.body.authId?.startsWith('recovery-codes') ) { - // Handle WebAuthn registration with recovery codes journey - // Identify by authIndexValue (initial) or authId (subsequent) const metadataCb = req.body.callbacks.find((cb) => cb.type === 'MetadataCallback'); const hiddenCb = req.body.callbacks.find((cb) => cb.type === 'HiddenValueCallback'); const confirmationCb = req.body.callbacks.find((cb) => cb.type === 'ConfirmationCallback'); - if (metadataCb && hiddenCb && hiddenCb.input[0].value) { - // WebAuthn credential has been submitted, show recovery codes + if (metadataCb && hiddenCb) { res.json(getRecoveryCodesDisplay()); } else if (confirmationCb) { - // Recovery codes have been acknowledged, complete authentication - res.cookie('iPlanetDirectoryPro', 'mock-webauthn-session-' + Date.now(), { domain: 'localhost' }); + res.cookie('iPlanetDirectoryPro', 'mock-webauthn-session-' + Date.now(), { + domain: 'localhost', + }); res.json(webAuthnSuccess); } else { - // Invalid state res.status(401).json(authFail); } } diff --git a/e2e/journey-app/components/device-profile.ts b/e2e/journey-app/components/device-profile.ts index c0d62e9223..1f773f2975 100644 --- a/e2e/journey-app/components/device-profile.ts +++ b/e2e/journey-app/components/device-profile.ts @@ -4,8 +4,13 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +import { Device } from '@forgerock/journey-client/device'; import type { DeviceProfileCallback } from '@forgerock/journey-client/types'; +/** + * Device Profile Component + * Automatically collects device metadata and location data using the Device class + */ export default function deviceProfileComponent( journeyEl: HTMLDivElement, callback: DeviceProfileCallback, @@ -19,14 +24,40 @@ export default function deviceProfileComponent( journeyEl?.appendChild(message); - // Device profile callback typically runs automatically - // The callback will collect device information in the background - setTimeout(() => { + // Automatically trigger device profile collection + setTimeout(async () => { try { - // Device profile collection is typically handled automatically by the callback - console.log('Device profile collection initiated'); + const isLocationRequired = callback.isLocationRequired(); + const isMetadataRequired = callback.isMetadataRequired(); + + console.log('Collecting device profile...', { isLocationRequired, isMetadataRequired }); + + // Create device instance and collect profile + const device = new Device(); + const profile = await device.getProfile({ + location: isLocationRequired, + metadata: isMetadataRequired, + }); + + console.log('Device profile collected successfully'); + + // Set the profile on the callback + callback.setProfile(profile); + message.innerText = 'Device profile collected successfully!'; + message.style.color = 'green'; + + // Auto-submit the form after successful collection + setTimeout(() => { + const submitButton = document.getElementById('submitButton') as HTMLButtonElement; + if (submitButton) { + submitButton.click(); + } + }, 500); } catch (error) { console.error('Device profile collection failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + message.innerText = `Collection failed: ${errorMessage}`; + message.style.color = 'red'; } }, 100); } diff --git a/e2e/journey-app/components/ping-protect-evaluation.ts b/e2e/journey-app/components/ping-protect-evaluation.ts index 7ebd11d9b4..ca15880d38 100644 --- a/e2e/journey-app/components/ping-protect-evaluation.ts +++ b/e2e/journey-app/components/ping-protect-evaluation.ts @@ -31,7 +31,9 @@ export default function pingProtectEvaluationComponent( const protectInstance = getProtectInstance(); if (!protectInstance) { - throw new Error('Protect instance not initialized. Initialize callback must be called first.'); + throw new Error( + 'Protect instance not initialized. Initialize callback must be called first.', + ); } console.log('Collecting Protect signals...'); diff --git a/e2e/journey-suites/src/device-profile.test.ts b/e2e/journey-suites/src/device-profile.test.ts index 65c8c348ee..e114952e01 100644 --- a/e2e/journey-suites/src/device-profile.test.ts +++ b/e2e/journey-suites/src/device-profile.test.ts @@ -9,32 +9,39 @@ import { expect, test } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; -test.skip('Test happy paths on test page', async ({ page }) => { +test('Test device profile collection journey flow', async ({ page }) => { const { clickButton, navigate } = asyncEvents(page); - await navigate('/?journey=TEST_DeviceProfile'); const messageArray: string[] = []; - // Listen for events on page page.on('console', async (msg) => { messageArray.push(msg.text()); return Promise.resolve(true); }); - // Perform basic login + await navigate('/?journey=DeviceProfileCallbackTest&clientId=basic'); + + await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 10000 }); await page.getByLabel('User Name').fill(username); await page.getByLabel('Password').fill(password); await clickButton('Submit', '/authenticate'); - await expect(page.getByText('Collecting device profile')).toBeVisible(); + await expect(page.getByText('Collecting device profile information...')).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByText('Device profile collected successfully!')).toBeVisible({ + timeout: 15000, + }); - await expect(page.getByText('Complete')).toBeVisible(); + await expect(page.getByText('Complete')).toBeVisible({ timeout: 15000 }); - // Perform logout await clickButton('Logout', '/authenticate'); - // Test assertions - expect(messageArray.includes('Device profile collected successfully')).toBe(true); - expect(messageArray.includes('Journey completed successfully')).toBe(true); - expect(messageArray.includes('Logout successful')).toBe(true); + await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 10000 }); + + expect(messageArray.some((msg) => msg.includes('Device profile collected successfully'))).toBe( + true, + ); + expect(messageArray.some((msg) => msg.includes('Journey completed successfully'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); }); diff --git a/e2e/journey-suites/src/protect.test.ts b/e2e/journey-suites/src/protect.test.ts index a709c32a43..910a37e8f8 100644 --- a/e2e/journey-suites/src/protect.test.ts +++ b/e2e/journey-suites/src/protect.test.ts @@ -9,79 +9,42 @@ import { expect, test } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; -test.describe('PingOne Protect Journey', () => { - test('should complete journey with Protect initialization and evaluation', async ({ page }) => { - const { clickButton } = asyncEvents(page); +test('Test PingOne Protect journey flow', async ({ page }) => { + const { clickButton } = asyncEvents(page); - const messageArray: string[] = []; + const messageArray: string[] = []; - // Listen for console messages - page.on('console', async (msg) => { - messageArray.push(msg.text()); - return Promise.resolve(true); - }); - - // Navigate to PingOne Protect journey - // Use 'load' instead of 'networkidle' because Protect SDK makes continuous requests - await page.goto('/?journey=TEST_LoginPingProtect&clientId=basic', { waitUntil: 'load' }); - - // Step 1: Wait for Protect initialization to display and complete - await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); - - // Wait for initialization success message - await expect(page.getByText('PingOne Protect initialized successfully!')).toBeVisible({ - timeout: 15000, - }); - - // Submit the form to proceed to next step - await page.getByRole('button', { name: 'Submit' }).click(); - - // Wait for the journey to progress - await page.waitForTimeout(2000); - - // Debug: Print console messages - console.log('Console messages so far:', messageArray); - - // Step 2: Perform login with username and password - await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 15000 }); - await page.getByLabel('User Name').fill(username); - await page.getByLabel('Password').fill(password); - - // Wait a bit to ensure input events have been processed - await page.waitForTimeout(500); - - await clickButton('Submit', '/authenticate'); - - // Step 3: Wait for Protect evaluation to display and complete - await expect(page.getByText('Evaluating risk assessment...')).toBeVisible({ timeout: 10000 }); + page.on('console', async (msg) => { + messageArray.push(msg.text()); + return Promise.resolve(true); + }); - // Wait for evaluation success message - await expect(page.getByText('Risk assessment completed successfully!')).toBeVisible({ - timeout: 15000, - }); + await page.goto('/?journey=TEST_LoginPingProtect&clientId=basic', { waitUntil: 'load' }); - // Submit the form to complete the journey - await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('PingOne Protect initialized successfully!')).toBeVisible({ + timeout: 15000, + }); - // Wait for the journey to complete - await page.waitForTimeout(2000); + await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 15000 }); + await page.getByLabel('User Name').fill(username); + await page.getByLabel('Password').fill(password); + await clickButton('Submit', '/authenticate'); - // Step 4: Verify journey completion - await expect(page.getByText('Complete')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Evaluating risk assessment...')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Risk assessment completed successfully!')).toBeVisible({ + timeout: 15000, + }); - // Verify session token is present - const sessionToken = await page.locator('#sessionToken').textContent(); - expect(sessionToken).toBeTruthy(); + await expect(page.getByText('Complete')).toBeVisible({ timeout: 15000 }); - // Step 5: Perform logout - await clickButton('Logout', '/authenticate'); + await clickButton('Logout', '/authenticate'); - // Verify we're back at the beginning - await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); - // Test console log assertions - expect(messageArray.some((msg) => msg.includes('Protect initialized successfully'))).toBe(true); - expect(messageArray.some((msg) => msg.includes('Protect data collected successfully'))).toBe(true); - expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); - }); + expect(messageArray.some((msg) => msg.includes('Protect initialized successfully'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Protect data collected successfully'))).toBe( + true, + ); + expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); }); diff --git a/e2e/journey-suites/src/qr-code.test.ts b/e2e/journey-suites/src/qr-code.test.ts new file mode 100644 index 0000000000..d7cc7e3b38 --- /dev/null +++ b/e2e/journey-suites/src/qr-code.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { expect, test } from '@playwright/test'; +import { asyncEvents } from './utils/async-events.js'; +import { username, password } from './utils/demo-user.js'; + +test('Test QR Code journey flow', async ({ page }) => { + const { clickButton, navigate } = asyncEvents(page); + + const messageArray: string[] = []; + + // Listen for console messages + page.on('console', async (msg) => { + messageArray.push(msg.text()); + return Promise.resolve(true); + }); + + // Navigate to QR Code test journey + await navigate('/?journey=QRCodeTest'); + + // Step 1: Perform basic login + await page.getByLabel('User Name').fill(username); + await page.getByLabel('Password').fill(password); + await clickButton('Submit', '/authenticate'); + + // Step 2: QR Code step should be displayed with instruction message + await expect(page.getByText('Scan the QR code image below', { exact: false })).toBeVisible({ + timeout: 10000, + }); + + // Step 3: The "Next" radio button is already selected by default + // Click Submit to proceed with the confirmation + await clickButton('Submit', '/authenticate'); + + // Step 4: Verify journey completion + await expect(page.getByText('Complete')).toBeVisible(); + + // Step 5: Perform logout + await clickButton('Logout', '/authenticate'); + + // Test assertions + expect(messageArray.includes('Journey completed successfully')).toBe(true); + expect(messageArray.includes('Logout successful')).toBe(true); +}); diff --git a/e2e/journey-suites/src/recovery-codes.test.ts b/e2e/journey-suites/src/recovery-codes.test.ts new file mode 100644 index 0000000000..fd7ace9d0d --- /dev/null +++ b/e2e/journey-suites/src/recovery-codes.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { expect, test } from '@playwright/test'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('Recovery Codes Journey', () => { + test('should display recovery codes after WebAuthn registration and complete journey', async ({ + page, + }) => { + const { clickButton, navigate } = asyncEvents(page); + + const messageArray: string[] = []; + + // Listen for console messages + page.on('console', async (msg) => { + messageArray.push(msg.text()); + return Promise.resolve(true); + }); + + // Navigate to WebAuthn with Recovery Codes test journey + await navigate('/?journey=TEST_WebAuthnWithRecoveryCodes'); + + // Step 1: WebAuthn registration step + // The UI renders MetadataCallback (invisible) and HiddenValueCallback (hidden input) + // Wait for the Submit button to be visible (indicates callbacks have rendered) + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible({ timeout: 10000 }); + + // Submit the WebAuthn registration step + // In e2e testing, the mock API accepts this without actual WebAuthn validation + await clickButton('Submit', '/authenticate'); + + // Step 2: Recovery Codes display step + // Should show the TextOutputCallback with recovery codes + // and a ConfirmationCallback with radio button option + await expect(page.getByText('I have saved my recovery codes')).toBeVisible({ timeout: 10000 }); + + // Click Submit to acknowledge recovery codes (radio option is selected by default) + await clickButton('Submit', '/authenticate'); + + // Step 3: Verify journey completion + await expect(page.getByText('Complete')).toBeVisible({ timeout: 10000 }); + + // Verify session token is present + const sessionToken = await page.locator('#sessionToken').textContent(); + expect(sessionToken).toBeTruthy(); + + // Step 4: Perform logout + await clickButton('Logout', '/authenticate'); + + // Verify we're back at the beginning + await page.waitForTimeout(1000); + + // Test console log assertions + expect(messageArray.some((msg) => msg.includes('Journey completed successfully'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); + }); +}); From ed1cb7f78f87e2da17dc079f794e2ce3e12edadb Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 3 Dec 2025 13:50:01 -0700 Subject: [PATCH 3/5] chore: update-pw --- e2e/davinci-suites/src/utils/demo-user.ts | 2 +- e2e/oidc-suites/src/utils/demo-users.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/davinci-suites/src/utils/demo-user.ts b/e2e/davinci-suites/src/utils/demo-user.ts index b9e9fd45c0..f29637e710 100644 --- a/e2e/davinci-suites/src/utils/demo-user.ts +++ b/e2e/davinci-suites/src/utils/demo-user.ts @@ -5,6 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ export const username = 'demouser'; -export const password = 'U.QPDWEN47ZMyJhCDmhGLK*nr'; +export const password = 'yvk4uwq2edr@gxb7UWD'; export const phoneNumber1 = '888123456'; export const phoneNumber2 = '888123457'; diff --git a/e2e/oidc-suites/src/utils/demo-users.ts b/e2e/oidc-suites/src/utils/demo-users.ts index 351e055b06..aa7a0f61dd 100644 --- a/e2e/oidc-suites/src/utils/demo-users.ts +++ b/e2e/oidc-suites/src/utils/demo-users.ts @@ -10,4 +10,4 @@ export const pingAmUsername = 'sdkuser'; export const pingAmPassword = 'password'; export const pingOneUsername = 'demouser'; -export const pingOnePassword = 'U.QPDWEN47ZMyJhCDmhGLK*nr'; +export const pingOnePassword = 'yvk4uwq2edr@gxb7UWD'; From ac7fe33916e3521718719e190bf47b3c13e23f30 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 4 Dec 2025 09:30:29 -0700 Subject: [PATCH 4/5] chore: updates --- e2e/journey-app/components/device-profile.ts | 6 +- .../components/ping-protect-evaluation.ts | 6 +- .../components/ping-protect-initialize.ts | 6 +- e2e/journey-app/components/qr-code.ts | 88 ++++++++++++++ e2e/journey-app/components/recovery-codes.ts | 115 ++++++++++++++++++ e2e/journey-app/main.ts | 10 +- e2e/journey-suites/src/device-profile.test.ts | 43 ++++++- e2e/journey-suites/src/protect.test.ts | 37 +++++- e2e/journey-suites/src/qr-code.test.ts | 41 ++++--- e2e/journey-suites/src/recovery-codes.test.ts | 48 ++++---- 10 files changed, 346 insertions(+), 54 deletions(-) create mode 100644 e2e/journey-app/components/qr-code.ts create mode 100644 e2e/journey-app/components/recovery-codes.ts diff --git a/e2e/journey-app/components/device-profile.ts b/e2e/journey-app/components/device-profile.ts index 1f773f2975..2f021e709f 100644 --- a/e2e/journey-app/components/device-profile.ts +++ b/e2e/journey-app/components/device-profile.ts @@ -48,9 +48,9 @@ export default function deviceProfileComponent( // Auto-submit the form after successful collection setTimeout(() => { - const submitButton = document.getElementById('submitButton') as HTMLButtonElement; - if (submitButton) { - submitButton.click(); + const form = document.getElementById('form') as HTMLFormElement; + if (form) { + form.requestSubmit(); } }, 500); } catch (error) { diff --git a/e2e/journey-app/components/ping-protect-evaluation.ts b/e2e/journey-app/components/ping-protect-evaluation.ts index ca15880d38..456b5a63d8 100644 --- a/e2e/journey-app/components/ping-protect-evaluation.ts +++ b/e2e/journey-app/components/ping-protect-evaluation.ts @@ -58,9 +58,9 @@ export default function pingProtectEvaluationComponent( // Auto-submit the form after successful data collection setTimeout(() => { - const submitButton = document.getElementById('submitButton') as HTMLButtonElement; - if (submitButton) { - submitButton.click(); + const form = document.getElementById('form') as HTMLFormElement; + if (form) { + form.requestSubmit(); } }, 500); } catch (error) { diff --git a/e2e/journey-app/components/ping-protect-initialize.ts b/e2e/journey-app/components/ping-protect-initialize.ts index f8a1b20c9e..d68614787e 100644 --- a/e2e/journey-app/components/ping-protect-initialize.ts +++ b/e2e/journey-app/components/ping-protect-initialize.ts @@ -76,9 +76,9 @@ export default function pingProtectInitializeComponent( // Auto-submit the form after successful initialization setTimeout(() => { - const submitButton = document.getElementById('submitButton') as HTMLButtonElement; - if (submitButton) { - submitButton.click(); + const form = document.getElementById('form') as HTMLFormElement; + if (form) { + form.requestSubmit(); } }, 500); } catch (error) { diff --git a/e2e/journey-app/components/qr-code.ts b/e2e/journey-app/components/qr-code.ts new file mode 100644 index 0000000000..1c1d5ff746 --- /dev/null +++ b/e2e/journey-app/components/qr-code.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { QRCode } from '@forgerock/journey-client/qr-code'; +import type { JourneyStep, ConfirmationCallback } from '@forgerock/journey-client/types'; + +export function renderQRCodeStep(journeyEl: HTMLDivElement, step: JourneyStep): boolean { + if (!QRCode.isQRCodeStep(step)) { + return false; + } + + const qrCodeData = QRCode.getQRCodeData(step); + + console.log('QR Code step detected via QRCode module'); + console.log('QR Code data:', JSON.stringify(qrCodeData)); + + const container = document.createElement('div'); + container.id = 'qr-code-container'; + + const message = document.createElement('p'); + message.id = 'qr-code-message'; + message.innerText = qrCodeData.message || 'Scan the QR code below'; + container.appendChild(message); + + const uriDisplay = document.createElement('div'); + uriDisplay.id = 'qr-code-uri'; + uriDisplay.style.cssText = ` + padding: 10px; + background-color: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + margin: 10px 0; + `; + uriDisplay.innerText = qrCodeData.uri; + container.appendChild(uriDisplay); + + const useType = document.createElement('p'); + useType.id = 'qr-code-use-type'; + useType.innerText = `Type: ${qrCodeData.use}`; + useType.style.color = '#666'; + container.appendChild(useType); + + const confirmationCallbacks = + step.getCallbacksOfType('ConfirmationCallback'); + + if (confirmationCallbacks.length > 0) { + const confirmCb = confirmationCallbacks[0]; + const options = confirmCb.getOptions(); + + const optionsContainer = document.createElement('div'); + optionsContainer.style.marginTop = '10px'; + + options.forEach((option, index) => { + const label = document.createElement('label'); + label.style.display = 'block'; + label.style.marginBottom = '5px'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'qr-confirmation'; + radio.value = String(index); + radio.checked = index === confirmCb.getDefaultOption(); + radio.addEventListener('change', () => { + confirmCb.setOptionIndex(index); + }); + + if (index === confirmCb.getDefaultOption()) { + confirmCb.setOptionIndex(index); + } + + label.appendChild(radio); + label.appendChild(document.createTextNode(` ${option}`)); + optionsContainer.appendChild(label); + }); + + container.appendChild(optionsContainer); + } + + journeyEl.appendChild(container); + + return true; +} diff --git a/e2e/journey-app/components/recovery-codes.ts b/e2e/journey-app/components/recovery-codes.ts new file mode 100644 index 0000000000..c74ab3ee18 --- /dev/null +++ b/e2e/journey-app/components/recovery-codes.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { RecoveryCodes } from '@forgerock/journey-client/recovery-codes'; +import type { JourneyStep, ConfirmationCallback } from '@forgerock/journey-client/types'; + +export function renderRecoveryCodesStep(journeyEl: HTMLDivElement, step: JourneyStep): boolean { + if (!RecoveryCodes.isDisplayStep(step)) { + return false; + } + + const codes = RecoveryCodes.getCodes(step); + const deviceName = RecoveryCodes.getDeviceName(step); + + console.log('Recovery Codes step detected via RecoveryCodes module'); + console.log('Recovery codes:', JSON.stringify(codes)); + console.log('Device name:', deviceName); + + const container = document.createElement('div'); + container.id = 'recovery-codes-container'; + + const header = document.createElement('h3'); + header.id = 'recovery-codes-header'; + header.innerText = 'Your Recovery Codes'; + container.appendChild(header); + + const instruction = document.createElement('p'); + instruction.innerText = + 'You must make a copy of these recovery codes. They cannot be displayed again.'; + instruction.style.color = '#666'; + container.appendChild(instruction); + + const codesContainer = document.createElement('div'); + codesContainer.id = 'recovery-codes-list'; + codesContainer.style.cssText = ` + padding: 15px; + background-color: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + font-size: 14px; + margin: 10px 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + `; + + codes.forEach((code, index) => { + const codeEl = document.createElement('div'); + codeEl.className = 'recovery-code'; + codeEl.setAttribute('data-code-index', String(index)); + codeEl.innerText = code; + codeEl.style.cssText = ` + padding: 5px 10px; + background-color: white; + border: 1px solid #ccc; + border-radius: 3px; + text-align: center; + `; + codesContainer.appendChild(codeEl); + }); + + container.appendChild(codesContainer); + + if (deviceName) { + const deviceInfo = document.createElement('p'); + deviceInfo.id = 'recovery-codes-device'; + deviceInfo.innerText = `Device: ${deviceName}`; + deviceInfo.style.color = '#666'; + container.appendChild(deviceInfo); + } + + const confirmationCallbacks = + step.getCallbacksOfType('ConfirmationCallback'); + + if (confirmationCallbacks.length > 0) { + const confirmCb = confirmationCallbacks[0]; + const options = confirmCb.getOptions(); + + const optionsContainer = document.createElement('div'); + optionsContainer.style.marginTop = '15px'; + + options.forEach((option, index) => { + const label = document.createElement('label'); + label.style.display = 'block'; + label.style.marginBottom = '5px'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'recovery-confirmation'; + radio.value = String(index); + radio.checked = index === confirmCb.getDefaultOption(); + radio.addEventListener('change', () => { + confirmCb.setOptionIndex(index); + }); + + if (index === confirmCb.getDefaultOption()) { + confirmCb.setOptionIndex(index); + } + + label.appendChild(radio); + label.appendChild(document.createTextNode(` ${option}`)); + optionsContainer.appendChild(label); + }); + + container.appendChild(optionsContainer); + } + + journeyEl.appendChild(container); + + return true; +} diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index f1eb3c6a99..de83f44416 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -11,6 +11,8 @@ import { journey } from '@forgerock/journey-client'; import type { RequestMiddleware } from '@forgerock/journey-client/types'; import { renderCallbacks } from './callback-map.js'; +import { renderQRCodeStep } from './components/qr-code.js'; +import { renderRecoveryCodesStep } from './components/recovery-codes.js'; import { serverConfigs } from './server-configs.js'; const qs = window.location.search; @@ -115,9 +117,13 @@ if (searchParams.get('middleware') === 'true') { header.innerText = formName || ''; journeyEl.appendChild(header); - const callbacks = step.callbacks; + const stepRendered = + renderQRCodeStep(journeyEl, step) || renderRecoveryCodesStep(journeyEl, step); - renderCallbacks(journeyEl, callbacks); + if (!stepRendered) { + const callbacks = step.callbacks; + renderCallbacks(journeyEl, callbacks); + } const submitBtn = document.createElement('button'); submitBtn.type = 'submit'; diff --git a/e2e/journey-suites/src/device-profile.test.ts b/e2e/journey-suites/src/device-profile.test.ts index e114952e01..e5770b3262 100644 --- a/e2e/journey-suites/src/device-profile.test.ts +++ b/e2e/journey-suites/src/device-profile.test.ts @@ -11,14 +11,38 @@ import { username, password } from './utils/demo-user.js'; test('Test device profile collection journey flow', async ({ page }) => { const { clickButton, navigate } = asyncEvents(page); - const messageArray: string[] = []; + let deviceProfileRequestBody: Record | null = null; page.on('console', async (msg) => { messageArray.push(msg.text()); return Promise.resolve(true); }); + page.on('request', (request) => { + if (request.url().includes('/authenticate') && request.method() === 'POST') { + try { + const postData = request.postData(); + if (!postData) return; + + const body = JSON.parse(postData); + const deviceCallback = body.callbacks?.find( + (cb: { type: string }) => cb.type === 'DeviceProfileCallback', + ); + if (!deviceCallback) return; + + const profileInput = deviceCallback.input?.find( + (input: { name: string }) => input.name === 'IDToken1', + ); + if (profileInput?.value) { + deviceProfileRequestBody = JSON.parse(profileInput.value); + } + } catch { + // Ignore parsing errors + } + } + }); + await navigate('/?journey=DeviceProfileCallbackTest&clientId=basic'); await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 10000 }); @@ -35,7 +59,22 @@ test('Test device profile collection journey flow', async ({ page }) => { await expect(page.getByText('Complete')).toBeVisible({ timeout: 15000 }); - await clickButton('Logout', '/authenticate'); + expect(deviceProfileRequestBody).not.toBeNull(); + expect(deviceProfileRequestBody).toHaveProperty('identifier'); + expect(typeof deviceProfileRequestBody?.identifier).toBe('string'); + expect((deviceProfileRequestBody?.identifier as string).length).toBeGreaterThan(0); + + expect(deviceProfileRequestBody).toHaveProperty('metadata'); + const metadata = deviceProfileRequestBody?.metadata as Record; + expect(metadata).toHaveProperty('hardware'); + expect(metadata).toHaveProperty('browser'); + expect(metadata).toHaveProperty('platform'); + + const platform = metadata.platform as Record; + expect(platform).toHaveProperty('deviceName'); + expect(typeof platform.deviceName).toBe('string'); + + await clickButton('Logout', '/sessions'); await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 10000 }); diff --git a/e2e/journey-suites/src/protect.test.ts b/e2e/journey-suites/src/protect.test.ts index 910a37e8f8..09e37c4920 100644 --- a/e2e/journey-suites/src/protect.test.ts +++ b/e2e/journey-suites/src/protect.test.ts @@ -11,14 +11,38 @@ import { username, password } from './utils/demo-user.js'; test('Test PingOne Protect journey flow', async ({ page }) => { const { clickButton } = asyncEvents(page); - const messageArray: string[] = []; + let protectSignalsData: string | null = null; page.on('console', async (msg) => { messageArray.push(msg.text()); return Promise.resolve(true); }); + page.on('request', (request) => { + if (request.url().includes('/authenticate') && request.method() === 'POST') { + try { + const postData = request.postData(); + if (postData) { + const body = JSON.parse(postData); + const callbacks = body.callbacks || []; + for (const callback of callbacks) { + if (callback.type === 'PingOneProtectEvaluationCallback') { + const inputs = callback.input || []; + for (const input of inputs) { + if (input.name === 'IDToken1signals' && input.value) { + protectSignalsData = input.value; + } + } + } + } + } + } catch { + // Ignore parsing errors + } + } + }); + await page.goto('/?journey=TEST_LoginPingProtect&clientId=basic', { waitUntil: 'load' }); await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); @@ -36,12 +60,21 @@ test('Test PingOne Protect journey flow', async ({ page }) => { timeout: 15000, }); + // Wait for the evaluation callback to auto-submit and complete + await page.waitForResponse((response) => response.url().includes('/authenticate')); + await expect(page.getByText('Complete')).toBeVisible({ timeout: 15000 }); - await clickButton('Logout', '/authenticate'); + // Verify signals were captured from the request + expect(protectSignalsData).not.toBeNull(); + expect(typeof protectSignalsData).toBe('string'); + expect(protectSignalsData?.length).toBeGreaterThan(0); + + await clickButton('Logout', '/sessions'); await expect(page.getByText('Initializing PingOne Protect...')).toBeVisible({ timeout: 10000 }); + // Verify the protect SDK flow through console logs expect(messageArray.some((msg) => msg.includes('Protect initialized successfully'))).toBe(true); expect(messageArray.some((msg) => msg.includes('Protect data collected successfully'))).toBe( true, diff --git a/e2e/journey-suites/src/qr-code.test.ts b/e2e/journey-suites/src/qr-code.test.ts index d7cc7e3b38..b18ebd2d4d 100644 --- a/e2e/journey-suites/src/qr-code.test.ts +++ b/e2e/journey-suites/src/qr-code.test.ts @@ -9,41 +9,48 @@ import { expect, test } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; -test('Test QR Code journey flow', async ({ page }) => { +test('Test QR Code journey flow using QRCode module', async ({ page }) => { const { clickButton, navigate } = asyncEvents(page); - const messageArray: string[] = []; - // Listen for console messages page.on('console', async (msg) => { messageArray.push(msg.text()); return Promise.resolve(true); }); - // Navigate to QR Code test journey await navigate('/?journey=QRCodeTest'); - // Step 1: Perform basic login await page.getByLabel('User Name').fill(username); await page.getByLabel('Password').fill(password); await clickButton('Submit', '/authenticate'); - // Step 2: QR Code step should be displayed with instruction message - await expect(page.getByText('Scan the QR code image below', { exact: false })).toBeVisible({ - timeout: 10000, - }); + await expect(page.locator('#qr-code-container')).toBeVisible({ timeout: 10000 }); + + await expect(page.locator('#qr-code-message')).toBeVisible(); + const messageText = await page.locator('#qr-code-message').textContent(); + expect(messageText).toContain('Scan the QR code'); + + await expect(page.locator('#qr-code-uri')).toBeVisible(); + const uriText = await page.locator('#qr-code-uri').textContent(); + expect(uriText).toContain('otpauth://'); + expect(uriText).toContain('secret='); + + await expect(page.locator('#qr-code-use-type')).toBeVisible(); + const useTypeText = await page.locator('#qr-code-use-type').textContent(); + expect(useTypeText).toContain('Type: otp'); - // Step 3: The "Next" radio button is already selected by default - // Click Submit to proceed with the confirmation await clickButton('Submit', '/authenticate'); - // Step 4: Verify journey completion await expect(page.getByText('Complete')).toBeVisible(); - // Step 5: Perform logout - await clickButton('Logout', '/authenticate'); + await clickButton('Logout', '/sessions'); + + await expect(page.getByLabel('User Name')).toBeVisible({ timeout: 10000 }); - // Test assertions - expect(messageArray.includes('Journey completed successfully')).toBe(true); - expect(messageArray.includes('Logout successful')).toBe(true); + expect(messageArray.some((msg) => msg.includes('QR Code step detected via QRCode module'))).toBe( + true, + ); + expect(messageArray.some((msg) => msg.includes('QR Code data:'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Journey completed successfully'))).toBe(true); + expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); }); diff --git a/e2e/journey-suites/src/recovery-codes.test.ts b/e2e/journey-suites/src/recovery-codes.test.ts index fd7ace9d0d..ce90a62607 100644 --- a/e2e/journey-suites/src/recovery-codes.test.ts +++ b/e2e/journey-suites/src/recovery-codes.test.ts @@ -9,53 +9,57 @@ import { expect, test } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; test.describe('Recovery Codes Journey', () => { - test('should display recovery codes after WebAuthn registration and complete journey', async ({ + test('should display recovery codes using RecoveryCodes module and complete journey', async ({ page, }) => { const { clickButton, navigate } = asyncEvents(page); - const messageArray: string[] = []; - // Listen for console messages page.on('console', async (msg) => { messageArray.push(msg.text()); return Promise.resolve(true); }); - // Navigate to WebAuthn with Recovery Codes test journey await navigate('/?journey=TEST_WebAuthnWithRecoveryCodes'); - // Step 1: WebAuthn registration step - // The UI renders MetadataCallback (invisible) and HiddenValueCallback (hidden input) - // Wait for the Submit button to be visible (indicates callbacks have rendered) await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible({ timeout: 10000 }); - - // Submit the WebAuthn registration step - // In e2e testing, the mock API accepts this without actual WebAuthn validation await clickButton('Submit', '/authenticate'); - // Step 2: Recovery Codes display step - // Should show the TextOutputCallback with recovery codes - // and a ConfirmationCallback with radio button option - await expect(page.getByText('I have saved my recovery codes')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#recovery-codes-container')).toBeVisible({ timeout: 10000 }); + + await expect(page.locator('#recovery-codes-header')).toBeVisible(); + const headerText = await page.locator('#recovery-codes-header').textContent(); + expect(headerText).toContain('Recovery Codes'); + + await expect(page.locator('#recovery-codes-list')).toBeVisible(); + + const codeElements = page.locator('.recovery-code'); + const codeCount = await codeElements.count(); + expect(codeCount).toBeGreaterThan(0); + + const firstCode = await codeElements.first().textContent(); + expect(firstCode).toBeTruthy(); + expect(firstCode?.length).toBeGreaterThan(0); + + await expect(page.getByText('I have saved my recovery codes')).toBeVisible(); - // Click Submit to acknowledge recovery codes (radio option is selected by default) await clickButton('Submit', '/authenticate'); - // Step 3: Verify journey completion await expect(page.getByText('Complete')).toBeVisible({ timeout: 10000 }); - // Verify session token is present const sessionToken = await page.locator('#sessionToken').textContent(); expect(sessionToken).toBeTruthy(); - // Step 4: Perform logout - await clickButton('Logout', '/authenticate'); + await clickButton('Logout', '/sessions'); - // Verify we're back at the beginning - await page.waitForTimeout(1000); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible({ timeout: 10000 }); - // Test console log assertions + expect( + messageArray.some((msg) => + msg.includes('Recovery Codes step detected via RecoveryCodes module'), + ), + ).toBe(true); + expect(messageArray.some((msg) => msg.includes('Recovery codes:'))).toBe(true); expect(messageArray.some((msg) => msg.includes('Journey completed successfully'))).toBe(true); expect(messageArray.some((msg) => msg.includes('Logout successful'))).toBe(true); }); From 00143a53d9b9ad8f46ea3cce11e9c014e215fadd Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 10 Dec 2025 09:47:12 -0700 Subject: [PATCH 5/5] chore: pass-form-el --- e2e/journey-app/callback-map.ts | 27 +++++++++++++++---- e2e/journey-app/components/device-profile.ts | 11 +++----- .../components/ping-protect-evaluation.ts | 11 +++----- .../components/ping-protect-initialize.ts | 11 +++----- e2e/journey-app/main.ts | 4 ++- 5 files changed, 37 insertions(+), 27 deletions(-) diff --git a/e2e/journey-app/callback-map.ts b/e2e/journey-app/callback-map.ts index 1f7fc328dd..a9b6e94831 100644 --- a/e2e/journey-app/callback-map.ts +++ b/e2e/journey-app/callback-map.ts @@ -60,11 +60,13 @@ import { * @param journeyEl - The container element to append the component to * @param callback - The callback instance * @param idx - Index for generating unique IDs + * @param onSubmit - Optional callback to trigger form submission */ export function renderCallback( journeyEl: HTMLDivElement, callback: BaseCallback, idx: number, + onSubmit?: () => void, ): void { switch (callback.getType()) { case 'BooleanAttributeInputCallback': @@ -83,7 +85,7 @@ export function renderCallback( confirmationComponent(journeyEl, callback as ConfirmationCallback, idx); break; case 'DeviceProfileCallback': - deviceProfileComponent(journeyEl, callback as DeviceProfileCallback, idx); + deviceProfileComponent(journeyEl, callback as DeviceProfileCallback, idx, onSubmit); break; case 'HiddenValueCallback': hiddenValueComponent(journeyEl, callback as HiddenValueCallback, idx); @@ -101,10 +103,20 @@ export function renderCallback( passwordComponent(journeyEl, callback as PasswordCallback, idx); break; case 'PingOneProtectEvaluationCallback': - pingProtectEvaluationComponent(journeyEl, callback as PingOneProtectEvaluationCallback, idx); + pingProtectEvaluationComponent( + journeyEl, + callback as PingOneProtectEvaluationCallback, + idx, + onSubmit, + ); break; case 'PingOneProtectInitializeCallback': - pingProtectInitializeComponent(journeyEl, callback as PingOneProtectInitializeCallback, idx); + pingProtectInitializeComponent( + journeyEl, + callback as PingOneProtectInitializeCallback, + idx, + onSubmit, + ); break; case 'PollingWaitCallback': pollingWaitComponent(journeyEl, callback as PollingWaitCallback, idx); @@ -149,9 +161,14 @@ export function renderCallback( * Renders all callbacks in a step * @param journeyEl - The container element to append components to * @param callbacks - Array of callback instances + * @param onSubmit - Optional callback to trigger form submission */ -export function renderCallbacks(journeyEl: HTMLDivElement, callbacks: BaseCallback[]): void { +export function renderCallbacks( + journeyEl: HTMLDivElement, + callbacks: BaseCallback[], + onSubmit?: () => void, +): void { callbacks.forEach((callback, idx) => { - renderCallback(journeyEl, callback, idx); + renderCallback(journeyEl, callback, idx, onSubmit); }); } diff --git a/e2e/journey-app/components/device-profile.ts b/e2e/journey-app/components/device-profile.ts index 2f021e709f..d452970ccc 100644 --- a/e2e/journey-app/components/device-profile.ts +++ b/e2e/journey-app/components/device-profile.ts @@ -15,6 +15,7 @@ export default function deviceProfileComponent( journeyEl: HTMLDivElement, callback: DeviceProfileCallback, idx: number, + onSubmit?: () => void, ) { const collectorKey = callback?.payload?.input?.[0].name || `collector-${idx}`; const message = document.createElement('p'); @@ -46,13 +47,9 @@ export default function deviceProfileComponent( message.innerText = 'Device profile collected successfully!'; message.style.color = 'green'; - // Auto-submit the form after successful collection - setTimeout(() => { - const form = document.getElementById('form') as HTMLFormElement; - if (form) { - form.requestSubmit(); - } - }, 500); + if (onSubmit) { + setTimeout(() => onSubmit(), 500); + } } catch (error) { console.error('Device profile collection failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/e2e/journey-app/components/ping-protect-evaluation.ts b/e2e/journey-app/components/ping-protect-evaluation.ts index 456b5a63d8..403e284f25 100644 --- a/e2e/journey-app/components/ping-protect-evaluation.ts +++ b/e2e/journey-app/components/ping-protect-evaluation.ts @@ -15,6 +15,7 @@ export default function pingProtectEvaluationComponent( journeyEl: HTMLDivElement, callback: PingOneProtectEvaluationCallback, idx: number, + onSubmit?: () => void, ) { const collectorKey = callback?.payload?.input?.[0].name || `collector-${idx}`; const message = document.createElement('p'); @@ -56,13 +57,9 @@ export default function pingProtectEvaluationComponent( message.innerText = 'Risk assessment completed successfully!'; message.style.color = 'green'; - // Auto-submit the form after successful data collection - setTimeout(() => { - const form = document.getElementById('form') as HTMLFormElement; - if (form) { - form.requestSubmit(); - } - }, 500); + if (onSubmit) { + setTimeout(() => onSubmit(), 500); + } } catch (error) { console.error('Protect evaluation failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/e2e/journey-app/components/ping-protect-initialize.ts b/e2e/journey-app/components/ping-protect-initialize.ts index d68614787e..6cbe21db28 100644 --- a/e2e/journey-app/components/ping-protect-initialize.ts +++ b/e2e/journey-app/components/ping-protect-initialize.ts @@ -26,6 +26,7 @@ export default function pingProtectInitializeComponent( journeyEl: HTMLDivElement, callback: PingOneProtectInitializeCallback, idx: number, + onSubmit?: () => void, ) { const collectorKey = callback?.payload?.input?.[0].name || `collector-${idx}`; const message = document.createElement('p'); @@ -74,13 +75,9 @@ export default function pingProtectInitializeComponent( message.innerText = 'PingOne Protect initialized successfully!'; message.style.color = 'green'; - // Auto-submit the form after successful initialization - setTimeout(() => { - const form = document.getElementById('form') as HTMLFormElement; - if (form) { - form.requestSubmit(); - } - }, 500); + if (onSubmit) { + setTimeout(() => onSubmit(), 500); + } } catch (error) { console.error('Protect initialization failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index de83f44416..d5868c1e2f 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -117,12 +117,14 @@ if (searchParams.get('middleware') === 'true') { header.innerText = formName || ''; journeyEl.appendChild(header); + const submitForm = () => formEl.requestSubmit(); + const stepRendered = renderQRCodeStep(journeyEl, step) || renderRecoveryCodesStep(journeyEl, step); if (!stepRendered) { const callbacks = step.callbacks; - renderCallbacks(journeyEl, callbacks); + renderCallbacks(journeyEl, callbacks, submitForm); } const submitBtn = document.createElement('button');