Skip to content

Commit 03135cf

Browse files
cameronwhitworthKMForgeRock
authored andcommitted
feat: add auto complete ui for AM-33773
1 parent 39c74ed commit 03135cf

7 files changed

Lines changed: 266 additions & 9 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

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
webAuthnAuthJSCallback70StoredUsername,
2222
webAuthnRegMetaCallback70StoredUsername,
2323
webAuthnAuthMetaCallback70StoredUsername,
24+
webAuthnAuthConditionalMetaCallback,
2425
} from './fr-webauthn.mock.data';
2526
import FRStep from '../fr-auth/fr-step';
2627

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

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('}')

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

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { CallbackType } from '../auth/enums';
1313
import type HiddenValueCallback from '../fr-auth/callbacks/hidden-value-callback';
1414
import type MetadataCallback from '../fr-auth/callbacks/metadata-callback';
1515
import type FRStep from '../fr-auth/fr-step';
16+
import { FRLogger } from '../util/logger';
1617
import { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType } from './enums';
1718
import {
1819
arrayBufferToString,
@@ -30,6 +31,7 @@ import type {
3031
} from './interfaces';
3132
import type TextOutputCallback from '../fr-auth/callbacks/text-output-callback';
3233
import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser';
34+
import { withTimeout } from '../util/timeout';
3335

3436
// <clientdata>::<attestation>::<publickeyCredential>::<DeviceName>
3537
type OutcomeWithName<
@@ -44,6 +46,8 @@ type OutcomeWithName<
4446
type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMetadata;
4547
// Script-based WebAuthn
4648
type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
49+
const TWO_SECOND = 2000;
50+
4751
/**
4852
* Utility for integrating a web browser's WebAuthn API.
4953
*
@@ -60,6 +64,24 @@ type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
6064
* await FRWebAuthn.authenticate(step);
6165
* }
6266
* ```
67+
*
68+
* Conditional UI (Autofill) Support:
69+
*
70+
* ```js
71+
* // Check if browser supports conditional UI
72+
* const supportsConditionalUI = await FRWebAuthn.isConditionalUISupported();
73+
*
74+
* if (supportsConditionalUI) {
75+
* // The authenticate() method automatically handles conditional UI
76+
* // when the server indicates support via conditionalWebAuthn: true
77+
* // in the metadata. No additional code changes needed.
78+
* await FRWebAuthn.authenticate(step);
79+
*
80+
* // For conditional UI to work in the browser, add autocomplete="webauthn"
81+
* // to your username input field:
82+
* // <input type="text" name="username" autocomplete="webauthn" />
83+
* }
84+
* ```
6385
*/
6486
abstract class FRWebAuthn {
6587
/**
@@ -94,8 +116,29 @@ abstract class FRWebAuthn {
94116
}
95117
}
96118

119+
/**
120+
* Checks if the browser supports conditional UI (autofill) for WebAuthn.
121+
*
122+
* @return Promise<boolean> indicating if conditional mediation is available
123+
*/
124+
public static async isConditionalUISupported(): Promise<boolean> {
125+
if (!window.PublicKeyCredential) {
126+
return false;
127+
}
128+
129+
// Check if the browser supports conditional mediation
130+
try {
131+
return withTimeout(PublicKeyCredential.isConditionalMediationAvailable(), TWO_SECOND);
132+
} catch {
133+
FRLogger.warn('Conditional mediation check timed out');
134+
}
135+
136+
return false;
137+
}
138+
97139
/**
98140
* Populates the step with the necessary authentication outcome.
141+
* Automatically handles conditional UI if indicated by the server metadata.
99142
*
100143
* @param step The step that contains WebAuthn authentication data
101144
* @return The populated step
@@ -108,19 +151,26 @@ abstract class FRWebAuthn {
108151

109152
try {
110153
let publicKey: PublicKeyCredentialRequestOptions;
154+
let useConditionalUI = false;
155+
111156
if (metadataCallback) {
112157
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
158+
159+
// Check if server indicates conditional UI should be used
160+
useConditionalUI = meta.conditional === 'true';
113161
publicKey = this.createAuthenticationPublicKey(meta);
114162

115163
credential = await this.getAuthenticationCredential(
116164
publicKey as PublicKeyCredentialRequestOptions,
165+
useConditionalUI,
117166
);
118167
outcome = this.getAuthenticationOutcome(credential);
119168
} else if (textOutputCallback) {
120169
publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());
121170

122171
credential = await this.getAuthenticationCredential(
123172
publicKey as PublicKeyCredentialRequestOptions,
173+
false, // Script-based callbacks don't support conditional UI
124174
);
125175
outcome = this.getAuthenticationOutcome(credential);
126176
} else {
@@ -300,18 +350,36 @@ abstract class FRWebAuthn {
300350
* Retrieves the credential from the browser Web Authentication API.
301351
*
302352
* @param options The public key options associated with the request
353+
* @param useConditionalUI Whether to use conditional UI (autofill)
303354
* @return The credential
304355
*/
305356
public static async getAuthenticationCredential(
306357
options: PublicKeyCredentialRequestOptions,
358+
useConditionalUI = false,
307359
): Promise<PublicKeyCredential | null> {
308-
// Feature check before we attempt registering a device
360+
// Feature check before we attempt authenticating
309361
if (!window.PublicKeyCredential) {
310362
const e = new Error('PublicKeyCredential not supported by this browser');
311363
e.name = WebAuthnOutcomeType.NotSupportedError;
312364
throw e;
313365
}
314-
const credential = await navigator.credentials.get({ publicKey: options });
366+
// Build the credential request options
367+
const credentialRequestOptions: CredentialRequestOptions = {
368+
publicKey: options,
369+
};
370+
371+
// Add conditional mediation if requested and supported
372+
if (useConditionalUI) {
373+
const isConditionalSupported = await this.isConditionalUISupported();
374+
if (isConditionalSupported) {
375+
credentialRequestOptions.mediation = 'conditional' as CredentialMediationRequirement;
376+
} else {
377+
// eslint-disable-next-line no-console
378+
FRLogger.warn('Conditional UI was requested, but is not supported by this browser.');
379+
}
380+
}
381+
382+
const credential = await navigator.credentials.get(credentialRequestOptions);
315383
return credential as PublicKeyCredential;
316384
}
317385

@@ -433,22 +501,51 @@ abstract class FRWebAuthn {
433501
const {
434502
acceptableCredentials,
435503
allowCredentials,
504+
_allowCredentials,
436505
challenge,
437506
relyingPartyId,
507+
_relyingPartyId,
438508
timeout,
439509
userVerification,
510+
extensions,
440511
} = metadata;
441-
const rpId = parseRelyingPartyId(relyingPartyId);
442-
const allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
443512

444-
return {
513+
// Use the structured _allowCredentials if available, otherwise parse the string format
514+
let allowCredentialsValue: PublicKeyCredentialDescriptor[] | undefined;
515+
if (_allowCredentials && Array.isArray(_allowCredentials)) {
516+
allowCredentialsValue = _allowCredentials;
517+
} else {
518+
allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
519+
}
520+
521+
// Use _relyingPartyId if available, otherwise parse the old format
522+
const rpId = _relyingPartyId || parseRelyingPartyId(relyingPartyId);
523+
524+
const options: PublicKeyCredentialRequestOptions = {
445525
challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer,
446526
timeout,
447-
// only add key-value pair if proper value is provided
448-
...(allowCredentialsValue && { allowCredentials: allowCredentialsValue }),
449-
...(userVerification && { userVerification }),
450-
...(rpId && { rpId }),
451527
};
528+
// For conditional UI, allowCredentials can be omitted.
529+
// For standard WebAuthn, it may or may not be present.
530+
// Only add the property if the array is not empty.
531+
if (allowCredentialsValue && allowCredentialsValue.length > 0) {
532+
options.allowCredentials = allowCredentialsValue;
533+
}
534+
535+
// Add optional properties only if they have values
536+
if (userVerification) {
537+
options.userVerification = userVerification;
538+
}
539+
540+
if (rpId) {
541+
options.rpId = rpId;
542+
}
543+
544+
if (extensions && Object.keys(extensions).length > 0) {
545+
options.extensions = extensions;
546+
}
547+
548+
return options;
452549
}
453550

454551
/**

0 commit comments

Comments
 (0)