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..f32103a811 --- /dev/null +++ b/e2e/recognize-app/src/index-callback-test.ts @@ -0,0 +1,225 @@ +import { + callbackType, + journey, + NameCallback, + PasswordCallback, + PingOneRecognizeCallback, +} from '@forgerock/journey-client'; +import { recognize } from '@forgerock/recognize'; +import './styles.css'; + +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()}`); + log(`[config] ${JSON.stringify(recognizeCallback.getWebSDKConfig())}`); + + const config = recognizeCallback.getWebSDKConfig(); + const operationType = recognizeCallback.getOperationType(); + + const serviceURL = config.ws.url + .replace(/^wss:\/\//, 'https://') + .replace(/^ws:\/\//, 'http://'); + + 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 9eaae7d039..a8bea6d8b5 100644 --- a/packages/journey-client/src/lib/callbacks/factory.ts +++ b/packages/journey-client/src/lib/callbacks/factory.ts @@ -22,6 +22,7 @@ import { NameCallback } from './name-callback.js'; import { PasswordCallback } from './password-callback.js'; import { PingOneProtectEvaluationCallback } from './ping-protect-evaluation-callback.js'; import { PingOneProtectInitializeCallback } from './ping-protect-initialize-callback.js'; +import { PingOneRecognizeCallback } from './ping-one-recognize-callback.js'; import { PollingWaitCallback } from './polling-wait-callback.js'; import { ReCaptchaCallback } from './recaptcha-callback.js'; import { ReCaptchaEnterpriseCallback } from './recaptcha-enterprise-callback.js'; @@ -65,6 +66,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.ts b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts new file mode 100644 index 0000000000..0f249cd9ad --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts @@ -0,0 +1,79 @@ +/* + * 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 { Callback } from '@forgerock/sdk-types'; + +import { BaseCallback } from './base-callback.js'; + +export type PingOneRecognizeOperationType = 'ENROLL' | 'AUTHENTICATE'; + +export interface PingOneRecognizeWebSDKConfig { + customer: { name: string }; + transaction: { data: string }; + username: string; + ws: { url: string }; + [key: string]: unknown; +} + +/** + * @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('websocketURL', ''); + } + + 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 getWebSDKConfig(): PingOneRecognizeWebSDKConfig { + return { + customer: { name: this.getCustomerName() }, + transaction: { data: this.getTransactionData() }, + username: this.getUsername(), + ws: { url: this.getServiceURL() }, + ...this.getOptions(), + }; + } + + 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/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',