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
5 changes: 5 additions & 0 deletions .changeset/itchy-dancers-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/javascript-sdk': minor
---

Added support for Conditional UI elements with WebAuthN
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
- run: pnpm exec nx-cloud record -- nx format:check --verbose
- run: pnpm exec nx affected -t build lint test docs e2e-ci

- name: Publish previews to Stackblitz on PR
run: pnpm pkg-pr-new publish './packages/*' --packageManager=pnpm

- uses: codecov/codecov-action@v5
with:
files: ./packages/**/coverage/*.xml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test('should login and logout with pingone', async ({ page }) => {
await btn.click({ delay: 1000 });
await page.waitForURL(/ping/);
await page.getByPlaceholder('Username').fill('reactdavinci@user.com');
await page.getByRole('textbox', { name: 'Password' }).fill('bae0fzc-mzg3krg5FQB');
await page.getByRole('textbox', { name: 'Password' }).fill('twf0MCH5xnw.jcj4qtq');
await page.getByRole('button', { name: 'Sign On' }).click();

await expect(page.getByText('preferred_username')).toContainText('reactdavinci@user.com');
Expand Down
37 changes: 37 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,3 +489,40 @@ export const webAuthnRegMetaCallbackJsonResponse = {
},
],
};

export const webAuthnAuthConditionalMetaCallback = {
authId: 'test-auth-id-conditional',
callbacks: [
{
type: CallbackType.MetadataCallback,
output: [
{
name: 'data',
value: {
_action: 'webauthn_authentication',
challenge: 'JEisuqkVMhI490jM0/iEgrRz+j94OoGc7gdY4gYicSk=',
allowCredentials: '',
_allowCredentials: [],
Copy link
Contributor

Choose a reason for hiding this comment

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

So AM now sends both of these values? we need to take precedence on the array I assume because it can list the possible passkeys?

Is this AM being backwards compatible with the older allowCredentials?

Copy link
Contributor

@KMForgeRock KMForgeRock Jan 9, 2026

Choose a reason for hiding this comment

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

They are effectively identical as AM supports both for as you mentioned backwards compatibility. If you feel like this answers your question, give me a should and I can resolve this thread.

Copy link
Contributor

Choose a reason for hiding this comment

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

Commend addressed

timeout: 60000,
userVerification: 'preferred',
conditionalWebAuthn: true,
relyingPartyId: '',
_relyingPartyId: 'example.com',
extensions: {},
_type: 'WebAuthn',
supportsJsonResponse: true,
},
},
],
_id: 0,
},
{
type: CallbackType.HiddenValueCallback,
output: [
{ name: 'value', value: 'false' },
{ name: 'id', value: 'webAuthnOutcome' },
],
input: [{ name: 'IDToken1', value: 'webAuthnOutcome' }],
},
],
};
114 changes: 114 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import {
webAuthnAuthJSCallback70StoredUsername,
webAuthnRegMetaCallback70StoredUsername,
webAuthnAuthMetaCallback70StoredUsername,
webAuthnAuthConditionalMetaCallback,
} from './fr-webauthn.mock.data';
import FRStep from '../fr-auth/fr-step';
import Config from '../config';

describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => {
it('should return Registration type with register text-output callbacks', () => {
Expand Down Expand Up @@ -104,3 +106,115 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
expect(stepType).toBe(WebAuthnStepType.Authentication);
});
});

describe('Test FRWebAuthn class with Conditional UI', () => {
beforeEach(() => {
// Mock navigator.credentials and window.PublicKeyCredential
Object.defineProperty(global.navigator, 'credentials', {
value: {
get: vi.fn().mockResolvedValue(null),
create: vi.fn(),
},
writable: true,
});
Object.defineProperty(window, 'PublicKeyCredential', {
value: {
isConditionalMediationAvailable: vi.fn(),
},
writable: true,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should detect if conditional UI is supported', async () => {
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
const isSupported = await FRWebAuthn.isConditionalUISupported();
expect(isSupported).toBe(true);
});

it('should return Authentication type with conditional UI metadata callback', () => {
const step = new FRStep(webAuthnAuthConditionalMetaCallback as any);
const stepType = FRWebAuthn.getWebAuthnStepType(step);
expect(stepType).toBe(WebAuthnStepType.Authentication);
});

it('should create authentication public key with empty allowCredentials for conditional UI', () => {
const metadata: any = {
_action: 'webauthn_authentication',
challenge: 'JEisuqkVMhI490jM0/iEgrRz+j94OoGc7gdY4gYicSk=',
allowCredentials: '',
_allowCredentials: [],
timeout: 60000,
userVerification: 'preferred',
conditionalWebAuthn: true,
relyingPartyId: '',
_relyingPartyId: 'example.com',
extensions: {},
supportsJsonResponse: true,
};

const publicKey = FRWebAuthn.createAuthenticationPublicKey(metadata);

expect(publicKey.challenge).toBeDefined();
expect(publicKey.timeout).toBe(60000);
expect(publicKey.userVerification).toBe('preferred');
expect(publicKey.rpId).toBe('example.com');
// allowCredentials should not be present for conditional UI with empty credentials
expect(publicKey.allowCredentials).toBeUndefined();
});

it('should warn and return false if conditional UI is requested but not supported', async () => {
Config.set({
serverConfig: {
baseUrl: 'http://localhost:8080',
},
clientId: 'test',
realmPath: 'alpha',
logLevel: 'warn',
});

// Mock browser support for conditional UI to be false
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(
false,
);
// FIX APPLIED HERE: Added block comment to empty function
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
/* empty */
});
const getSpy = vi.spyOn(navigator.credentials, 'get');

// Attempt to authenticate with conditional UI requested
await FRWebAuthn.getAuthenticationCredential({}, true);

// Expect a warning to be logged
expect(consoleSpy).toHaveBeenCalledWith(
'Conditional UI was requested, but is not supported by this browser.',
);

// Expect the call to navigator.credentials.get to NOT have the mediation property
expect(getSpy).toHaveBeenCalledWith(
expect.not.objectContaining({
mediation: 'conditional',
}),
);
});

it('should set mediation to conditional if supported', async () => {
// Mock browser support for conditional UI to be true
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
const getSpy = vi.spyOn(navigator.credentials, 'get');

// Attempt to authenticate with conditional UI requested
await FRWebAuthn.getAuthenticationCredential({}, true);

// Expect the call to navigator.credentials.get to have the mediation property
expect(getSpy).toHaveBeenCalledWith(
expect.objectContaining({
mediation: 'conditional',
}),
);
});
});
5 changes: 5 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ function getIndexOne(arr: RegExpMatchArray | null): string {

// TODO: Remove this once AM is providing fully-serialized JSON
function parseCredentials(value: string): ParsedCredential[] {
// Handle empty string or missing value
if (!value || value === '' || value === '[]') {
return [];
}

try {
const creds = value
.split('}')
Expand Down
Loading
Loading