diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca64a418..b545f5cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ 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: files: ./packages/**/coverage/*.xml 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..07e33b61 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,106 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => { expect(stepType).toBe(WebAuthnStepType.Authentication); }); }); + +describe('Test FRWebAuthn class with Conditional UI', () => { + beforeEach(() => { + // Mock navigator.credentials and window.PublicKeyCredential + Object.defineProperty(global.navigator, 'credentials', { + value: { + get: vi.fn().mockResolvedValue(null), + create: vi.fn(), + }, + writable: true, + }); + Object.defineProperty(window, 'PublicKeyCredential', { + value: { + isConditionalMediationAvailable: vi.fn(), + }, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should detect if conditional UI is supported', async () => { + vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true); + const isSupported = await FRWebAuthn.isConditionalUISupported(); + expect(isSupported).toBe(true); + }); + + 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(); + }); + + it('should warn and fallback if conditional UI is requested but not supported', async () => { + // Mock browser support for conditional UI to be false + vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue( + false, + ); + // FIX APPLIED HERE: Added block comment to empty function + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* empty */ + }); + const getSpy = vi.spyOn(navigator.credentials, 'get'); + + // Attempt to authenticate with conditional UI requested + await FRWebAuthn.getAuthenticationCredential({}, true); + + // Expect a warning to be logged + expect(consoleSpy).toHaveBeenCalledWith( + 'Conditional UI was requested, but is not supported by this browser.', + ); + + // Expect the call to navigator.credentials.get to NOT have the mediation property + expect(getSpy).toHaveBeenCalledWith( + expect.not.objectContaining({ + mediation: 'conditional', + }), + ); + }); + + it('should set mediation to conditional if supported', async () => { + // Mock browser support for conditional UI to be true + vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true); + const getSpy = vi.spyOn(navigator.credentials, 'get'); + + // Attempt to authenticate with conditional UI requested + await FRWebAuthn.getAuthenticationCredential({}, true); + + // Expect the call to navigator.credentials.get to have the mediation property + expect(getSpy).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + }), + ); + }); +}); 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..c547d881 100644 --- a/packages/javascript-sdk/src/fr-webauthn/index.ts +++ b/packages/javascript-sdk/src/fr-webauthn/index.ts @@ -13,6 +13,7 @@ import { CallbackType } from '../auth/enums'; import type HiddenValueCallback from '../fr-auth/callbacks/hidden-value-callback'; import type MetadataCallback from '../fr-auth/callbacks/metadata-callback'; import type FRStep from '../fr-auth/fr-step'; +import { FRLogger } from '../util/logger'; import { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType } from './enums'; import { arrayBufferToString, @@ -30,6 +31,7 @@ import type { } from './interfaces'; import type TextOutputCallback from '../fr-auth/callbacks/text-output-callback'; import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser'; +import { withTimeout } from '../util/timeout'; // :::::: type OutcomeWithName< @@ -44,6 +46,8 @@ type OutcomeWithName< type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMetadata; // Script-based WebAuthn type WebAuthnTextOutput = WebAuthnTextOutputRegistration; +const ONE_SECOND = 1000; + /** * Utility for integrating a web browser's WebAuthn API. * @@ -60,6 +64,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 +116,29 @@ 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 + try { + return withTimeout(PublicKeyCredential.isConditionalMediationAvailable(), ONE_SECOND); + } catch { + FRLogger.warn('Conditional mediation check timed out'); + } + + 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 +151,18 @@ 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.conditional === 'true'; publicKey = this.createAuthenticationPublicKey(meta); credential = await this.getAuthenticationCredential( publicKey as PublicKeyCredentialRequestOptions, + useConditionalUI, ); outcome = this.getAuthenticationOutcome(credential); } else if (textOutputCallback) { @@ -121,6 +170,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 +350,36 @@ 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; + } else { + // eslint-disable-next-line no-console + FRLogger.warn('Conditional UI was requested, but is not supported by this browser.'); + } + } + + const credential = await navigator.credentials.get(credentialRequestOptions); return credential as PublicKeyCredential; } @@ -433,22 +501,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 can be omitted. + // For standard WebAuthn, it may or may not be present. + // Only add the property if the array is 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..9990868a 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?: 'webauthn_authentication'; acceptableCredentials?: string; allowCredentials?: string; + _allowCredentials?: PublicKeyCredentialDescriptor[]; challenge: string; relyingPartyId: string; + _relyingPartyId?: string; timeout: number; userVerification: UserVerificationType; + conditional?: string; + extensions?: Record; + _type?: 'WebAuthn'; supportsJsonResponse?: boolean; }