Skip to content

Commit af0560c

Browse files
author
cameronwhitworth
committed
feat: add auto complete ui for AM-33773
1 parent 39c74ed commit af0560c

6 files changed

Lines changed: 256 additions & 9 deletions

File tree

.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: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,24 @@ type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
6060
* await FRWebAuthn.authenticate(step);
6161
* }
6262
* ```
63+
*
64+
* Conditional UI (Autofill) Support:
65+
*
66+
* ```js
67+
* // Check if browser supports conditional UI
68+
* const supportsConditionalUI = await FRWebAuthn.isConditionalUISupported();
69+
*
70+
* if (supportsConditionalUI) {
71+
* // The authenticate() method automatically handles conditional UI
72+
* // when the server indicates support via conditionalWebAuthn: true
73+
* // in the metadata. No additional code changes needed.
74+
* await FRWebAuthn.authenticate(step);
75+
*
76+
* // For conditional UI to work in the browser, add autocomplete="webauthn"
77+
* // to your username input field:
78+
* // <input type="text" name="username" autocomplete="webauthn" />
79+
* }
80+
* ```
6381
*/
6482
abstract class FRWebAuthn {
6583
/**
@@ -94,8 +112,27 @@ abstract class FRWebAuthn {
94112
}
95113
}
96114

115+
/**
116+
* Checks if the browser supports conditional UI (autofill) for WebAuthn.
117+
*
118+
* @return Promise<boolean> indicating if conditional mediation is available
119+
*/
120+
public static async isConditionalUISupported(): Promise<boolean> {
121+
if (!window.PublicKeyCredential) {
122+
return false;
123+
}
124+
125+
// Check if the browser supports conditional mediation
126+
if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') {
127+
return await PublicKeyCredential.isConditionalMediationAvailable();
128+
}
129+
130+
return false;
131+
}
132+
97133
/**
98134
* Populates the step with the necessary authentication outcome.
135+
* Automatically handles conditional UI if indicated by the server metadata.
99136
*
100137
* @param step The step that contains WebAuthn authentication data
101138
* @return The populated step
@@ -108,19 +145,27 @@ abstract class FRWebAuthn {
108145

109146
try {
110147
let publicKey: PublicKeyCredentialRequestOptions;
148+
let useConditionalUI = false;
149+
111150
if (metadataCallback) {
112151
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
152+
153+
// Check if server indicates conditional UI should be used
154+
useConditionalUI = meta.conditionalWebAuthn === true;
155+
113156
publicKey = this.createAuthenticationPublicKey(meta);
114157

115158
credential = await this.getAuthenticationCredential(
116159
publicKey as PublicKeyCredentialRequestOptions,
160+
useConditionalUI,
117161
);
118162
outcome = this.getAuthenticationOutcome(credential);
119163
} else if (textOutputCallback) {
120164
publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());
121165

122166
credential = await this.getAuthenticationCredential(
123167
publicKey as PublicKeyCredentialRequestOptions,
168+
false, // Script-based callbacks don't support conditional UI
124169
);
125170
outcome = this.getAuthenticationOutcome(credential);
126171
} else {
@@ -300,18 +345,36 @@ abstract class FRWebAuthn {
300345
* Retrieves the credential from the browser Web Authentication API.
301346
*
302347
* @param options The public key options associated with the request
348+
* @param useConditionalUI Whether to use conditional UI (autofill)
303349
* @return The credential
304350
*/
305351
public static async getAuthenticationCredential(
306352
options: PublicKeyCredentialRequestOptions,
353+
useConditionalUI = false,
307354
): Promise<PublicKeyCredential | null> {
308-
// Feature check before we attempt registering a device
355+
// Feature check before we attempt authenticating
309356
if (!window.PublicKeyCredential) {
310357
const e = new Error('PublicKeyCredential not supported by this browser');
311358
e.name = WebAuthnOutcomeType.NotSupportedError;
312359
throw e;
313360
}
314-
const credential = await navigator.credentials.get({ publicKey: options });
361+
// Build the credential request options
362+
const credentialRequestOptions: CredentialRequestOptions = {
363+
publicKey: options,
364+
};
365+
366+
// Add conditional mediation if requested and supported
367+
if (useConditionalUI) {
368+
const isConditionalSupported = await this.isConditionalUISupported();
369+
if (isConditionalSupported) {
370+
credentialRequestOptions.mediation = 'conditional' as CredentialMediationRequirement;
371+
} else {
372+
// eslint-disable-next-line no-console
373+
console.warn('Conditional UI was requested, but is not supported by this browser.');
374+
}
375+
}
376+
377+
const credential = await navigator.credentials.get(credentialRequestOptions);
315378
return credential as PublicKeyCredential;
316379
}
317380

@@ -433,22 +496,51 @@ abstract class FRWebAuthn {
433496
const {
434497
acceptableCredentials,
435498
allowCredentials,
499+
_allowCredentials,
436500
challenge,
437501
relyingPartyId,
502+
_relyingPartyId,
438503
timeout,
439504
userVerification,
505+
extensions,
440506
} = metadata;
441-
const rpId = parseRelyingPartyId(relyingPartyId);
442-
const allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
443507

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

454546
/**

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,18 @@ interface WebAuthnRegistrationMetadata {
7777
}
7878

7979
interface WebAuthnAuthenticationMetadata {
80+
_action?: string;
8081
acceptableCredentials?: string;
8182
allowCredentials?: string;
83+
_allowCredentials?: PublicKeyCredentialDescriptor[];
8284
challenge: string;
8385
relyingPartyId: string;
86+
_relyingPartyId?: string;
8487
timeout: number;
8588
userVerification: UserVerificationType;
89+
conditionalWebAuthn?: boolean;
90+
extensions?: Record<string, unknown>;
91+
_type?: string;
8692
supportsJsonResponse?: boolean;
8793
}
8894

0 commit comments

Comments
 (0)