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; }