diff --git a/__test__/webauthnMethods.test.ts b/__test__/webauthnMethods.test.ts index ed632e2..d143727 100644 --- a/__test__/webauthnMethods.test.ts +++ b/__test__/webauthnMethods.test.ts @@ -132,4 +132,18 @@ describe('WebAuthn SDK methods', () => { const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.variables.id).toBe('cred-1'); }); + + it('loginWithPasskeyAutofill fails cleanly when conditional mediation is unavailable', async () => { + // No PublicKeyCredential in the Node test env, so conditional mediation is + // unavailable: the method must return an error WITHOUT touching the network + // (no login-options request) rather than throwing. + const res = await authorizerRef.loginWithPasskeyAutofill(); + expect(res.errors).toHaveLength(1); + expect(res.errors[0].message).toMatch(/autofill is not available/i); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('cancelPasskeyAutofill is safe to call with no ceremony in flight', () => { + expect(() => authorizerRef.cancelPasskeyAutofill()).not.toThrow(); + }); }); diff --git a/src/index.ts b/src/index.ts index f31cc14..df1658e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,10 @@ export class Authorizer { // class variable config: Types.ConfigType; codeVerifier: string; + // Tracks the in-flight passkey-autofill (conditional mediation) ceremony so + // it can be aborted before a modal ceremony starts - the browser allows only + // one outstanding navigator.credentials.get() at a time. + private conditionalPasskeyAbort?: AbortController; // constructor constructor(config: Types.ConfigType) { @@ -744,20 +748,64 @@ export class Authorizer { // loginWithPasskey drives the full login ceremony end to end. Omit `email` // for a usernameless (discoverable-credential) login; pass it to scope the - // ceremony to one account's own passkeys (the MFA-alternative flow). + // ceremony to one account's own passkeys (the MFA-alternative flow). Pass + // `opts.mediation: 'conditional'` (with an AbortSignal) for passkey autofill; + // prefer loginWithPasskeyAutofill() which manages the signal for you. loginWithPasskey = async ( email?: string, + opts?: { mediation?: CredentialMediationRequirement; signal?: AbortSignal }, ): Promise> => { try { + // A modal ceremony must cancel any pending autofill request first, or the + // browser rejects the modal get() with "a request is already pending". + if (opts?.mediation !== 'conditional') { + this.conditionalPasskeyAbort?.abort(); + this.conditionalPasskeyAbort = undefined; + } const optRes = await this.webauthnLoginOptions(email); if (optRes.errors.length) return this.errorResponse(optRes.errors); - const credential = await webauthn.loginWithPasskey(optRes.data!.options); + const credential = await webauthn.loginWithPasskey( + optRes.data!.options, + opts, + ); return this.webauthnLoginVerify({ credential }); } catch (err) { return this.errorResponse([err]); } }; + // loginWithPasskeyAutofill starts a "passkey autofill" (conditional + // mediation) login: the browser offers discoverable passkeys inline in a + // username field's autofill dropdown rather than a modal. The returned + // promise resolves ONLY when the user actually picks a passkey (or rejects + // when aborted/cancelled), so fire it on mount and ignore abort errors. + // Requires an input with autocomplete="username webauthn" on the page. Only + // one runs at a time: a new call, or an explicit modal loginWithPasskey(), + // aborts the previous one. + loginWithPasskeyAutofill = async (): Promise< + Types.ApiResponse + > => { + if (!(await webauthn.isConditionalMediationAvailable())) { + return this.errorResponse([ + new Error('Passkey autofill is not available in this browser.'), + ]); + } + this.conditionalPasskeyAbort?.abort(); + const controller = new AbortController(); + this.conditionalPasskeyAbort = controller; + return this.loginWithPasskey(undefined, { + mediation: 'conditional', + signal: controller.signal, + }); + }; + + // cancelPasskeyAutofill aborts a pending loginWithPasskeyAutofill ceremony, + // e.g. on component unmount. Safe to call when none is in flight. + cancelPasskeyAutofill = (): void => { + this.conditionalPasskeyAbort?.abort(); + this.conditionalPasskeyAbort = undefined; + }; + resetPassword = async ( data: Types.ResetPasswordRequest, ): Promise> => { diff --git a/src/webauthn.ts b/src/webauthn.ts index 3e44276..8447205 100644 --- a/src/webauthn.ts +++ b/src/webauthn.ts @@ -27,6 +27,26 @@ const assertSupported = () => { } }; +// isConditionalMediationAvailable reports whether the browser supports +// conditional mediation ("passkey autofill") - surfacing discoverable passkeys +// inline in a username field's autofill dropdown instead of a modal prompt. +// Async because the underlying PublicKeyCredential API is a promise; returns +// false (never throws) when unsupported so callers can guard cheaply. +export const isConditionalMediationAvailable = async (): Promise => { + if ( + !isWebauthnSupported() || + typeof window.PublicKeyCredential.isConditionalMediationAvailable !== + 'function' + ) { + return false; + } + try { + return await window.PublicKeyCredential.isConditionalMediationAvailable(); + } catch { + return false; + } +}; + // wrapCeremonyError normalizes what navigator.credentials.create/get can // throw. A DOMException (e.g. NotAllowedError for "user cancelled or the // ceremony timed out") gets its standard `.name` attached as `.code` - the @@ -75,15 +95,25 @@ export const registerPasskey = async (optionsJSON: string): Promise => { // loginWithPasskey drives a full login (assertion) ceremony: pass the // `options` string returned by webauthn_login_options, get back the -// `credential` string to send to webauthn_login_verify. -export const loginWithPasskey = async (optionsJSON: string): Promise => { +// `credential` string to send to webauthn_login_verify. Pass +// `mediation: 'conditional'` (with an AbortSignal) for the passkey-autofill +// flow, where the browser offers passkeys inline in a username field instead +// of a modal. +export const loginWithPasskey = async ( + optionsJSON: string, + opts?: { mediation?: CredentialMediationRequirement; signal?: AbortSignal }, +): Promise => { assertSupported(); const options = window.PublicKeyCredential.parseRequestOptionsFromJSON( JSON.parse(optionsJSON), ); let credential: Credential | null; try { - credential = await navigator.credentials.get({ publicKey: options }); + credential = await navigator.credentials.get({ + publicKey: options, + mediation: opts?.mediation, + signal: opts?.signal, + }); } catch (err) { throw wrapCeremonyError(err, 'Passkey login'); }