diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ca64a418..3a40fa8d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,8 @@ 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:
diff --git a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
index 98b6df6e..092910b3 100644
--- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
@@ -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' }],
+ },
+ ],
+};
diff --git a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
index d14d1794..e6a15f8e 100644
--- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
@@ -21,6 +21,7 @@ import {
webAuthnAuthJSCallback70StoredUsername,
webAuthnRegMetaCallback70StoredUsername,
webAuthnAuthMetaCallback70StoredUsername,
+ webAuthnAuthConditionalMetaCallback,
} from './fr-webauthn.mock.data';
import FRStep from '../fr-auth/fr-step';
@@ -104,3 +105,41 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
expect(stepType).toBe(WebAuthnStepType.Authentication);
});
});
+
+describe('Test FRWebAuthn class with Conditional UI', () => {
+ it('should detect if conditional UI is supported', async () => {
+ const isSupported = await FRWebAuthn.isConditionalUISupported();
+ expect(typeof isSupported).toBe('boolean');
+ });
+
+ 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();
+ });
+});
diff --git a/packages/javascript-sdk/src/fr-webauthn/helpers.ts b/packages/javascript-sdk/src/fr-webauthn/helpers.ts
index fb704760..b2c6d51c 100644
--- a/packages/javascript-sdk/src/fr-webauthn/helpers.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/helpers.ts
@@ -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('}')
diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts
index 3c697fac..43fc0c07 100644
--- a/packages/javascript-sdk/src/fr-webauthn/index.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/index.ts
@@ -60,6 +60,24 @@ type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
* await FRWebAuthn.authenticate(step);
* }
* ```
+ *
+ * Conditional UI (Autofill) Support:
+ *
+ * ```js
+ * // Check if browser supports conditional UI
+ * const supportsConditionalUI = await FRWebAuthn.isConditionalUISupported();
+ *
+ * if (supportsConditionalUI) {
+ * // The authenticate() method automatically handles conditional UI
+ * // when the server indicates support via conditionalWebAuthn: true
+ * // in the metadata. No additional code changes needed.
+ * await FRWebAuthn.authenticate(step);
+ *
+ * // For conditional UI to work in the browser, add autocomplete="webauthn"
+ * // to your username input field:
+ * //
+ * }
+ * ```
*/
abstract class FRWebAuthn {
/**
@@ -94,8 +112,27 @@ abstract class FRWebAuthn {
}
}
+ /**
+ * Checks if the browser supports conditional UI (autofill) for WebAuthn.
+ *
+ * @return Promise indicating if conditional mediation is available
+ */
+ public static async isConditionalUISupported(): Promise {
+ if (!window.PublicKeyCredential) {
+ return false;
+ }
+
+ // Check if the browser supports conditional mediation
+ if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') {
+ return await PublicKeyCredential.isConditionalMediationAvailable();
+ }
+
+ return false;
+ }
+
/**
* Populates the step with the necessary authentication outcome.
+ * Automatically handles conditional UI if indicated by the server metadata.
*
* @param step The step that contains WebAuthn authentication data
* @return The populated step
@@ -108,12 +145,19 @@ abstract class FRWebAuthn {
try {
let publicKey: PublicKeyCredentialRequestOptions;
+ let useConditionalUI = false;
+
if (metadataCallback) {
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
+
+ // Check if server indicates conditional UI should be used
+ useConditionalUI = meta.conditionalWebAuthn === true;
+
publicKey = this.createAuthenticationPublicKey(meta);
credential = await this.getAuthenticationCredential(
publicKey as PublicKeyCredentialRequestOptions,
+ useConditionalUI,
);
outcome = this.getAuthenticationOutcome(credential);
} else if (textOutputCallback) {
@@ -121,6 +165,7 @@ abstract class FRWebAuthn {
credential = await this.getAuthenticationCredential(
publicKey as PublicKeyCredentialRequestOptions,
+ false, // Script-based callbacks don't support conditional UI
);
outcome = this.getAuthenticationOutcome(credential);
} else {
@@ -300,18 +345,34 @@ abstract class FRWebAuthn {
* Retrieves the credential from the browser Web Authentication API.
*
* @param options The public key options associated with the request
+ * @param useConditionalUI Whether to use conditional UI (autofill)
* @return The credential
*/
public static async getAuthenticationCredential(
options: PublicKeyCredentialRequestOptions,
+ useConditionalUI = false,
): Promise {
- // Feature check before we attempt registering a device
+ // Feature check before we attempt authenticating
if (!window.PublicKeyCredential) {
const e = new Error('PublicKeyCredential not supported by this browser');
e.name = WebAuthnOutcomeType.NotSupportedError;
throw e;
}
- const credential = await navigator.credentials.get({ publicKey: options });
+
+ // Build the credential request options
+ const credentialRequestOptions: CredentialRequestOptions = {
+ publicKey: options,
+ };
+
+ // Add conditional mediation if requested and supported
+ if (useConditionalUI) {
+ const isConditionalSupported = await this.isConditionalUISupported();
+ if (isConditionalSupported) {
+ credentialRequestOptions.mediation = 'conditional' as CredentialMediationRequirement;
+ }
+ }
+
+ const credential = await navigator.credentials.get(credentialRequestOptions);
return credential as PublicKeyCredential;
}
@@ -433,22 +494,51 @@ abstract class FRWebAuthn {
const {
acceptableCredentials,
allowCredentials,
+ _allowCredentials,
challenge,
relyingPartyId,
+ _relyingPartyId,
timeout,
userVerification,
+ extensions,
} = metadata;
- const rpId = parseRelyingPartyId(relyingPartyId);
- const allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
- return {
+ // Use the structured _allowCredentials if available, otherwise parse the string format
+ let allowCredentialsValue: PublicKeyCredentialDescriptor[] | undefined;
+ if (_allowCredentials && Array.isArray(_allowCredentials)) {
+ allowCredentialsValue = _allowCredentials;
+ } else {
+ allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
+ }
+
+ // Use _relyingPartyId if available, otherwise parse the old format
+ const rpId = _relyingPartyId || parseRelyingPartyId(relyingPartyId);
+
+ const options: PublicKeyCredentialRequestOptions = {
challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer,
timeout,
- // only add key-value pair if proper value is provided
- ...(allowCredentialsValue && { allowCredentials: allowCredentialsValue }),
- ...(userVerification && { userVerification }),
- ...(rpId && { rpId }),
};
+
+ // For conditional UI, allowCredentials should be an empty array or omitted
+ // Only add if there are actual credentials AND not empty
+ if (allowCredentialsValue && allowCredentialsValue.length > 0) {
+ options.allowCredentials = allowCredentialsValue;
+ }
+
+ // Add optional properties only if they have values
+ if (userVerification) {
+ options.userVerification = userVerification;
+ }
+
+ if (rpId) {
+ options.rpId = rpId;
+ }
+
+ if (extensions && Object.keys(extensions).length > 0) {
+ options.extensions = extensions;
+ }
+
+ return options;
}
/**
diff --git a/packages/javascript-sdk/src/fr-webauthn/interfaces.ts b/packages/javascript-sdk/src/fr-webauthn/interfaces.ts
index 6161f5ec..f3fdeeab 100644
--- a/packages/javascript-sdk/src/fr-webauthn/interfaces.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/interfaces.ts
@@ -77,12 +77,18 @@ interface WebAuthnRegistrationMetadata {
}
interface WebAuthnAuthenticationMetadata {
+ _action?: string;
acceptableCredentials?: string;
allowCredentials?: string;
+ _allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: string;
relyingPartyId: string;
+ _relyingPartyId?: string;
timeout: number;
userVerification: UserVerificationType;
+ conditionalWebAuthn?: boolean;
+ extensions?: Record;
+ _type?: string;
supportsJsonResponse?: boolean;
}