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',