Skip to content
Merged
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
1 change: 0 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/recognize-callback-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/journey-client': minor
---

Add support for PingOne Recognize enrollment and authentication callbacks
53 changes: 53 additions & 0 deletions e2e/am-mock-api/src/app/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
51 changes: 51 additions & 0 deletions e2e/am-mock-api/src/app/routes.auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
pingProtectEvaluate,
pingProtectInitialize,
pingProtectSignalsInitializationOptions,
pingOneRecognize,
redirectCallback,
redirectCallbackSaml,
requestDeviceProfile,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion e2e/journey-app/callback-map.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -14,6 +14,7 @@ import {
kbaCreateComponent,
metadataComponent,
passwordComponent,
pingOneRecognizeComponent,
pingProtectEvaluationComponent,
pingProtectInitializeComponent,
pollingWaitComponent,
Expand Down Expand Up @@ -42,6 +43,7 @@ import type {
PasswordCallback,
PingOneProtectEvaluationCallback,
PingOneProtectInitializeCallback,
PingOneRecognizeCallback,
PollingWaitCallback,
ReCaptchaCallback,
ReCaptchaEnterpriseCallback,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions e2e/journey-app/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
84 changes: 84 additions & 0 deletions e2e/journey-app/components/ping-one-recognize.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 2 additions & 2 deletions e2e/journey-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading