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
33 changes: 33 additions & 0 deletions e2e/journey-app/components/delete-device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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.
*/

export function renderDeleteDevicesSection(
journeyEl: HTMLDivElement,
deleteWebAuthnDevice: () => Promise<string>,
): void {
const deleteWebAuthnDeviceButton = document.createElement('button');
deleteWebAuthnDeviceButton.type = 'button';
deleteWebAuthnDeviceButton.id = 'deleteWebAuthnDeviceButton';
deleteWebAuthnDeviceButton.innerText = 'Delete Webauthn Device';

const deviceStatus = document.createElement('pre');
deviceStatus.id = 'deviceStatus';
deviceStatus.style.minHeight = '1.5em';

journeyEl.appendChild(deleteWebAuthnDeviceButton);
journeyEl.appendChild(deviceStatus);

deleteWebAuthnDeviceButton.addEventListener('click', async () => {
try {
deviceStatus.innerText = 'Deleting WebAuthn device...';
deviceStatus.innerText = await deleteWebAuthnDevice();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deviceStatus.innerText = `Delete failed: ${message}`;
}
});
}
86 changes: 86 additions & 0 deletions e2e/journey-app/components/webauthn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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 { JourneyStep } from '@forgerock/journey-client/types';
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';

export function extractRegistrationCredentialId(outcomeValue: string): string | null {
// This app consumes the hidden `webAuthnOutcome` callback populated by journey-client.
// See packages/journey-client/src/lib/webauthn/webauthn.ts:
// - register(): JSON-wrapped outcome when `supportsJsonResponse` is enabled
// - register(): plain legacy outcome string otherwise
let legacyData: string | null = outcomeValue;

// Newer journey-client responses may wrap the legacy string as:
// { authenticatorAttachment, legacyData }
// We only need the legacy payload here; the attachment is not used by journey-app.
try {
const parsed = JSON.parse(outcomeValue) as unknown;
if (parsed && typeof parsed === 'object' && 'legacyData' in parsed) {
const candidate = (parsed as Record<string, unknown>).legacyData;
legacyData = typeof candidate === 'string' ? candidate : null;
}
} catch {
// Not JSON; fall back to plain legacy outcome string.
}

if (!legacyData) {
return null;
}

// journey-client registration outcome format is:
// clientDataJSON::attestationObject::credentialId[::deviceName]
// The app only needs the third segment so delete-webauthn-devices can target
// the same registered credential later.
// See e2e/journey-app/main.ts and e2e/journey-app/services/delete-webauthn-devices.ts.
const parts = legacyData.split('::');
const credentialId = parts[2];
return credentialId && credentialId.length > 0 ? credentialId : null;
}

export type WebAuthnHandleResult = {
success: boolean;
credentialId: string | null;
};

export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) {
const container = document.createElement('div');
container.id = `webauthn-container-${idx}`;
const info = document.createElement('p');
info.innerText = 'Please complete the WebAuthn challenge using your authenticator.';
container.appendChild(info);
journeyEl.appendChild(container);

const webAuthnStepType = WebAuthn.getWebAuthnStepType(step);

async function handleWebAuthn(): Promise<WebAuthnHandleResult> {
try {
if (webAuthnStepType === WebAuthnStepType.Authentication) {
console.log('trying authentication');
await WebAuthn.authenticate(step);
return { success: true, credentialId: null };
}

if (webAuthnStepType === WebAuthnStepType.Registration) {
console.log('trying registration');
await WebAuthn.register(step);

const { hiddenCallback } = WebAuthn.getCallbacks(step);
const rawOutcome = String(hiddenCallback?.getInputValue() ?? '');
const credentialId = extractRegistrationCredentialId(rawOutcome);
console.log('[WebAuthn] registration credentialId:', credentialId);
return { success: true, credentialId };
}

return { success: false, credentialId: null };
} catch {
return { success: false, credentialId: null };
}
}

return handleWebAuthn();
}
127 changes: 97 additions & 30 deletions e2e/journey-app/main.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025-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 './style.css';

import { journey } from '@forgerock/journey-client';
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';

import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';

import { renderCallbacks } from './callback-map.js';
import { renderDeleteDevicesSection } from './components/delete-device.js';
import { renderQRCodeStep } from './components/qr-code.js';
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
import { deleteWebAuthnDevice } from './services/delete-webauthn-devices.js';
import { webauthnComponent } from './components/webauthn.js';
import { serverConfigs } from './server-configs.js';

const qs = window.location.search;
const searchParams = new URLSearchParams(qs);

const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';

const config = serverConfigs[searchParams.get('clientId') || 'basic'];

const journeyName = searchParams.get('journey') ?? 'UsernamePassword';
Expand Down Expand Up @@ -59,9 +65,27 @@ if (searchParams.get('middleware') === 'true') {
const formEl = document.getElementById('form') as HTMLFormElement;
const journeyEl = document.getElementById('journey') as HTMLDivElement;

const getCredentialIdFromUrl = (): string | null => {
const params = new URLSearchParams(window.location.search);
const value = params.get(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM);
return value && value.length > 0 ? value : null;
};

const setCredentialIdInUrl = (credentialId: string | null): void => {
const url = new URL(window.location.href);
if (credentialId) {
url.searchParams.set(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM, credentialId);
} else {
url.searchParams.delete(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM);
}
window.history.replaceState({}, document.title, url.toString());
};

let registrationCredentialId: string | null = getCredentialIdFromUrl();

let journeyClient: JourneyClient;
try {
journeyClient = await journey({ config: config, requestMiddleware });
journeyClient = await journey({ config, requestMiddleware });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Failed to initialize journey client:', message);
Expand All @@ -70,34 +94,6 @@ if (searchParams.get('middleware') === 'true') {
}
let step = await journeyClient.start({ journey: journeyName });

function renderComplete() {
if (step?.type !== 'LoginSuccess') {
throw new Error('Expected step to be defined and of type LoginSuccess');
}

const session = step.getSessionToken();

console.log(`Session Token: ${session || 'none'}`);

journeyEl.innerHTML = `
<h2 id="completeHeader">Complete</h2>
<span id="sessionLabel">Session:</span>
<pre id="sessionToken" id="sessionToken">${session}</pre>
<button type="button" id="logoutButton">Logout</button>
`;

const loginBtn = document.getElementById('logoutButton') as HTMLButtonElement;
loginBtn.addEventListener('click', async () => {
await journeyClient.terminate();

console.log('Logout successful');

step = await journeyClient.start({ journey: journeyName });

renderForm();
});
}

function renderError() {
if (step?.type !== 'LoginFailure') {
throw new Error('Expected step to be defined and of type LoginFailure');
Expand All @@ -117,6 +113,7 @@ if (searchParams.get('middleware') === 'true') {
// Represents the main render function for app
async function renderForm() {
journeyEl.innerHTML = '';
errorEl.textContent = '';

if (step?.type !== 'Step') {
throw new Error('Expected step to be defined and of type Step');
Expand All @@ -130,6 +127,28 @@ if (searchParams.get('middleware') === 'true') {

const submitForm = () => formEl.requestSubmit();

// Handle WebAuthn steps first so we can hide the Submit button while processing,
// auto-submit on success, and show an error on failure.
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
if (
webAuthnStep === WebAuthnStepType.Authentication ||
webAuthnStep === WebAuthnStepType.Registration
) {
const webAuthnResponse = await webauthnComponent(journeyEl, step, 0);
if (webAuthnResponse.success) {
if (webAuthnResponse.credentialId) {
registrationCredentialId = webAuthnResponse.credentialId;
setCredentialIdInUrl(registrationCredentialId);
console.log('[WebAuthn] stored registration credentialId:', registrationCredentialId);
}
submitForm();
return;
} else {
errorEl.textContent =
'WebAuthn failed or was cancelled. Please try again or use a different method.';
}
}

const stepRendered =
renderQRCodeStep(journeyEl, step) || renderRecoveryCodesStep(journeyEl, step);

Expand All @@ -145,6 +164,54 @@ if (searchParams.get('middleware') === 'true') {
journeyEl.appendChild(submitBtn);
}

function renderComplete() {
if (step?.type !== 'LoginSuccess') {
throw new Error('Expected step to be defined and of type LoginSuccess');
}

const session = step.getSessionToken();

console.log(`Session Token: ${session || 'none'}`);

journeyEl.replaceChildren();

const completeHeader = document.createElement('h2');
completeHeader.id = 'completeHeader';
completeHeader.innerText = 'Complete';
journeyEl.appendChild(completeHeader);

renderDeleteDevicesSection(journeyEl, () =>
deleteWebAuthnDevice(config, registrationCredentialId),
);

const sessionLabelEl = document.createElement('span');
sessionLabelEl.id = 'sessionLabel';
sessionLabelEl.innerText = 'Session:';

const sessionTokenEl = document.createElement('pre');
sessionTokenEl.id = 'sessionToken';
sessionTokenEl.textContent = session || 'none';

const logoutBtn = document.createElement('button');
logoutBtn.type = 'button';
logoutBtn.id = 'logoutButton';
logoutBtn.innerText = 'Logout';

journeyEl.appendChild(sessionLabelEl);
journeyEl.appendChild(sessionTokenEl);
journeyEl.appendChild(logoutBtn);

logoutBtn.addEventListener('click', async () => {
await journeyClient.terminate();

console.log('Logout successful');

step = await journeyClient.start({ journey: journeyName });

renderForm();
});
}

formEl.addEventListener('submit', async (event) => {
event.preventDefault();

Expand Down
3 changes: 2 additions & 1 deletion e2e/journey-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"@forgerock/journey-client": "workspace:*",
"@forgerock/oidc-client": "workspace:*",
"@forgerock/protect": "workspace:*",
"@forgerock/sdk-logger": "workspace:*"
"@forgerock/sdk-logger": "workspace:*",
"@forgerock/device-client": "workspace:*"
},
"nx": {
"tags": ["scope:e2e"]
Expand Down
Loading
Loading