diff --git a/.changeset/config.json b/.changeset/config.json index 28ecbd2341..791171e604 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -17,7 +17,6 @@ "@forgerock/pingone-scripts", "@forgerock/device-client-app", "@forgerock/davinci-app", - "@forgerock/recognize", "@forgerock/davinci-suites", "@forgerock/api-report", "@forgerock/interface-mapping-validator", diff --git a/.changeset/recognize-callback-support.md b/.changeset/recognize-callback-support.md new file mode 100644 index 0000000000..3221162d02 --- /dev/null +++ b/.changeset/recognize-callback-support.md @@ -0,0 +1,5 @@ +--- +'@forgerock/journey-client': minor +--- + +Add support for PingOne Recognize enrollment and authentication callbacks diff --git a/e2e/am-mock-api/src/app/responses.js b/e2e/am-mock-api/src/app/responses.js index 6f3df3ad2b..a9265773f7 100644 --- a/e2e/am-mock-api/src/app/responses.js +++ b/e2e/am-mock-api/src/app/responses.js @@ -395,6 +395,59 @@ export const pingProtectSignalsInitializationOptions = { ], }; +export const pingOneRecognize = { + authId: 'foo', + callbacks: [ + { + type: 'PingOneRecognizeCallback', + output: [ + { + name: 'operationType', + value: 'AUTHENTICATE', + }, + { + name: 'authenticationServiceUrl', + value: 'https://recognize.example.com', + }, + { + name: 'customerName', + value: 'mock-customer', + }, + { + name: 'username', + value: 'sdkuser', + }, + { + name: 'transactionData', + value: 'mock-transaction-data', + }, + { + name: 'webSDKOptions', + value: { requestRecognitionFrame: true }, + }, + ], + input: [ + { + name: 'IDToken1signedJwt', + value: '', + }, + { + name: 'IDToken1recognizeId', + value: '', + }, + { + name: 'IDToken1clientError', + value: '', + }, + { + name: 'IDToken1clientErrorCode', + value: '', + }, + ], + }, + ], +}; + export const choiceCallback = { authId: 'foo', callbacks: [ diff --git a/e2e/am-mock-api/src/app/routes.auth.js b/e2e/am-mock-api/src/app/routes.auth.js index 046d41c8e4..5616e5d456 100644 --- a/e2e/am-mock-api/src/app/routes.auth.js +++ b/e2e/am-mock-api/src/app/routes.auth.js @@ -29,6 +29,7 @@ import { pingProtectEvaluate, pingProtectInitialize, pingProtectSignalsInitializationOptions, + pingOneRecognize, redirectCallback, redirectCallbackSaml, requestDeviceProfile, @@ -61,6 +62,19 @@ import wait from './wait.js'; console.log(`Your user password from 'env.config' file: ${USERS[0].pw}`); +/** + * Decodes the payload segment of an unsigned JWT and returns its `sub` claim, + * or null if the token can't be parsed. + */ +function getJwtSub(jwt) { + try { + const [, payload] = jwt.split('.'); + return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))).sub ?? null; + } catch { + return null; + } +} + export const baz = { canWithdraw: false, }; @@ -92,6 +106,8 @@ export default function (app) { req.query.authIndexValue === 'SAMLFailure' ) { res.json(nameCallback); + } else if (req.query.authIndexValue === 'TEST_LoginPingRecognize') { + res.json({ ...initialBasicLogin, authId: 'recognize-journey-login' }); } else if (req.query.authIndexValue === 'TEST_LoginPingProtect') { res.json({ ...pingProtectInitialize, authId: 'protect-journey-init' }); } else if (req.query.authIndexValue === 'TEST_LoginPingProtectSignalsOptions') { @@ -365,6 +381,41 @@ export default function (app) { } else { res.status(401).json(authFail); } + } else if ( + req.query.authIndexValue === 'TEST_LoginPingRecognize' || + req.body.authId?.startsWith('recognize-journey') + ) { + const recognizeCb = req.body.callbacks.find((cb) => cb.type === 'PingOneRecognizeCallback'); + const passwordCb = req.body.callbacks.find((cb) => cb.type === 'PasswordCallback'); + + if (recognizeCb) { + const inputValue = (name) => { + const input = recognizeCb.input?.find((x) => x.name === name); + return input ? String(input.value ?? '') : ''; + }; + const signedJwt = inputValue('IDToken1signedJwt'); + const recognizeId = inputValue('IDToken1recognizeId'); + const clientError = inputValue('IDToken1clientError'); + const clientErrorCode = inputValue('IDToken1clientErrorCode'); + + if (clientError) { + res.status(401).json({ + ...authFail, + message: `Recognize client error: ${clientError} (${clientErrorCode})`, + }); + } else if (signedJwt && recognizeId && getJwtSub(signedJwt) === recognizeId) { + res.cookie('iPlanetDirectoryPro', 'recognize-session-' + Date.now(), { + domain: 'localhost', + }); + res.json(authSuccess); + } else { + res.status(401).json(authFail); + } + } else if (passwordCb && passwordCb.input[0].value === USERS[0].pw) { + res.json({ ...pingOneRecognize, authId: 'recognize-journey-recognize' }); + } else { + res.status(401).json(authFail); + } } else if (req.body.callbacks.find((cb) => cb.type === 'PasswordCallback')) { const pwCb = req.body.callbacks.find((cb) => cb.type === 'PasswordCallback'); if (pwCb.input[0].value !== USERS[0].pw) { diff --git a/e2e/journey-app/callback-map.ts b/e2e/journey-app/callback-map.ts index c43c47bd7a..42fc183535 100644 --- a/e2e/journey-app/callback-map.ts +++ b/e2e/journey-app/callback-map.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. + * 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. @@ -14,6 +14,7 @@ import { kbaCreateComponent, metadataComponent, passwordComponent, + pingOneRecognizeComponent, pingProtectEvaluationComponent, pingProtectInitializeComponent, pollingWaitComponent, @@ -42,6 +43,7 @@ import type { PasswordCallback, PingOneProtectEvaluationCallback, PingOneProtectInitializeCallback, + PingOneRecognizeCallback, PollingWaitCallback, ReCaptchaCallback, ReCaptchaEnterpriseCallback, @@ -118,6 +120,9 @@ export function renderCallback( onSubmit, ); break; + case 'PingOneRecognizeCallback': + pingOneRecognizeComponent(journeyEl, callback as PingOneRecognizeCallback, idx, onSubmit); + break; case 'PollingWaitCallback': pollingWaitComponent(journeyEl, callback as PollingWaitCallback, idx); break; diff --git a/e2e/journey-app/components/index.ts b/e2e/journey-app/components/index.ts index 425c8560be..8e97e9ebe6 100644 --- a/e2e/journey-app/components/index.ts +++ b/e2e/journey-app/components/index.ts @@ -22,6 +22,7 @@ export { default as metadataComponent } from './metadata.js'; export { default as passwordComponent } from './password.js'; export { default as pingProtectEvaluationComponent } from './ping-protect-evaluation.js'; export { default as pingProtectInitializeComponent } from './ping-protect-initialize.js'; +export { default as pingOneRecognizeComponent } from './ping-one-recognize.js'; export { default as pollingWaitComponent } from './polling-wait.js'; export { default as recaptchaComponent } from './recaptcha.js'; export { default as recaptchaEnterpriseComponent } from './recaptcha-enterprise.js'; diff --git a/e2e/journey-app/components/ping-one-recognize.ts b/e2e/journey-app/components/ping-one-recognize.ts new file mode 100644 index 0000000000..cd0e53ed08 --- /dev/null +++ b/e2e/journey-app/components/ping-one-recognize.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 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 type { PingOneRecognizeCallback } from '@forgerock/journey-client/types'; + +/** + * PingOne Recognize Component + * + * The real @forgerock/recognize WASM camera component can't run headless, + * so this simulates its completion: it renders the callback's server-provided + * configuration (via the getters), then either produces a signed JWT + + * recognizeId or a client error — controlled by `?recognizeError=true` — + * and auto-submits the step. + */ +export default function pingOneRecognizeComponent( + journeyEl: HTMLDivElement, + callback: PingOneRecognizeCallback, + idx: number, + onSubmit?: () => void, +) { + const collectorKey = callback?.payload?.input?.[0].name || `collector-${idx}`; + const message = document.createElement('p'); + + message.id = collectorKey; + message.innerText = 'Starting PingOne Recognize...'; + + journeyEl?.appendChild(message); + + // Show the values the callback exposes from the server's outputs + const config = document.createElement('pre'); + config.id = 'recognizeConfig'; + config.innerText = JSON.stringify( + { + operationType: callback.getOperationType(), + serviceURL: callback.getServiceURL(), + customerName: callback.getCustomerName(), + username: callback.getUsername(), + transactionData: callback.getTransactionData(), + webSDKOptions: callback.getOptions(), + }, + null, + 2, + ); + journeyEl?.appendChild(config); + + // Simulate the Recognize web component completing (or failing) + setTimeout(async () => { + const recognizeError = new URLSearchParams(window.location.search).get('recognizeError'); + + if (recognizeError) { + // Pairs with 3004 (CORE_FACE_NOT_MATCHING) from @forgerock/recognize + console.log('Recognize error simulated'); + callback.setClientError('CORE_FACE_NOT_MATCHING'); + callback.setClientErrorCode('3004'); + message.innerText = 'Recognize failed: CORE_FACE_NOT_MATCHING'; + message.style.color = 'red'; + } else { + console.log('Recognize data collected successfully'); + const signedJwt = [ + window.btoa('{"alg":"none"}'), + window.btoa('{"sub":"mock-recognize-id"}'), + window.btoa('signature'), + ].join('.'); + callback.setSignedJwt(signedJwt); + try { + const payload = JSON.parse(atob(signedJwt.split('.')[1])); + if (payload.sub) { + callback.setRecognizeId(payload.sub); + } + } catch (e) { + console.error('Could not parse mock JWT sub:', e); + } + message.innerText = 'Recognize completed successfully!'; + message.style.color = 'green'; + } + + if (onSubmit) { + setTimeout(() => onSubmit(), 500); + } + }, 100); +} diff --git a/e2e/journey-app/package.json b/e2e/journey-app/package.json index 7cc9e67485..917d423425 100644 --- a/e2e/journey-app/package.json +++ b/e2e/journey-app/package.json @@ -11,11 +11,11 @@ "serve": "pnpm nx nxServe" }, "dependencies": { + "@forgerock/device-client": "workspace:*", "@forgerock/journey-client": "workspace:*", "@forgerock/oidc-client": "workspace:*", "@forgerock/protect": "workspace:*", - "@forgerock/sdk-logger": "workspace:*", - "@forgerock/device-client": "workspace:*" + "@forgerock/sdk-logger": "workspace:*" }, "nx": { "tags": ["scope:e2e"] diff --git a/e2e/journey-suites/src/recognize.test.ts b/e2e/journey-suites/src/recognize.test.ts new file mode 100644 index 0000000000..4ac69191c4 --- /dev/null +++ b/e2e/journey-suites/src/recognize.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 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'; + +interface RecognizeInputCapture { + signedJwt: string | null; + recognizeId: string | null; + clientError: string | null; + clientErrorCode: string | null; +} + +function captureRecognizeInputs(page: import('@playwright/test').Page) { + const captured: RecognizeInputCapture[] = []; + + 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 === 'PingOneRecognizeCallback') { + const inputs = callback.input || []; + const value = (name: string) => + inputs.find((input: { name: string }) => input.name === name)?.value ?? null; + captured.push({ + signedJwt: value('IDToken1signedJwt'), + recognizeId: value('IDToken1recognizeId'), + clientError: value('IDToken1clientError'), + clientErrorCode: value('IDToken1clientErrorCode'), + }); + } + } + } + } catch { + // Ignore parsing errors + } + } + }); + + return captured; +} + +test('Test PingOne Recognize journey flow', async ({ page }) => { + const { clickButton, navigate } = asyncEvents(page); + const messageArray: string[] = []; + const captured = captureRecognizeInputs(page); + + page.on('console', async (msg) => { + messageArray.push(msg.text()); + return Promise.resolve(true); + }); + + await navigate('/?journey=TEST_LoginPingRecognize&clientId=basic'); + + 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'); + + // The callback component should render the server-provided outputs + await expect(page.locator('#recognizeConfig')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#recognizeConfig')).toContainText('"operationType": "AUTHENTICATE"'); + await expect(page.locator('#recognizeConfig')).toContainText('https://recognize.example.com'); + await expect(page.locator('#recognizeConfig')).toContainText('mock-customer'); + await expect(page.locator('#recognizeConfig')).toContainText('"sdkuser"'); + await expect(page.locator('#recognizeConfig')).toContainText('mock-transaction-data'); + await expect(page.locator('#recognizeConfig')).toContainText('"requestRecognitionFrame": true'); + + // Simulated Recognize completion sets the JWT and auto-submits + await expect(page.getByText('Recognize completed successfully!')).toBeVisible({ + timeout: 10000, + }); + + // Wait for the recognize callback to auto-submit and complete + await page.waitForResponse((response) => response.url().includes('/authenticate')); + + await expect(page.getByText('Complete')).toBeVisible({ timeout: 15000 }); + + // Verify the signed JWT and recognizeId were submitted to the server + expect(captured.length).toBeGreaterThan(0); + const lastSubmit = captured[captured.length - 1]; + expect(lastSubmit.signedJwt).toBeTruthy(); + expect(lastSubmit.signedJwt).toContain('.'); + expect(lastSubmit.recognizeId).toBe('mock-recognize-id'); + expect(lastSubmit.clientError).toBe(''); + + // Verify the recognize SDK flow through console logs + expect(messageArray.some((msg) => msg.includes('Recognize data collected successfully'))).toBe( + true, + ); +}); + +test('Test PingOne Recognize journey flow with client error', async ({ page }) => { + const { clickButton, navigate } = asyncEvents(page); + const captured = captureRecognizeInputs(page); + + await navigate('/?journey=TEST_LoginPingRecognize&clientId=basic&recognizeError=true'); + + 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'); + + // The component simulates a Recognize failure and auto-submits the client error + await expect(page.getByText('Recognize failed: CORE_FACE_NOT_MATCHING')).toBeVisible({ + timeout: 10000, + }); + + await page.waitForResponse((response) => response.url().includes('/authenticate')); + + // The mock echoes the submitted client error in the 401 failure message + const errorMessage = page.locator('#errorMessage'); + await expect(errorMessage).toBeVisible({ timeout: 15000 }); + await expect(errorMessage).toContainText('CORE_FACE_NOT_MATCHING'); + await expect(errorMessage).toContainText('3004'); + + // Verify the client error was submitted and no JWT was sent + expect(captured.length).toBeGreaterThan(0); + const lastSubmit = captured[captured.length - 1]; + expect(lastSubmit.clientError).toBe('CORE_FACE_NOT_MATCHING'); + expect(lastSubmit.clientErrorCode).toBe('3004'); + expect(lastSubmit.signedJwt).toBe(''); + expect(lastSubmit.recognizeId).toBe(''); +}); diff --git a/e2e/recognize-app/src/index-callback-test.html b/e2e/recognize-app/src/index-callback-test.html new file mode 100644 index 0000000000..050c0e96bb --- /dev/null +++ b/e2e/recognize-app/src/index-callback-test.html @@ -0,0 +1,12 @@ + + + + + + Recognize Callback Test | Ping Identity JavaScript SDK + + +
+ + + diff --git a/e2e/recognize-app/src/index-callback-test.ts b/e2e/recognize-app/src/index-callback-test.ts new file mode 100644 index 0000000000..603b9eba30 --- /dev/null +++ b/e2e/recognize-app/src/index-callback-test.ts @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 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 { callbackType, journey } from '@forgerock/journey-client'; +import { recognize } from '@forgerock/recognize'; + +import type { + NameCallback, + PasswordCallback, + PingOneRecognizeCallback, +} from '@forgerock/journey-client'; + +const appEl = document.getElementById('app') as HTMLDivElement; +appEl.style.cssText = 'display:flex;gap:1.5rem;align-items:flex-start;'; + +const leftEl = document.createElement('div'); +leftEl.style.cssText = 'flex:0 0 400px;min-width:400px;'; +appEl.appendChild(leftEl); + +const rightEl = document.createElement('div'); +rightEl.style.cssText = 'flex:1;height:calc(100vh - 4rem);overflow-y:auto;'; +appEl.appendChild(rightEl); + +console.log('[build] recognize-app loaded'); + +function promptConfig(): Promise<{ wellknown: string; journeyName: string }> { + return new Promise((resolve) => { + const form = document.createElement('form'); + form.style.cssText = 'display:flex;flex-direction:column;gap:0.5rem;'; + form.innerHTML = ` + + + + `; + leftEl.appendChild(form); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const wellknown = (form.querySelector('#wellknown') as HTMLInputElement).value.trim(); + const journeyName = (form.querySelector('#journeyName') as HTMLInputElement).value.trim(); + form.remove(); + resolve({ wellknown, journeyName }); + }); + }); +} + +function log(msg: string) { + console.log(msg); + const p = document.createElement('p'); + p.style.cssText = 'font-family:monospace;font-size:0.85rem;margin:2px 0;'; + if (msg.startsWith('[error]')) p.style.color = 'crimson'; + else if (msg.startsWith('[done]')) p.style.color = 'green'; + else if (msg.startsWith('[recognize]')) p.style.color = '#2563eb'; + else if (msg.startsWith('[step]')) p.style.color = '#7c3aed'; + p.textContent = msg; + rightEl.appendChild(p); +} + +function promptCredentials(): Promise<{ username: string; password: string }> { + return new Promise((resolve) => { + const form = document.createElement('form'); + form.style.cssText = 'display:flex;flex-direction:column;gap:0.5rem;'; + form.innerHTML = ` + + + + `; + leftEl.appendChild(form); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const username = (form.querySelector('#username') as HTMLInputElement).value; + const password = (form.querySelector('#password') as HTMLInputElement).value; + form.remove(); + resolve({ username, password }); + }); + }); +} + +(async () => { + const { wellknown, journeyName } = await promptConfig(); + log('[init] starting journey client...'); + let journeyClient; + try { + journeyClient = await journey({ config: { serverConfig: { wellknown } } }); + } catch (err) { + log(`[error] failed to init journey client: ${err}`); + return; + } + + log('[init] starting journey...'); + let step; + try { + step = await journeyClient.start({ journey: journeyName }); + } catch (err) { + log(`[error] failed to start journey: ${err}`); + return; + } + + while (step.type === 'Step') { + const recognizeCallback = step.callbacks.find( + (cb) => cb.getType() === callbackType.PingOneRecognizeCallback, + ) as PingOneRecognizeCallback | undefined; + + if (recognizeCallback) { + log(`[step] got PingOneRecognizeCallback — op: ${recognizeCallback.getOperationType()}`); + + const operationType = recognizeCallback.getOperationType(); + const serviceURL = recognizeCallback.getServiceURL(); + + log(`[config] serviceURL: ${serviceURL}`); + log(`[config] customer: ${recognizeCallback.getCustomerName()}`); + log(`[config] username: ${recognizeCallback.getUsername()}`); + log(`[options] webSDKOptions from server: ${JSON.stringify(recognizeCallback.getOptions())}`); + + const client = recognize({ + customer: recognizeCallback.getCustomerName(), + serviceURL, + ...(recognizeCallback.getTransactionData() + ? { transactionData: recognizeCallback.getTransactionData() } + : {}), + ...(recognizeCallback.getOptions() as Record), + }); + + await new Promise((resolve, reject) => { + client.subscribe({ + next: (event) => { + log( + `[recognize] ${event.type}${'detail' in event ? ': ' + JSON.stringify(event.detail) : ''}`, + ); + }, + error: (err) => { + console.error( + '[recognize] raw error:', + err, + 'constructor:', + err?.constructor?.name, + 'instanceof RecognizeError:', + err instanceof Error, + ); + log( + `[recognize] error: ${JSON.stringify(err)} — code:${err.error.code} — msg:${err.error.message} — constructor:${err?.constructor?.name}`, + ); + recognizeCallback.setClientError(err.error.message); + recognizeCallback.setClientErrorCode(String(err.error.code)); + resolve(); + }, + complete: (data) => { + log(`[recognize] complete — data: ${JSON.stringify(data)}`); + if (data.jwt) { + recognizeCallback.setSignedJwt(data.jwt); + try { + const payload = JSON.parse(atob(data.jwt.split('.')[1])); + if (payload.sub) { + log(`[recognize] recognizeId from JWT sub: ${payload.sub}`); + recognizeCallback.setRecognizeId(payload.sub); + } + } catch (e) { + log(`[recognize] could not parse JWT sub: ${e}`); + } + } + resolve(); + }, + }); + + const container = document.createElement('div'); + leftEl.appendChild(container); + + client + .init({ + mode: 'mount', + container, + type: operationType === 'ENROLL' ? 'enroll' : 'auth', + username: recognizeCallback.getUsername(), + }) + .then((err) => { + if (err) { + log(`[recognize] init error: ${err}`); + reject(err); + } + }) + .catch((err) => { + log(`[recognize] init threw: ${err}`); + console.error('[recognize] init threw:', err); + reject(err); + }); + }); + + client.dispose(); + } else { + const hasName = step.callbacks.some((cb) => cb.getType() === callbackType.NameCallback); + const hasPassword = step.callbacks.some( + (cb) => cb.getType() === callbackType.PasswordCallback, + ); + + if (hasName || hasPassword) { + log('[step] credentials required'); + const { username, password } = await promptCredentials(); + + if (hasName) { + const cb = step.callbacks.find( + (cb) => cb.getType() === callbackType.NameCallback, + ) as NameCallback; + cb.setName(username); + } + if (hasPassword) { + const cb = step.callbacks.find( + (cb) => cb.getType() === callbackType.PasswordCallback, + ) as PasswordCallback; + cb.setPassword(password); + } + } else { + const types = step.callbacks.map((cb) => cb.getType()).join(', '); + log(`[step] unhandled callbacks: [${types}]`); + break; + } + } + + step = await journeyClient.next(step); + } + + if (step.type === 'LoginSuccess') { + log(`[done] Login successful — session: ${step.getSessionToken() ?? 'none'}`); + } else if (step.type === 'LoginFailure') { + log(`[done] Login failed — ${step.payload.message}`); + } +})(); diff --git a/packages/journey-client/src/lib/callbacks/factory.ts b/packages/journey-client/src/lib/callbacks/factory.ts index f0ebdeec78..580f9896ef 100644 --- a/packages/journey-client/src/lib/callbacks/factory.ts +++ b/packages/journey-client/src/lib/callbacks/factory.ts @@ -17,6 +17,7 @@ import { KbaCreateCallback } from './kba-create-callback.js'; import { MetadataCallback } from './metadata-callback.js'; import { NameCallback } from './name-callback.js'; import { PasswordCallback } from './password-callback.js'; +import { PingOneRecognizeCallback } from './ping-one-recognize-callback.js'; import { PingOneProtectEvaluationCallback } from './ping-protect-evaluation-callback.js'; import { PingOneProtectInitializeCallback } from './ping-protect-initialize-callback.js'; import { PollingWaitCallback } from './polling-wait-callback.js'; @@ -64,6 +65,8 @@ export function createCallback(callback: Callback): BaseCallback { return new PingOneProtectEvaluationCallback(callback); case callbackType.PingOneProtectInitializeCallback: return new PingOneProtectInitializeCallback(callback); + case callbackType.PingOneRecognizeCallback: + return new PingOneRecognizeCallback(callback); case callbackType.PollingWaitCallback: return new PollingWaitCallback(callback); case callbackType.ReCaptchaCallback: diff --git a/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.test.ts b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.test.ts new file mode 100644 index 0000000000..5cc64a24bb --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.test.ts @@ -0,0 +1,155 @@ +/* + * @forgerock/ping-javascript-sdk + * + * ping-one-recognize-callback.test.ts + * + * Copyright (c) 2026 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 { callbackType } from '@forgerock/sdk-types'; +import { describe, expect, it, vi } from 'vitest'; + +import { PingOneRecognizeCallback } from './ping-one-recognize-callback.js'; + +describe('PingOneRecognizeCallback', () => { + it('should be defined', () => { + expect(PingOneRecognizeCallback).toBeDefined(); + }); + + it('should test that the getOperationType method can be called', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [{ name: 'operationType', value: 'ENROLL' }], + }); + const mock = vi.spyOn(callback, 'getOperationType'); + const result = callback.getOperationType(); + expect(mock).toHaveBeenCalled(); + expect(result).toBe('ENROLL'); + }); + + it('should default getOperationType to AUTHENTICATE when output is missing', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [], + }); + expect(callback.getOperationType()).toBe('AUTHENTICATE'); + }); + + it('should test that the getServiceURL method can be called', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [{ name: 'authenticationServiceUrl', value: 'https://recognize.example.com' }], + }); + const mock = vi.spyOn(callback, 'getServiceURL'); + const result = callback.getServiceURL(); + expect(mock).toHaveBeenCalled(); + expect(result).toBe('https://recognize.example.com'); + }); + + it('should test that the getCustomerName method can be called', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [{ name: 'customerName', value: 'acme' }], + }); + const mock = vi.spyOn(callback, 'getCustomerName'); + const result = callback.getCustomerName(); + expect(mock).toHaveBeenCalled(); + expect(result).toBe('acme'); + }); + + it('should test that the getUsername method can be called', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [{ name: 'username', value: 'jdoe' }], + }); + const mock = vi.spyOn(callback, 'getUsername'); + const result = callback.getUsername(); + expect(mock).toHaveBeenCalled(); + expect(result).toBe('jdoe'); + }); + + it('should test that the getTransactionData method can be called', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [{ name: 'transactionData', value: 'txn-123' }], + }); + const mock = vi.spyOn(callback, 'getTransactionData'); + const result = callback.getTransactionData(); + expect(mock).toHaveBeenCalled(); + expect(result).toBe('txn-123'); + }); + + it('should test that the getOptions method can be called', () => { + const options = { requestRecognitionFrame: true }; + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [{ name: 'webSDKOptions', value: options }], + }); + const mock = vi.spyOn(callback, 'getOptions'); + const result = callback.getOptions(); + expect(mock).toHaveBeenCalled(); + expect(result).toEqual(options); + }); + + it('should default getOptions to an empty object when output is missing', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [], + }); + expect(callback.getOptions()).toEqual({}); + }); + + it('should test setSignedJwt method', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [], + input: [{ name: 'IDToken1signedJwt', value: '' }], + }); + const mock = vi.spyOn(callback, 'setSignedJwt'); + callback.setSignedJwt('jwt-value'); + expect(mock).toHaveBeenCalledWith('jwt-value'); + expect(callback.getInputValue('IDToken1signedJwt')).toBe('jwt-value'); + }); + + it('should test setRecognizeId method', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [], + input: [{ name: 'IDToken1recognizeId', value: '' }], + }); + const mock = vi.spyOn(callback, 'setRecognizeId'); + callback.setRecognizeId('recognize-id-123'); + expect(mock).toHaveBeenCalledWith('recognize-id-123'); + expect(callback.getInputValue('IDToken1recognizeId')).toBe('recognize-id-123'); + }); + + it('should test setClientError method', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [], + input: [{ name: 'IDToken1clientError', value: '' }], + }); + // The Keyless SDK's RecognizeError.message is the error code's key name itself + // (see @forgerock/recognize's getRecognizeErrorCodeKey), so this pairs with the + // CORE_FACE_NOT_MATCHING (3004) code used in setClientErrorCode below. + const mock = vi.spyOn(callback, 'setClientError'); + callback.setClientError('CORE_FACE_NOT_MATCHING'); + expect(mock).toHaveBeenCalledWith('CORE_FACE_NOT_MATCHING'); + expect(callback.getInputValue('IDToken1clientError')).toBe('CORE_FACE_NOT_MATCHING'); + }); + + it('should test setClientErrorCode method', () => { + const callback = new PingOneRecognizeCallback({ + type: callbackType.PingOneRecognizeCallback, + output: [], + input: [{ name: 'IDToken1clientErrorCode', value: '' }], + }); + // 3004 is CORE_FACE_NOT_MATCHING from @forgerock/recognize's RECOGNIZE_ERROR_CODE + const mock = vi.spyOn(callback, 'setClientErrorCode'); + callback.setClientErrorCode('3004'); + expect(mock).toHaveBeenCalledWith('3004'); + expect(callback.getInputValue('IDToken1clientErrorCode')).toBe('3004'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts new file mode 100644 index 0000000000..44d4c5b389 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 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 { BaseCallback } from './base-callback.js'; + +import type { Callback } from '@forgerock/sdk-types'; + +export type PingOneRecognizeOperationType = 'ENROLL' | 'AUTHENTICATE'; + +/** + * @class - Represents a callback used to perform PingOne Recognize (Keyless) biometric operations. + */ +export class PingOneRecognizeCallback extends BaseCallback { + constructor(public override payload: Callback) { + super(payload); + } + + public getOperationType(): PingOneRecognizeOperationType { + return this.getOutputByName('operationType', 'AUTHENTICATE'); + } + + public getServiceURL(): string { + return this.getOutputByName('authenticationServiceUrl', ''); + } + + public getCustomerName(): string { + return this.getOutputByName('customerName', ''); + } + + public getUsername(): string { + return this.getOutputByName('username', ''); + } + + public getTransactionData(): string { + return this.getOutputByName('transactionData', ''); + } + + public getOptions(): Record { + return this.getOutputByName>('webSDKOptions', {}); + } + + public setSignedJwt(jwt: string): void { + this.setInputValue(jwt, 'IDToken1signedJwt'); + } + + public setRecognizeId(recognizeId: string): void { + this.setInputValue(recognizeId, 'IDToken1recognizeId'); + } + + public setClientError(errorMessage: string): void { + this.setInputValue(errorMessage, 'IDToken1clientError'); + } + + public setClientErrorCode(errorCode: string): void { + this.setInputValue(errorCode, 'IDToken1clientErrorCode'); + } +} diff --git a/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts index e4802c9db3..0af88cc79d 100644 --- a/packages/journey-client/src/types.ts +++ b/packages/journey-client/src/types.ts @@ -47,6 +47,7 @@ export * from './lib/callbacks/name-callback.js'; export * from './lib/callbacks/password-callback.js'; export * from './lib/callbacks/ping-protect-evaluation-callback.js'; export * from './lib/callbacks/ping-protect-initialize-callback.js'; +export * from './lib/callbacks/ping-one-recognize-callback.js'; export * from './lib/callbacks/polling-wait-callback.js'; export * from './lib/callbacks/recaptcha-callback.js'; export * from './lib/callbacks/recaptcha-enterprise-callback.js'; diff --git a/packages/recognize/package.json b/packages/recognize/package.json index a5ae62f569..060ae20e61 100644 --- a/packages/recognize/package.json +++ b/packages/recognize/package.json @@ -1,7 +1,6 @@ { "name": "@forgerock/recognize", "version": "0.0.1", - "private": true, "repository": { "type": "git", "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git", diff --git a/packages/sdk-types/src/lib/am-callback.types.ts b/packages/sdk-types/src/lib/am-callback.types.ts index 8ee87effdd..7fc8cd5c5c 100644 --- a/packages/sdk-types/src/lib/am-callback.types.ts +++ b/packages/sdk-types/src/lib/am-callback.types.ts @@ -20,6 +20,7 @@ export const callbackType = { PasswordCallback: 'PasswordCallback', PingOneProtectEvaluationCallback: 'PingOneProtectEvaluationCallback', PingOneProtectInitializeCallback: 'PingOneProtectInitializeCallback', + PingOneRecognizeCallback: 'PingOneRecognizeCallback', PollingWaitCallback: 'PollingWaitCallback', ReCaptchaCallback: 'ReCaptchaCallback', ReCaptchaEnterpriseCallback: 'ReCaptchaEnterpriseCallback',