Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions __test__/webauthnMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
52 changes: 50 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Types.ApiResponse<Types.AuthToken>> => {
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<Types.AuthToken>
> => {
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<Types.ApiResponse<Types.GenericResponse>> => {
Expand Down
36 changes: 33 additions & 3 deletions src/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
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
Expand Down Expand Up @@ -75,15 +95,25 @@ export const registerPasskey = async (optionsJSON: string): Promise<string> => {

// 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<string> => {
// `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<string> => {
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');
}
Expand Down
Loading