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
6 changes: 6 additions & 0 deletions .changeset/pink-jars-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@forgerock/journey-client': minor
'@forgerock/oidc-client': minor
---

Add AM server-side conditional mediation check to `WebAuthn.isConditionalMediationSupported` and export custom storage config types from oidc-client
11 changes: 2 additions & 9 deletions e2e/journey-app/components/webauthn-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,9 @@ export async function handleWebAuthnStep(
) as HTMLInputElement | null;
conditionalInput?.focus();

const isConditionalSupported = await WebAuthn.isConditionalMediationSupported();
const isConditionalMediation = await WebAuthn.isConditionalMediationSupported(step);

const metadataCallback = WebAuthn.getMetadataCallback(step);
const meta = metadataCallback?.getData<{
mediation?: CredentialMediationRequirement;
conditional?: boolean;
}>();
const isConditionalMediation = meta?.mediation === 'conditional' || meta?.conditional === true;

if (isConditionalSupported && conditionalInput && isConditionalMediation) {
if (isConditionalMediation && conditionalInput) {
const controller = new AbortController();
void WebAuthn.authenticate(step, controller.signal)
.then(() => submitForm())
Expand Down
36 changes: 18 additions & 18 deletions interface_mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,24 +859,24 @@ const response = await fetch('https://api.example.com/resource', {

## 12. WebAuthn

| Legacy API | New API | Return Type Change | Behavioral Notes |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------- |
| `import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | — | Class renamed `FRWebAuthn` → `WebAuthn`. Import path changed to submodule |
| `FRWebAuthn.getWebAuthnStepType(step: FRStep): WebAuthnStepType` | `WebAuthn.getWebAuthnStepType(step: JourneyStep): WebAuthnStepType` | Same | Step type changed to `JourneyStep` |
| `FRWebAuthn.authenticate(step: FRStep, optionsTransformer?): Promise<FRStep>` | `WebAuthn.authenticate(step: JourneyStep): Promise<void>` | `FRStep` → `void` | Mutates step in-place instead of returning it |
| `FRWebAuthn.register(step: FRStep, deviceName?): Promise<FRStep>` | `WebAuthn.register(step: JourneyStep): Promise<void>` | `FRStep` → `void` | Mutates step in-place instead of returning it. Device name not passed here |
| `FRWebAuthn.isWebAuthnSupported(): boolean` | No equivalent exported | — | Check `window.PublicKeyCredential` directly |
| `FRWebAuthn.isConditionalMediationSupported(): Promise<boolean>` | No equivalent exported | — | Check `PublicKeyCredential.isConditionalMediationAvailable()` directly |
| `FRWebAuthn.getCallbacks(step): WebAuthnCallbacks` | Not exported as public API | — | Internal to `WebAuthn.authenticate/register` |
| `FRWebAuthn.getMetadataCallback(step)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getOutcomeCallback(step)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getTextOutputCallback(step)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getAuthenticationCredential(options)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getAuthenticationOutcome(credential)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getRegistrationCredential(options)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getRegistrationOutcome(credential)` | Not exported as public API | — | Internal |
| `FRWebAuthn.createAuthenticationPublicKey(metadata)` | Not exported as public API | — | Internal |
| `FRWebAuthn.createRegistrationPublicKey(metadata)` | Not exported as public API | — | Internal |
| Legacy API | New API | Return Type Change | Behavioral Notes |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | — | Class renamed `FRWebAuthn` → `WebAuthn`. Import path changed to submodule |
| `FRWebAuthn.getWebAuthnStepType(step: FRStep): WebAuthnStepType` | `WebAuthn.getWebAuthnStepType(step: JourneyStep): WebAuthnStepType` | Same | Step type changed to `JourneyStep` |
| `FRWebAuthn.authenticate(step: FRStep, optionsTransformer?): Promise<FRStep>` | `WebAuthn.authenticate(step: JourneyStep): Promise<void>` | `FRStep` → `void` | Mutates step in-place instead of returning it |
| `FRWebAuthn.register(step: FRStep, deviceName?): Promise<FRStep>` | `WebAuthn.register(step: JourneyStep): Promise<void>` | `FRStep` → `void` | Mutates step in-place instead of returning it. Device name not passed here |
| `FRWebAuthn.isWebAuthnSupported(): boolean` | No equivalent exported | — | Check `window.PublicKeyCredential` directly |
| `FRWebAuthn.isConditionalMediationSupported(): Promise<boolean>` | `WebAuthn.isConditionalMediationSupported(step?): Promise<boolean>` | Same | Pass a step to also check AM requested conditional mediation (`meta.mediation === 'conditional'`); without a step, only the browser check runs |
| `FRWebAuthn.getCallbacks(step): WebAuthnCallbacks` | Not exported as public API | — | Internal to `WebAuthn.authenticate/register` |
| `FRWebAuthn.getMetadataCallback(step)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getOutcomeCallback(step)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getTextOutputCallback(step)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getAuthenticationCredential(options)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getAuthenticationOutcome(credential)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getRegistrationCredential(options)` | Not exported as public API | — | Internal |
| `FRWebAuthn.getRegistrationOutcome(credential)` | Not exported as public API | — | Internal |
| `FRWebAuthn.createAuthenticationPublicKey(metadata)` | Not exported as public API | — | Internal |
| `FRWebAuthn.createRegistrationPublicKey(metadata)` | Not exported as public API | — | Internal |

### WebAuthn Enums

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export abstract class WebAuthn {
static getRegistrationOutcome(credential: PublicKeyCredential | null): OutcomeWithName<string, AttestationType, PublicKeyCredential>;
static getTextOutputCallback(step: JourneyStep): TextOutputCallback | undefined;
static getWebAuthnStepType(step: JourneyStep): WebAuthnStepType;
static isConditionalMediationSupported(): Promise<boolean>;
static isConditionalMediationSupported(step?: JourneyStep): Promise<boolean>;
static register<T extends string = ''>(step: JourneyStep, deviceName?: T): Promise<JourneyStep>;
}

Expand Down
33 changes: 32 additions & 1 deletion packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* fr-webauthn.mock.data.ts
*
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2020 - 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.
*/
Expand Down Expand Up @@ -325,6 +325,37 @@ export const webAuthnAuthMetaCallback70StoredUsername = {
],
};

export const webAuthnAuthMetaCallback70Conditional = {
authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ',
callbacks: [
{
type: callbackType.MetadataCallback,
output: [
{
name: 'data',
value: {
userVerification: 'preferred',
_type: 'WebAuthn',
challenge: 'OHmmFKfBhrUZKkuZJ84lf9N8TaRmQSjRdZyueeSIXAo=',
relyingPartyId: '',
allowCredentials: '',
timeout: '60000',
mediation: 'conditional',
},
},
],
},
{
type: callbackType.HiddenValueCallback,
output: [
{ name: 'value', value: 'false' },
{ name: 'id', value: 'webAuthnOutcome' },
],
input: [{ name: 'IDToken2', value: 'webAuthnOutcome' }],
},
],
};

export const webAuthnAuthMetaCallbackJsonResponse = {
authId:
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoSW5kZXhWYWx1ZSI6IndlYmF1dGhuIiwib3RrIjoicXN1dTA0anNxZ2hmcGpubjFiM2IxdDh0NTQiLCJhdXRoSW5kZXhUeXBlIjoic2VydmljZSIsInJlYWxtIjoiLyIsInNlc3Npb25JZCI6IipBQUpUU1FBQ01ERUFCSFI1Y0dVQUNFcFhWRjlCVlZSSUFBSlRNUUFBKmV5SjBlWEFpT2lKS1YxUWlMQ0pqZEhraU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSjkuWlhsS01HVllRV2xQYVVwTFZqRlJhVXhEU214aWJVMXBUMmxLUWsxVVNUUlJNRXBFVEZWb1ZFMXFWVEpKYVhkcFdWZDRia2xxYjJsYVIyeDVTVzR3TGk0M01qbDZSRXhsU0hwM1pXbFVPRll6VDB4cFgycEJMbGxpV1Y5WVl6WTJPVWxJU0MxT01VTk5Va05MZVROdFZFaHliVlJSVDJoV2VVSTBUVUUzYUVoRmFISjFTRU5SVWpWeWVsQnRZVzFpYlRNeFFqQlhSMlZxVEU5allUWllWMEUwTVROSlozRnVXV2gwV0hadlRHaE9VMkpDYUdkUlJFaGFSV1pLVmxaeWNsOTBTRUpJU1Y5elIyMVhNMEZYWm5wTk9WZElXblJMTm10ZmFITm9hemRNUTBkSVkwbzVhVGQ2Wnpob2FUbFROVm8wTVVkbk1rZFpXSFJJTnpoWlRGOVVaVTl5VWtOc1ozTlBkbDlWTWpGRFJ6STBReTFMWVRJNVMxRm9ibFYwTTNCVlozWldiamRCVUhWdWRFdEdaR2h6VERselJ6QlBTVnAwYzNoV1NqTXRPVmc1TmpSM2VrVldSV2w2Vnprek9XNTRlVm94VjNWVVgzWmpTRzFFV2t4eE0yWTJXamswVlVWTU5VNDJjMjVMTWs5U01XeDBOR3BrVld3eWVVMVRaVXR6YzJkb2VIRmlNMnhoZW1WQlVtMDBWM3BUVld4c1JFUTRVVVJuWDNoSFozSktlVmhQZWxCa1RWUnZYeTB0U1c0MFkxVjBTeloxTVdWSVkxOWhZbkZLTlhsRVpWcEpaM04yYkU5eE5qVTJkVU5KV0dzNU9GbElWMHBEWkhSR1MzcGtWV1k1ZG0xNlJIWk9iMmxXTlZnd2RXd3hiRzlTV0dOaFZtTkhVMDlaTlVGNFdITkJkSGd5UVhkVlVXUnViR0pmYTJodWN5MUhXblowZUVOM1lYRldlR2h1T1RsdVVWY3ljWFphUjNCTk1raFVPRzFMYUU5SVIyOVJOQzFWVkZrNVVWbDBNbGcwZGpaZlQzSk5kemxUZEdwSVl6RnRjMTkxVTI1VWVtVmpUR2RYUkVZdFVFNXNVM0J1Ymkxc1EyRlljRXREVmxsS1FVeDVUbWhoWDBJeGQwNTRSRzV0WW5vNVdYVjFXakZMYzFWTkxVZHJjVlJZYkY5c2JqUlBMVEpXVUhoTVFYSjFVblZOZW0xaVgxQndRMjlqY1d4T1Z6Sm1jWHBPV25seVlteE9RVEZXUkdaM04yYzJNMnhmTkhvd05UWkhlRXhOVjBOck5rOTZVQzFMY1RJMVlXTmxSa0ZQWWpGd1JtMXBkVGgzWW5kUGVITmtZa0ZLVW0xSWMxVlJWVzlQWm5aQlpURldORmcyUW5veGNFeG9SV3d0UzNGblkwMDBjMjluYTFab1YyRkhZWFpyVFUxSVFTNVlWRVpOV1d4R2F6bHFSV1V5VG5CamFIZDVZVzVuLmYyS2t1RlhnM05MUU1NbGNnMU1HU2Y2YTZQVmdJalhtUC1wcmJhQTNtTnciLCJleHAiOjE2MTM0OTc0OTksImlhdCI6MTYxMzQ5NzE5OX0.EuDmsY3C6I6vc_x7KlkW4rSQJY1FWevbGGmxkSu4HVU',
Expand Down
55 changes: 55 additions & 0 deletions packages/journey-client/src/lib/webauthn/webauthn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,64 @@ import {
webAuthnAuthJSCallback70StoredUsername,
webAuthnRegMetaCallback70StoredUsername,
webAuthnAuthMetaCallback70StoredUsername,
webAuthnAuthMetaCallback70Conditional,
} from './webauthn.mock.data.js';
import { createJourneyStep } from '../step.utils.js';

describe('Test FRWebAuthn conditional mediation support', () => {
const originalPublicKeyCredential = globalThis.PublicKeyCredential;

const withConditionalMediationAvailable = (available: boolean) => {
// jsdom has no PublicKeyCredential; install a minimal stub for these tests.
Object.defineProperty(globalThis, 'PublicKeyCredential', {
value: {
isConditionalMediationAvailable: vi.fn().mockResolvedValue(available),
},
configurable: true,
writable: true,
});
};

afterEach(() => {
Object.defineProperty(globalThis, 'PublicKeyCredential', {
value: originalPublicKeyCredential,
configurable: true,
writable: true,
});
});

it('should return true when the browser supports conditional mediation and no step is given', async () => {
withConditionalMediationAvailable(true);
await expect(WebAuthn.isConditionalMediationSupported()).resolves.toBe(true);
});

it('should return false when the browser does not support conditional mediation and no step is given', async () => {
withConditionalMediationAvailable(false);
await expect(WebAuthn.isConditionalMediationSupported()).resolves.toBe(false);
});

it('should return false when the browser supports it but AM did not request conditional mediation', async () => {
// eslint-disable-next-line
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
withConditionalMediationAvailable(true);
await expect(WebAuthn.isConditionalMediationSupported(step)).resolves.toBe(false);
});

it('should return true when the browser supports it and AM requested conditional mediation', async () => {
// eslint-disable-next-line
const step = createJourneyStep(webAuthnAuthMetaCallback70Conditional as any);
withConditionalMediationAvailable(true);
await expect(WebAuthn.isConditionalMediationSupported(step)).resolves.toBe(true);
});

it('should return false when AM requested conditional mediation but the browser does not support it', async () => {
// eslint-disable-next-line
const step = createJourneyStep(webAuthnAuthMetaCallback70Conditional as any);
withConditionalMediationAvailable(false);
await expect(WebAuthn.isConditionalMediationSupported(step)).resolves.toBe(false);
});
});

describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => {
it('should return Registration type with register text-output callbacks', () => {
// eslint-disable-next-line
Expand Down
Loading
Loading