Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions e2e/recognize-app/src/index-callback-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recognize Callback Test | Ping Identity JavaScript SDK</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="index-callback-test.ts"></script>
</body>
</html>
225 changes: 225 additions & 0 deletions e2e/recognize-app/src/index-callback-test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<label style="display:flex;flex-direction:column;gap:2px;font-size:0.85rem;">Well-known URL <input id="wellknown" type="text" style="width:100%;box-sizing:border-box;" /></label>
<label style="display:flex;flex-direction:column;gap:2px;font-size:0.85rem;">Journey name <input id="journeyName" type="text" /></label>
<button type="submit">Connect</button>
`;
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 = `
<label style="display:flex;flex-direction:column;gap:2px;font-size:0.85rem;">Username <input id="username" type="text" autocomplete="username" /></label>
<label style="display:flex;flex-direction:column;gap:2px;font-size:0.85rem;">Password <input id="password" type="password" autocomplete="current-password" /></label>
<button type="submit">Submit</button>
`;
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<string, unknown>),
});

await new Promise<void>((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)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Remove authentication data from logs.

The test writes callback configuration, Recognize result data, JWTs, and session tokens to the browser console and page. Log only non-sensitive status and error identifiers.

  • e2e/recognize-app/src/index-callback-test.ts#L147-L147: Do not log data, because it can contain jwt.
  • e2e/recognize-app/src/index-callback-test.ts#L103-L103: Do not log the full Web SDK configuration, because it includes username and transaction data.
  • e2e/recognize-app/src/index-callback-test.ts#L112-L112: Do not log unfiltered webSDKOptions.
  • e2e/recognize-app/src/index-callback-test.ts#L221-L221: Do not log the session token.
📍 Affects 1 file
  • e2e/recognize-app/src/index-callback-test.ts#L147-L147 (this comment)
  • e2e/recognize-app/src/index-callback-test.ts#L103-L103
  • e2e/recognize-app/src/index-callback-test.ts#L112-L112
  • e2e/recognize-app/src/index-callback-test.ts#L221-L221
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/recognize-app/src/index-callback-test.ts` at line 147, Remove sensitive
values from logging in e2e/recognize-app/src/index-callback-test.ts: at lines
147, 103, and 112 log only non-sensitive status or error identifiers instead of
callback data, the full Web SDK configuration, or unfiltered webSDKOptions; at
line 221 do not log the session token. Preserve the existing test behavior while
ensuring JWTs, usernames, transaction data, and session tokens never reach the
browser console or page.

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);
Comment on lines +151 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find existing Base64url or JWT decoding helpers before adding another implementation.
rg -n -C 4 --glob '*.{ts,tsx}' 'base64url|base64Url|atob\(|TextDecoder|split\('\''\.'\''\)' packages e2e

Repository: ForgeRock/ping-javascript-sdk

Length of output: 7404


Decode the JWT payload as Base64url. Normalize - and _, restore padding, and decode the bytes as UTF-8 before parsing JSON. Otherwise, valid JWTs can leave recognizeId unset.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/recognize-app/src/index-callback-test.ts` around lines 151 - 154, Update
the JWT payload decoding in the callback handling around
recognizeCallback.setRecognizeId to treat the token segment as Base64url:
convert URL-safe characters, restore required padding, decode the resulting
bytes as UTF-8, then parse the JSON. Preserve the existing payload.sub logging
and recognize ID assignment behavior.

}
} 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();
Comment on lines +174 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For the repository version of @forgerock/recognize, does a client returned by recognize()requiredispose()afterinit() rejects, and which fields are guaranteed on initialization errors?

💡 Result:

In the repository version of @forgerock/recognize, the client returned by recognize does not require a call to dispose if init rejects, because an initialization failure prevents the client from successfully reaching a state that necessitates cleanup [1]. Regarding initialization errors and guaranteed fields: The library utilizes standardized error handling through RecognizeError and RecognizeErrorCode [1]. When the init method fails, it throws a RecognizeError rather than resolving with it [1]. Because the process fails at the point of initialization, the client instance is typically not considered fully established or active, and you should rely on catching the thrown exception to handle the error, rather than inspecting the client object for guaranteed fields [1]. Developers should ensure that their implementation catches this exception to prevent unhandled promise rejections [1]. To prevent potential memory leaks or stale listeners in cases where the client was successfully initialized but later needs to be stopped, you must call dispose [1]. Specifically, ensure that any abort controller used with the client is properly signaled (e.g., calling aborter.abort) during the disposal process to guarantee that all event listeners are cleaned up [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- target file outline ---'
ast-grep outline e2e/recognize-app/src/index-callback-test.ts 2>/dev/null || true
printf '%s\n' '--- target lines ---'
sed -n '130,240p' e2e/recognize-app/src/index-callback-test.ts
printf '%s\n' '--- recognize imports and API usage ---'
rg -n -C 3 'recognize|client\.init|client\.dispose|journeyClient\.(next|fail)|callback|client-error' e2e/recognize-app/src package.json e2e/recognize-app 2>/dev/null
printf '%s\n' '--- package manifests and lockfile references ---'
rg -n -C 2 '"`@forgerock/recognize`"|`@forgerock/recognize`|ping-javascript-sdk' --glob 'package.json' --glob '*lock*' --glob '*.yaml' --glob '*.yml' .

Repository: ForgeRock/ping-javascript-sdk

Length of output: 42673


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- recognize package files ---'
git ls-files packages/recognize
printf '%s\n' '--- recognize package outline ---'
ast-grep outline packages/recognize 2>/dev/null || true
printf '%s\n' '--- init/dispose definitions and references ---'
rg -n -C 5 'class .*Recognize|function recognize|const recognize|init\s*[:=]|dispose\s*[:=]|dispose\(|RecognizeError|errorCode|client-error|setClientError' packages/recognize packages/journey-client

Repository: ForgeRock/ping-javascript-sdk

Length of output: 50387


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- recognize implementation ---'
sed -n '34,185p' packages/recognize/src/lib/recognize.ts
printf '%s\n' '--- recognize types and error codes ---'
sed -n '32,145p' packages/recognize/src/lib/recognize.types.ts
cat packages/recognize/src/lib/defs/recognize-error-code.ts
printf '%s\n' '--- initialization-related tests ---'
sed -n '1,180p' packages/recognize/src/lib/recognize.test.ts

Repository: ForgeRock/ping-javascript-sdk

Length of output: 15708


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

impl = Path("packages/recognize/src/lib/recognize.ts").read_text()
app = Path("e2e/recognize-app/src/index-callback-test.ts").read_text()

checks = {
    "init_declares_error_union": "Promise<RecognizeError | void>" in impl,
    "init_returns_import_error": "return createRecognizeError(RECOGNIZE_ERROR_CODE.SDK_WEB_ASSEMBLY_IMPORT_FAILED" in impl,
    "init_returns_attach_error": "return createRecognizeError(RECOGNIZE_ERROR_CODE.SDK_ERROR" in impl
        and "invalid element <${tag.toLowerCase()}" in impl,
    "element_created_before_mount_append": impl.index("element = document.createElement(tag);")
        < impl.index("options.container.appendChild(element);"),
    "app_rejects_returned_init_error": ".then((err) =>" in app
        and "if (err)" in app
        and "reject(err);" in app,
    "app_disposes_after_await_only": app.index("client.dispose();")
        > app.index("await new Promise<void>"),
    "app_advances_after_recognize_branch": "step = await journeyClient.next(step);" in app,
}
for name, result in checks.items():
    print(f"{name}: {'PASS' if result else 'FAIL'}")
if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: ForgeRock/ping-javascript-sdk

Length of output: 421


Handle initialization errors without aborting the Journey flow.

client.init() returns a RecognizeError; it does not reject for these failures. Set the client-error inputs, resolve the wrapper promise, and call client.dispose() in finally so journeyClient.next(step) still runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/recognize-app/src/index-callback-test.ts` around lines 174 - 187, Update
the client.init() handling in the wrapper promise so a returned RecognizeError
populates the client-error inputs and resolves instead of rejecting or aborting
the Journey flow. Ensure client.dispose() runs in a finally block, allowing
journeyClient.next(step) to execute for both initialization success and failure.

} 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}`);
}
})();
3 changes: 3 additions & 0 deletions packages/journey-client/src/lib/callbacks/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PingOneRecognizeOperationType>('operationType', 'AUTHENTICATE');
}

public getServiceURL(): string {
return this.getOutputByName<string>('websocketURL', '');
}

public getCustomerName(): string {
return this.getOutputByName<string>('customerName', '');
}

public getUsername(): string {
return this.getOutputByName<string>('username', '');
}

public getTransactionData(): string {
return this.getOutputByName<string>('transactionData', '');
}

public getOptions(): Record<string, unknown> {
return this.getOutputByName<Record<string, unknown>>('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');
}
}
Comment on lines +25 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Use a factory-created callback implementation instead of a class.

This new client-package implementation introduces PingOneRecognizeCallback as a class. Adapt the callback factory and callback contract so the implementation does not require a class.

As per coding guidelines: "packages/*/src/**/*.{ts,tsx}: Initialize client packages through factory functions; do not use classes or singletons."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts`
around lines 25 - 79, Replace the class-based PingOneRecognizeCallback
implementation with a factory function that returns the callback contract’s
operation and accessor methods while preserving the existing payload behavior
and setters. Update the callback factory and related callback type/contract
references to construct and consume the factory result instead of instantiating
PingOneRecognizeCallback, and remove the class dependency from the client
package.

Source: Coding guidelines

1 change: 1 addition & 0 deletions packages/journey-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-types/src/lib/am-callback.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const callbackType = {
PasswordCallback: 'PasswordCallback',
PingOneProtectEvaluationCallback: 'PingOneProtectEvaluationCallback',
PingOneProtectInitializeCallback: 'PingOneProtectInitializeCallback',
PingOneRecognizeCallback: 'PingOneRecognizeCallback',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move callbackType out of this type-contract file.

Line 23 adds runtime code to a *.types.ts file. Move the runtime registry to a non-.types.ts module and import its derived type where needed.

As per coding guidelines: "*.types.ts files may contain only type contracts (type and interface); they must contain no runtime code or enum declarations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-types/src/lib/am-callback.types.ts` at line 23, Move the runtime
callbackType registry containing PingOneRecognizeCallback out of the *.types.ts
module into a non-types runtime module, then export/import its derived callback
type wherever the type contract is required. Keep the registry values and
callback type behavior unchanged, and ensure am-callback.types.ts contains only
type or interface declarations.

Source: Coding guidelines

PollingWaitCallback: 'PollingWaitCallback',
ReCaptchaCallback: 'ReCaptchaCallback',
ReCaptchaEnterpriseCallback: 'ReCaptchaEnterpriseCallback',
Expand Down
Loading