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
2 changes: 1 addition & 1 deletion .github/workflows/ci-fork.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ jobs:
- run: git branch --track main origin/main || true

- run: pnpm nx format:check
- run: pnpm nx affected -t build typecheck lint test e2e-ci
- run: pnpm nx affected -t build typecheck lint test e2e
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
1 change: 1 addition & 0 deletions .github/workflows/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ env:
jobs:
label-pr:
runs-on: ubuntu-latest
if: ${{github.event.pull_request.head.repo.full_name == github.repository}}
steps:
- uses: actions/checkout@v3
with:
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
6 changes: 6 additions & 0 deletions packages/javascript-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 4.9.0

### Minor Changes

- [#571](https://github.com/ForgeRock/forgerock-javascript-sdk/pull/571) [`03135cf`](https://github.com/ForgeRock/forgerock-javascript-sdk/commit/03135cf543e3f694d48e6b9e0b9116ccf42737d1) Thanks [@cameronwhitworthforgerock](https://github.com/cameronwhitworthforgerock)! - Added support for Conditional UI elements with WebAuthN

## 4.8.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/javascript-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@forgerock/javascript-sdk",
"version": "4.8.3",
"version": "4.9.0",
"description": "ForgeRock JavaScript SDK",
"author": "ForgeRock",
"license": "MIT",
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: [],
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