Skip to content

Commit 04ae8bb

Browse files
authored
Merge pull request #571 from cameronwhitworthforgerock/AME-33773
feat: add auto complete ui for AM-33773
2 parents a732896 + b1b29d4 commit 04ae8bb

8 files changed

Lines changed: 277 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/javascript-sdk': minor
3+
---
4+
5+
Added support for Conditional UI elements with WebAuthN

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ jobs:
3636
- run: pnpm exec nx-cloud record -- nx format:check --verbose
3737
- run: pnpm exec nx affected -t build lint test docs e2e-ci
3838

39+
- name: Publish previews to Stackblitz on PR
40+
run: pnpm pkg-pr-new publish './packages/*' --packageManager=pnpm
41+
3942
- uses: codecov/codecov-action@v5
4043
with:
4144
files: ./packages/**/coverage/*.xml

e2e/autoscript-suites/src/suites/authn-central-logout.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ test('should login and logout with pingone', async ({ page }) => {
2020
await btn.click({ delay: 1000 });
2121
await page.waitForURL(/ping/);
2222
await page.getByPlaceholder('Username').fill('reactdavinci@user.com');
23-
await page.getByRole('textbox', { name: 'Password' }).fill('bae0fzc-mzg3krg5FQB');
23+
await page.getByRole('textbox', { name: 'Password' }).fill('twf0MCH5xnw.jcj4qtq');
2424
await page.getByRole('button', { name: 'Sign On' }).click();
2525

2626
await expect(page.getByText('preferred_username')).toContainText('reactdavinci@user.com');

packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,40 @@ export const webAuthnRegMetaCallbackJsonResponse = {
489489
},
490490
],
491491
};
492+
493+
export const webAuthnAuthConditionalMetaCallback = {
494+
authId: 'test-auth-id-conditional',
495+
callbacks: [
496+
{
497+
type: CallbackType.MetadataCallback,
498+
output: [
499+
{
500+
name: 'data',
501+
value: {
502+
_action: 'webauthn_authentication',
503+
challenge: 'JEisuqkVMhI490jM0/iEgrRz+j94OoGc7gdY4gYicSk=',
504+
allowCredentials: '',
505+
_allowCredentials: [],
506+
timeout: 60000,
507+
userVerification: 'preferred',
508+
conditionalWebAuthn: true,
509+
relyingPartyId: '',
510+
_relyingPartyId: 'example.com',
511+
extensions: {},
512+
_type: 'WebAuthn',
513+
supportsJsonResponse: true,
514+
},
515+
},
516+
],
517+
_id: 0,
518+
},
519+
{
520+
type: CallbackType.HiddenValueCallback,
521+
output: [
522+
{ name: 'value', value: 'false' },
523+
{ name: 'id', value: 'webAuthnOutcome' },
524+
],
525+
input: [{ name: 'IDToken1', value: 'webAuthnOutcome' }],
526+
},
527+
],
528+
};

packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import {
2121
webAuthnAuthJSCallback70StoredUsername,
2222
webAuthnRegMetaCallback70StoredUsername,
2323
webAuthnAuthMetaCallback70StoredUsername,
24+
webAuthnAuthConditionalMetaCallback,
2425
} from './fr-webauthn.mock.data';
2526
import FRStep from '../fr-auth/fr-step';
27+
import Config from '../config';
2628

2729
describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => {
2830
it('should return Registration type with register text-output callbacks', () => {
@@ -104,3 +106,115 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
104106
expect(stepType).toBe(WebAuthnStepType.Authentication);
105107
});
106108
});
109+
110+
describe('Test FRWebAuthn class with Conditional UI', () => {
111+
beforeEach(() => {
112+
// Mock navigator.credentials and window.PublicKeyCredential
113+
Object.defineProperty(global.navigator, 'credentials', {
114+
value: {
115+
get: vi.fn().mockResolvedValue(null),
116+
create: vi.fn(),
117+
},
118+
writable: true,
119+
});
120+
Object.defineProperty(window, 'PublicKeyCredential', {
121+
value: {
122+
isConditionalMediationAvailable: vi.fn(),
123+
},
124+
writable: true,
125+
});
126+
});
127+
128+
afterEach(() => {
129+
vi.restoreAllMocks();
130+
});
131+
132+
it('should detect if conditional UI is supported', async () => {
133+
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
134+
const isSupported = await FRWebAuthn.isConditionalUISupported();
135+
expect(isSupported).toBe(true);
136+
});
137+
138+
it('should return Authentication type with conditional UI metadata callback', () => {
139+
const step = new FRStep(webAuthnAuthConditionalMetaCallback as any);
140+
const stepType = FRWebAuthn.getWebAuthnStepType(step);
141+
expect(stepType).toBe(WebAuthnStepType.Authentication);
142+
});
143+
144+
it('should create authentication public key with empty allowCredentials for conditional UI', () => {
145+
const metadata: any = {
146+
_action: 'webauthn_authentication',
147+
challenge: 'JEisuqkVMhI490jM0/iEgrRz+j94OoGc7gdY4gYicSk=',
148+
allowCredentials: '',
149+
_allowCredentials: [],
150+
timeout: 60000,
151+
userVerification: 'preferred',
152+
conditionalWebAuthn: true,
153+
relyingPartyId: '',
154+
_relyingPartyId: 'example.com',
155+
extensions: {},
156+
supportsJsonResponse: true,
157+
};
158+
159+
const publicKey = FRWebAuthn.createAuthenticationPublicKey(metadata);
160+
161+
expect(publicKey.challenge).toBeDefined();
162+
expect(publicKey.timeout).toBe(60000);
163+
expect(publicKey.userVerification).toBe('preferred');
164+
expect(publicKey.rpId).toBe('example.com');
165+
// allowCredentials should not be present for conditional UI with empty credentials
166+
expect(publicKey.allowCredentials).toBeUndefined();
167+
});
168+
169+
it('should warn and return false if conditional UI is requested but not supported', async () => {
170+
Config.set({
171+
serverConfig: {
172+
baseUrl: 'http://localhost:8080',
173+
},
174+
clientId: 'test',
175+
realmPath: 'alpha',
176+
logLevel: 'warn',
177+
});
178+
179+
// Mock browser support for conditional UI to be false
180+
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(
181+
false,
182+
);
183+
// FIX APPLIED HERE: Added block comment to empty function
184+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
185+
/* empty */
186+
});
187+
const getSpy = vi.spyOn(navigator.credentials, 'get');
188+
189+
// Attempt to authenticate with conditional UI requested
190+
await FRWebAuthn.getAuthenticationCredential({}, true);
191+
192+
// Expect a warning to be logged
193+
expect(consoleSpy).toHaveBeenCalledWith(
194+
'Conditional UI was requested, but is not supported by this browser.',
195+
);
196+
197+
// Expect the call to navigator.credentials.get to NOT have the mediation property
198+
expect(getSpy).toHaveBeenCalledWith(
199+
expect.not.objectContaining({
200+
mediation: 'conditional',
201+
}),
202+
);
203+
});
204+
205+
it('should set mediation to conditional if supported', async () => {
206+
// Mock browser support for conditional UI to be true
207+
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
208+
const getSpy = vi.spyOn(navigator.credentials, 'get');
209+
210+
// Attempt to authenticate with conditional UI requested
211+
await FRWebAuthn.getAuthenticationCredential({}, true);
212+
213+
// Expect the call to navigator.credentials.get to have the mediation property
214+
expect(getSpy).toHaveBeenCalledWith(
215+
expect.objectContaining({
216+
mediation: 'conditional',
217+
}),
218+
);
219+
});
220+
});

packages/javascript-sdk/src/fr-webauthn/helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ function getIndexOne(arr: RegExpMatchArray | null): string {
3636

3737
// TODO: Remove this once AM is providing fully-serialized JSON
3838
function parseCredentials(value: string): ParsedCredential[] {
39+
// Handle empty string or missing value
40+
if (!value || value === '' || value === '[]') {
41+
return [];
42+
}
43+
3944
try {
4045
const creds = value
4146
.split('}')

0 commit comments

Comments
 (0)