feat(app): add passkey popup bridge for password managers (#2957)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-25 17:43:19 +02:00
1 parent 838bbdb5ec
commit ff62bc89a4
51 files changed
+5429 -2681

No files matched your search

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isPasskeyBridgeAvailable} from '@app/features/auth/utils/PasskeyBridge';
import {Button} from '@app/features/ui/button/Button';
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {PasswordIcon} from '@phosphor-icons/react';
import type React from 'react';
const USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR = msg({
message: 'Use a password manager passkey',
comment:
'Button that runs the passkey prompt in a small pop-up window on the old web address, so password manager extensions can find passkeys saved there.',
});
const PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR = msg({
message:
'Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead.',
comment:
'Shown after a passkey prompt fails, above the button that retries the passkey prompt in a pop-up window on the old web address.',
});
interface PasswordManagerPasskeyActionProps {
suggested: boolean;
disabled?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>) => void;
}
export function PasswordManagerPasskeyAction({suggested, disabled, onClick}: PasswordManagerPasskeyActionProps) {
const {i18n} = useLingui();
if (!isPasskeyBridgeAvailable()) {
return null;
}
const button = (
<Button
type="button"
fitContainer
variant={suggested ? 'primary' : 'secondary'}
onClick={onClick}
disabled={disabled}
leftIcon={<PasswordIcon size={16} data-flx="auth.password-manager-passkey-action.icon" />}
data-flx="auth.password-manager-passkey-action.button"
>
{i18n._(USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR)}
</Button>
);
if (!suggested) {
return button;
}
return (
<WarningAlert actions={button} data-flx="auth.password-manager-passkey-action.suggestion">
{i18n._(PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR)}
</WarningAlert>
);
}
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
import {describePasskeyBridgeFailure, shouldSuggestPasskeyBridge} from '@app/features/auth/utils/PasskeyBridge';
import {
PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR,
PasskeyDomainUnsupportedError,
@@ -14,6 +16,8 @@ import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useState} from 'react';
import {useForm} from 'react-hook-form';
const NAME_PASSKEY_FORM_DESCRIPTOR = msg({
@@ -37,14 +41,54 @@ interface FormInputs {
name: string;
}
export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string) => void | Promise<void>}) => {
interface PasskeyNameModalProps {
onSubmit: (name: string) => void | Promise<void>;
onSubmitWithPasswordManager?: (name: string) => Promise<void>;
}
export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager}: PasskeyNameModalProps) => {
const {i18n} = useLingui();
const form = useForm<FormInputs>();
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
const [passkeyBridgeSubmitting, setPasskeyBridgeSubmitting] = useState(false);
const handlePasswordManagerSubmit = (
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,
) => {
if (!onSubmitWithPasswordManager || passkeyBridgeSubmitting || form.formState.isSubmitting) {
return;
}
if (event.currentTarget.form?.reportValidity() === false) {
return;
}
const submission = onSubmitWithPasswordManager(form.getValues('name').trim());
form.clearErrors('name');
setPasskeyBridgeSubmitting(true);
submission
.then(() => {
ModalCommands.pop();
})
.catch((error: unknown) => {
if (error instanceof HttpError) {
FormUtils.handleError(i18n, form, error, 'name');
return;
}
const descriptor = describePasskeyBridgeFailure(error);
if (descriptor) {
form.setError('name', {type: 'server', message: i18n._(descriptor)});
}
})
.finally(() => {
setPasskeyBridgeSubmitting(false);
});
};
const handleSubmit = async (data: FormInputs) => {
try {
await onSubmit(data.name.trim());
ModalCommands.pop();
} catch (error) {
if (onSubmitWithPasswordManager && shouldSuggestPasskeyBridge(error)) {
setPasskeyBridgeSuggested(true);
}
if (error instanceof HttpError) {
FormUtils.handleError(i18n, form, error, 'name');
} else if (error instanceof PasskeyDomainUnsupportedError) {
@@ -79,6 +123,14 @@ export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string)
required={true}
type="text"
/>
{onSubmitWithPasswordManager && (
<PasswordManagerPasskeyAction
suggested={passkeyBridgeSuggested}
disabled={form.formState.isSubmitting || passkeyBridgeSubmitting}
onClick={handlePasswordManagerSubmit}
data-flx="auth.passkey-name-modal.password-manager-passkey-action"
/>
)}
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer data-flx="auth.passkey-name-modal.modal-footer">
@@ -88,6 +140,7 @@ export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string)
<Button
type="submit"
submitting={form.formState.isSubmitting}
disabled={passkeyBridgeSubmitting}
data-flx="auth.passkey-name-modal.button.submit"
>
<Trans>Save</Trans>
@@ -3,7 +3,13 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import styles from '@app/features/auth/components/modals/SudoVerificationModal.module.css';
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
import SudoPrompt, {SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
import {
describePasskeyBridgeFailure,
runPasskeyViaBridge,
shouldSuggestPasskeyBridge,
} from '@app/features/auth/utils/PasskeyBridge';
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
import {PASSWORD_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {http} from '@app/features/platform/transport/RestTransport';
@@ -15,6 +21,7 @@ import {Spinner} from '@app/features/ui/components/Spinner';
import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useRef, useState} from 'react';
@@ -82,6 +89,7 @@ const SudoVerificationModal: React.FC = observer(() => {
const form = useForm<FormInputs>({defaultValues: {password: '', totp: ''}});
const [webAuthnInFlight, setWebAuthnInFlight] = useState(false);
const [webAuthnError, setWebAuthnError] = useState<string | null>(null);
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
const autoTriggeredRef = useRef(false);
const showPasskey = availableMethods.webauthn;
const showTotp = availableMethods.totp;
@@ -126,6 +134,10 @@ const SudoVerificationModal: React.FC = observer(() => {
setWebAuthnError(i18n._(PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR));
return;
}
if (shouldSuggestPasskeyBridge(err)) {
setPasskeyBridgeSuggested(true);
return;
}
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
setWebAuthnError(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
return;
@@ -133,6 +145,32 @@ const SudoVerificationModal: React.FC = observer(() => {
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR));
}
};
const handlePasskeyBridge = () => {
if (webAuthnInFlight || isVerifying) return;
const options = http
.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.SUDO_WEBAUTHN_OPTIONS)
.then((response) => response.body);
const credential = runPasskeyViaBridge('authenticate', options);
setWebAuthnError(null);
form.clearErrors();
setWebAuthnInFlight(true);
Promise.all([options, credential])
.then(([resolvedOptions, resolvedCredential]) => {
SudoPrompt.submit({
mfa_method: SudoVerificationMethod.WEBAUTHN,
webauthn_challenge: resolvedOptions.challenge,
webauthn_response: resolvedCredential,
});
})
.catch((err: unknown) => {
logger.error('WebAuthn verification in the pop-up window failed', err);
setWebAuthnInFlight(false);
const descriptor = describePasskeyBridgeFailure(err);
if (descriptor) {
setWebAuthnError(i18n._(descriptor));
}
});
};
useEffect(() => {
if (autoTriggeredRef.current) return;
if (!showPasskey || showPassword || showCode) return;
@@ -249,6 +287,14 @@ const SudoVerificationModal: React.FC = observer(() => {
<Trans>Continue with passkey</Trans>
</Button>
)}
{!webAuthnInFlight && (
<PasswordManagerPasskeyAction
suggested={passkeyBridgeSuggested}
disabled={isVerifying}
onClick={handlePasskeyBridge}
data-flx="auth.sudo-verification-modal.password-manager-passkey-action"
/>
)}
{webAuthnError && (
<p className={styles.formError} role="alert" data-flx="auth.sudo-verification-modal.form-error">
{webAuthnError}
@@ -162,18 +162,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
},
[desktopHandoff, handoff, onLoginComplete],
);
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
useLoginFormController({
redirectPath,
inviteCode,
onLoginSuccess: handleLoginSuccess,
onRequireMfa: (challenge) => {
AuthenticationCommands.setMfaTicket(challenge);
},
onRequireIpAuthorization: (challenge) => {
setIpAuthChallenge(challenge);
},
});
const {
form,
isLoading,
fieldErrors,
handlePasskeyLogin,
handlePasskeyBrowserLogin,
handlePasskeyBridgeLogin,
passkeyBridgeSuggested,
isPasskeyLoading,
} = useLoginFormController({
redirectPath,
inviteCode,
onLoginSuccess: handleLoginSuccess,
onRequireMfa: (challenge) => {
AuthenticationCommands.setMfaTicket(challenge);
},
onRequireIpAuthorization: (challenge) => {
setIpAuthChallenge(challenge);
},
});
const showBrowserPasskey = IS_DEV || isDesktop();
const passkeyControlsDisabled = isLoading || Boolean(form.isSubmitting) || isPasskeyLoading;
const offerOldAppSignIn = useMemo(
@@ -425,6 +433,8 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
onPasskeyLogin={handlePasskeyLogin}
showBrowserOption={showBrowserPasskey}
onBrowserLogin={handlePasskeyBrowserLogin}
onPasswordManagerLogin={handlePasskeyBridgeLogin}
passwordManagerSuggested={passkeyBridgeSuggested}
browserLabel={i18n._(SIGN_IN_VIA_BROWSER_DESCRIPTOR)}
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
/>
@@ -30,6 +30,9 @@
}
.webauthnSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MFA_CODE_DIGIT_COUNT} from '@app/features/app/config/I18nDisplayConstants';
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
import FormField from '@app/features/auth/flow/AuthFormField';
import styles from '@app/features/auth/flow/MfaScreen.module.css';
import {useAuthCardPresentation} from '@app/features/auth/flow/useAuthCardPresentation';
@@ -53,7 +54,16 @@ interface MfaScreenProps {
const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) => {
const {i18n} = useLingui();
const {form, isLoading, fieldErrors, handleWebAuthn, isWebAuthnLoading, supports} = useMfaController({
const {
form,
isLoading,
fieldErrors,
handleWebAuthn,
handlePasskeyBridge,
passkeyBridgeSuggested,
isWebAuthnLoading,
supports,
} = useMfaController({
ticket: challenge.ticket,
methods: {totp: challenge.totp, webauthn: challenge.webauthn, backupCodes: challenge.backupCodes},
inviteCode,
@@ -128,6 +138,12 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
>
{i18n._(isCodePrimary ? TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR : SECURITY_KEY_OR_PASSKEY_DESCRIPTOR)}
</Button>
<PasswordManagerPasskeyAction
suggested={passkeyBridgeSuggested}
disabled={isWebAuthnLoading}
onClick={handlePasskeyBridge}
data-flx="auth.flow.mfa-screen.password-manager-passkey-action"
/>
</div>
)}
<div className={styles.footerButtons} data-flx="auth.flow.mfa-screen.footer-buttons">
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
import {Button} from '@app/features/ui/button/Button';
import {Trans} from '@lingui/react/macro';
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
@@ -50,6 +51,8 @@ interface Props {
onPasskeyLogin: () => void;
showBrowserOption: boolean;
onBrowserLogin?: () => void;
onPasswordManagerLogin?: () => void;
passwordManagerSuggested?: boolean;
primaryLabel?: React.ReactNode;
browserLabel?: React.ReactNode;
}
@@ -60,6 +63,8 @@ export default function AuthLoginPasskeyActions({
onPasskeyLogin,
showBrowserOption,
onBrowserLogin,
onPasswordManagerLogin,
passwordManagerSuggested = false,
primaryLabel = <Trans>Sign in with a passkey</Trans>,
browserLabel = <Trans>Sign in via browser</Trans>,
}: Props) {
@@ -91,6 +96,14 @@ export default function AuthLoginPasskeyActions({
{browserLabel}
</Button>
) : null}
{onPasswordManagerLogin ? (
<PasswordManagerPasskeyAction
suggested={passwordManagerSuggested}
disabled={disabled}
onClick={onPasswordManagerLogin}
data-flx="auth.flow.auth-login-core.auth-login-passkey-actions.password-manager-passkey-action"
/>
) : null}
</div>
);
}
@@ -16,12 +16,18 @@ import {
loginWithPassword,
type MfaChallenge,
} from '@app/features/auth/state/AuthFlow';
import {
describePasskeyBridgeFailure,
runPasskeyViaBridge,
shouldSuggestPasskeyBridge,
} from '@app/features/auth/utils/PasskeyBridge';
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {useLingui} from '@lingui/react/macro';
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
import {useCallback, useMemo, useRef, useState} from 'react';
const logger = Logger.create('useLoginFlow');
@@ -91,6 +97,7 @@ export function useLoginFormController({
}: LoginFormControllerOptions) {
const {i18n} = useLingui();
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
const {form, isLoading, fieldErrors, error} = useAuthForm({
initialValues: {email: '', password: ''},
onSubmit: async (values) => {
@@ -112,12 +119,8 @@ export function useLoginFormController({
}
});
}, [onLoginSuccess, redirectPath]);
const handlePasskeyLogin = useCallback(async () => {
setIsPasskeyLoading(true);
try {
await WebAuthnUtils.assertWebAuthnSupported();
const options = await getWebAuthnAuthenticationOptions();
const credential = await WebAuthnUtils.performAuthentication(options);
const completePasskeyLogin = useCallback(
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
const response = await authenticateWithWebAuthn({
response: credential,
challenge: options.challenge,
@@ -127,11 +130,25 @@ export function useLoginFormController({
if (redirectPath) {
RouterUtils.replaceWith(redirectPath);
}
},
[inviteCode, onLoginSuccess, redirectPath],
);
const handlePasskeyLogin = useCallback(async () => {
setIsPasskeyLoading(true);
try {
await WebAuthnUtils.assertWebAuthnSupported();
const options = await getWebAuthnAuthenticationOptions();
const credential = await WebAuthnUtils.performAuthentication(options);
await completePasskeyLogin(options, credential);
} catch (err) {
if (err instanceof CaptchaCancelledError) {
return;
}
logger.error('Passkey login failed', err);
if (shouldSuggestPasskeyBridge(err)) {
setPasskeyBridgeSuggested(true);
return;
}
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
return;
@@ -144,7 +161,27 @@ export function useLoginFormController({
} finally {
setIsPasskeyLoading(false);
}
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff, i18n]);
}, [completePasskeyLogin, handleDesktopPasskeyHandoff, i18n]);
const handlePasskeyBridgeLogin = useCallback(() => {
const options = getWebAuthnAuthenticationOptions();
const credential = runPasskeyViaBridge('authenticate', options);
setIsPasskeyLoading(true);
Promise.all([options, credential])
.then(([resolvedOptions, resolvedCredential]) => completePasskeyLogin(resolvedOptions, resolvedCredential))
.catch((err: unknown) => {
if (err instanceof CaptchaCancelledError) {
return;
}
logger.error('Passkey login in the pop-up window failed', err);
const descriptor = describePasskeyBridgeFailure(err);
if (descriptor) {
ToastCommands.error(i18n._(descriptor));
}
})
.finally(() => {
setIsPasskeyLoading(false);
});
}, [completePasskeyLogin, i18n]);
return {
form,
isLoading,
@@ -152,6 +189,8 @@ export function useLoginFormController({
error,
handlePasskeyLogin,
handlePasskeyBrowserLogin: handleDesktopPasskeyHandoff,
handlePasskeyBridgeLogin,
passkeyBridgeSuggested,
isPasskeyLoading,
};
}
@@ -170,6 +209,7 @@ interface MfaControllerOptions {
export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}: MfaControllerOptions) {
const {i18n} = useLingui();
const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false);
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
const {form, isLoading, fieldErrors} = useAuthForm({
initialValues: {code: ''},
onSubmit: async (values) => {
@@ -187,11 +227,8 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
firstFieldName: 'code',
redirectPath: undefined,
});
const handleWebAuthn = useCallback(async () => {
setIsWebAuthnLoading(true);
try {
const options = await getWebAuthnMfaOptions(ticket);
const credential = await WebAuthnUtils.performAuthentication(options);
const completeWebAuthnMfa = useCallback(
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
const response = await authenticateMfaWithWebAuthn({
response: credential,
challenge: options.challenge,
@@ -199,15 +236,45 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
inviteCode,
});
await onLoginSuccess?.(response);
},
[inviteCode, onLoginSuccess, ticket],
);
const handleWebAuthn = useCallback(async () => {
setIsWebAuthnLoading(true);
try {
const options = await getWebAuthnMfaOptions(ticket);
const credential = await WebAuthnUtils.performAuthentication(options);
await completeWebAuthnMfa(options, credential);
} catch (error) {
logger.error('WebAuthn MFA failed', error);
if (shouldSuggestPasskeyBridge(error)) {
setPasskeyBridgeSuggested(true);
return;
}
if (error instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
}
} finally {
setIsWebAuthnLoading(false);
}
}, [inviteCode, onLoginSuccess, ticket, i18n]);
}, [completeWebAuthnMfa, ticket, i18n]);
const handlePasskeyBridge = useCallback(() => {
const options = getWebAuthnMfaOptions(ticket);
const credential = runPasskeyViaBridge('authenticate', options);
setIsWebAuthnLoading(true);
Promise.all([options, credential])
.then(([resolvedOptions, resolvedCredential]) => completeWebAuthnMfa(resolvedOptions, resolvedCredential))
.catch((error: unknown) => {
logger.error('WebAuthn MFA in the pop-up window failed', error);
const descriptor = describePasskeyBridgeFailure(error);
if (descriptor) {
ToastCommands.error(i18n._(descriptor));
}
})
.finally(() => {
setIsWebAuthnLoading(false);
});
}, [completeWebAuthnMfa, ticket, i18n]);
const supports = useMemo(
() => ({totp: methods.totp, webauthn: methods.webauthn, backupCodes: methods.backupCodes}),
[methods.totp, methods.webauthn, methods.backupCodes],
@@ -217,6 +284,8 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
isLoading,
fieldErrors,
handleWebAuthn,
handlePasskeyBridge,
passkeyBridgeSuggested,
isWebAuthnLoading,
supports,
};
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/ErrorFallback.module.css';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {
type PasskeyBridgeSession,
type PasskeyBridgeViewState,
startPasskeyBridgeSession,
} from '@app/features/auth/passkey_bridge/PasskeyBridgeSession';
import {CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
message: 'Use your passkey',
comment: 'Heading of the small pop-up window that runs a passkey prompt for the new web address.',
});
const CONTINUE_TO_SIGN_IN_DESCRIPTOR = msg({
message: 'Continue with your passkey to sign in to {host}',
comment:
'Body of the passkey pop-up window when signing in or confirming identity. host is a web address such as fluxer.com.',
});
const CONTINUE_TO_CREATE_DESCRIPTOR = msg({
message: 'Continue to create a passkey for {host}',
comment: 'Body of the passkey pop-up window when adding a new passkey. host is a web address such as fluxer.com.',
});
const CLOSE_WINDOW_DESCRIPTOR = msg({
message: 'Close window',
comment: 'Button in the passkey pop-up window that closes it after an error.',
});
const BRIDGE_UNAVAILABLE_DESCRIPTOR = msg({
message: 'Open this window from {productName} to use your passkey.',
comment: 'Error in the passkey pop-up window when someone opens its address directly. productName is the app name.',
});
const BRIDGE_REJECTED_DESCRIPTOR = msg({
message: 'This passkey request could not be verified. Close this window and try again.',
comment: 'Error in the passkey pop-up window when the request it received is not valid.',
});
const BRIDGE_TIMED_OUT_DESCRIPTOR = msg({
message: 'This passkey request timed out. Close this window and try again.',
comment: 'Error in the passkey pop-up window after five minutes without finishing.',
});
interface PasskeyBridgeScreenProps {
openerOrigin: string;
}
export const PasskeyBridgeScreen: React.FC<PasskeyBridgeScreenProps> = ({openerOrigin}) => {
const {i18n} = useLingui();
const [state, setState] = useState<PasskeyBridgeViewState>({status: 'waiting'});
const sessionRef = useRef<PasskeyBridgeSession | null>(null);
useEffect(() => {
const session = startPasskeyBridgeSession(openerOrigin, setState);
sessionRef.current = session;
return () => {
session.dispose();
sessionRef.current = null;
};
}, [openerOrigin]);
const handleContinue = useCallback(() => {
sessionRef.current?.continueCeremony();
}, []);
const handleClose = useCallback(() => {
window.close();
}, []);
const host = new URL(openerOrigin).host;
const failure =
state.status === 'unavailable'
? i18n._(BRIDGE_UNAVAILABLE_DESCRIPTOR, {productName: PRODUCT_NAME})
: state.status === 'rejected'
? i18n._(BRIDGE_REJECTED_DESCRIPTOR)
: state.status === 'timed_out'
? i18n._(BRIDGE_TIMED_OUT_DESCRIPTOR)
: null;
const kind = state.status === 'ready' || state.status === 'running' ? state.kind : 'authenticate';
return (
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-screen.container">
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-screen.icon" />
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-screen.content">
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-screen.title">
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
</h1>
<p
className={styles.errorFallbackDescription}
role={failure === null ? undefined : 'alert'}
data-flx="auth.passkey-bridge-screen.description"
>
{failure ??
i18n._(kind === 'register' ? CONTINUE_TO_CREATE_DESCRIPTOR : CONTINUE_TO_SIGN_IN_DESCRIPTOR, {host})}
</p>
</div>
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-screen.actions">
{failure === null ? (
<Button
onClick={handleContinue}
disabled={state.status !== 'ready'}
submitting={state.status === 'waiting' || state.status === 'running'}
autoFocus
data-flx="auth.passkey-bridge-screen.button.continue"
>
{i18n._(CONTINUE_DESCRIPTOR)}
</Button>
) : state.status !== 'unavailable' ? (
<Button variant="secondary" onClick={handleClose} data-flx="auth.passkey-bridge-screen.button.close">
{i18n._(CLOSE_WINDOW_DESCRIPTOR)}
</Button>
) : null}
</div>
</main>
);
};
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
PASSKEY_BRIDGE_READY_TYPE,
PASSKEY_BRIDGE_RESULT_TYPE,
PASSKEY_BRIDGE_TIMEOUT_MS,
PASSKEY_BRIDGE_VERSION,
type PasskeyBridgeKind,
type PasskeyBridgeRequest,
type PasskeyBridgeResponseMap,
type PasskeyBridgeResult,
parsePasskeyBridgeRequest,
readPasskeyBridgeRequestId,
toPasskeyBridgeErrorPayload,
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
import {startAuthentication, startRegistration} from '@simplewebauthn/browser';
export type PasskeyBridgeViewState =
| {status: 'unavailable'}
| {status: 'waiting'}
| {status: 'ready'; kind: PasskeyBridgeKind}
| {status: 'running'; kind: PasskeyBridgeKind}
| {status: 'rejected'}
| {status: 'timed_out'};
export interface PasskeyBridgeSession {
continueCeremony(): void;
dispose(): void;
}
function runCeremony(request: PasskeyBridgeRequest): Promise<PasskeyBridgeResponseMap[PasskeyBridgeKind]> {
if (request.kind === 'authenticate') {
return startAuthentication({optionsJSON: request.options});
}
return startRegistration({optionsJSON: request.options});
}
function failureResult(id: string, error: unknown): PasskeyBridgeResult {
return {
type: PASSKEY_BRIDGE_RESULT_TYPE,
v: PASSKEY_BRIDGE_VERSION,
id,
ok: false,
error: toPasskeyBridgeErrorPayload(error),
};
}
export function startPasskeyBridgeSession(
openerOrigin: string,
onStateChange: (state: PasskeyBridgeViewState) => void,
): PasskeyBridgeSession {
const opener = window.opener as Window | null;
if (opener === null || opener === window) {
onStateChange({status: 'unavailable'});
return {continueCeremony() {}, dispose() {}};
}
let request: PasskeyBridgeRequest | null = null;
let running = false;
let finished = false;
const post = (message: unknown) => {
opener.postMessage(message, openerOrigin);
};
const finish = (result: PasskeyBridgeResult) => {
finished = true;
window.clearTimeout(timeout);
post(result);
window.close();
};
const handleMessage = (event: MessageEvent) => {
if (event.origin !== openerOrigin || event.source !== opener) {
return;
}
window.removeEventListener('message', handleMessage);
const parsed = parsePasskeyBridgeRequest(event.data);
if (parsed === null) {
finished = true;
window.clearTimeout(timeout);
const id = readPasskeyBridgeRequestId(event.data);
if (id !== null) {
post(failureResult(id, new DOMException('The passkey request was rejected', 'SecurityError')));
}
onStateChange({status: 'rejected'});
return;
}
request = parsed;
onStateChange({status: 'ready', kind: parsed.kind});
};
const timeout = window.setTimeout(() => {
if (running || finished) {
return;
}
finished = true;
window.removeEventListener('message', handleMessage);
if (request !== null) {
post(failureResult(request.id, new DOMException('The passkey window timed out', 'TimeoutError')));
request = null;
}
onStateChange({status: 'timed_out'});
}, PASSKEY_BRIDGE_TIMEOUT_MS);
window.addEventListener('message', handleMessage);
onStateChange({status: 'waiting'});
post({type: PASSKEY_BRIDGE_READY_TYPE, v: PASSKEY_BRIDGE_VERSION});
return {
continueCeremony() {
const current = request;
if (current === null || running || finished) {
return;
}
running = true;
onStateChange({status: 'running', kind: current.kind});
runCeremony(current).then(
(response) => {
finish({type: PASSKEY_BRIDGE_RESULT_TYPE, v: PASSKEY_BRIDGE_VERSION, id: current.id, ok: true, response});
},
(error: unknown) => {
finish(failureResult(current.id, error));
},
);
},
dispose() {
window.removeEventListener('message', handleMessage);
window.clearTimeout(timeout);
},
};
}
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
isPasskeyBridgeReady,
PASSKEY_BRIDGE_PATH,
PASSKEY_BRIDGE_REQUEST_TYPE,
PASSKEY_BRIDGE_TIMEOUT_MS,
PASSKEY_BRIDGE_VERSION,
type PasskeyBridgeKind,
type PasskeyBridgeOptionsMap,
type PasskeyBridgeRequest,
type PasskeyBridgeResponseMap,
parsePasskeyBridgeResult,
resolvePasskeyBridgeLegacyOrigin,
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
import {PasskeyDomainUnsupportedError} from '@app/features/auth/utils/WebAuthnUtils';
import {Platform} from '@app/features/platform/types/Platform';
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const PASSKEY_BRIDGE_POPUP_FEATURES = 'popup,width=460,height=620';
const PASSKEY_BRIDGE_CLOSED_POLL_MS = 500;
const SUGGEST_BRIDGE_ERROR_NAMES: ReadonlySet<string> = new Set(['NotAllowedError', 'SecurityError']);
const PASSKEY_BRIDGE_POPUP_BLOCKED_DESCRIPTOR = msg({
message: 'Your browser blocked the passkey pop-up window. Allow pop-ups for this site and try again.',
comment: 'Error shown when the browser blocks the pop-up window used for password manager passkeys.',
});
const PASSKEY_BRIDGE_FAILED_DESCRIPTOR = msg({
message: "Couldn't use your passkey in the pop-up window. Try again.",
comment: 'Error shown when the pop-up window used for password manager passkeys does not finish.',
});
export class PasskeyBridgeError extends Error {
constructor(name: string, message: string) {
super(message);
this.name = name;
}
}
export function isPasskeyBridgeAvailable(): boolean {
return !Platform.isElectron && resolvePasskeyBridgeLegacyOrigin(window.location.origin) !== null;
}
export function shouldSuggestPasskeyBridge(error: unknown): boolean {
if (!isPasskeyBridgeAvailable() || error instanceof PasskeyBridgeError) {
return false;
}
return (
error instanceof PasskeyDomainUnsupportedError ||
(error instanceof Error && SUGGEST_BRIDGE_ERROR_NAMES.has(error.name))
);
}
function isPasskeyBridgeDismissal(error: unknown): boolean {
return error instanceof PasskeyBridgeError && (error.name === 'AbortError' || error.name === 'NotAllowedError');
}
export function describePasskeyBridgeFailure(error: unknown): MessageDescriptor | null {
if (isPasskeyBridgeDismissal(error)) {
return null;
}
if (error instanceof PasskeyBridgeError && error.name === 'PopupBlockedError') {
return PASSKEY_BRIDGE_POPUP_BLOCKED_DESCRIPTOR;
}
return PASSKEY_BRIDGE_FAILED_DESCRIPTOR;
}
export function runPasskeyViaBridge<K extends PasskeyBridgeKind>(
kind: K,
options: Promise<PasskeyBridgeOptionsMap[K]>,
): Promise<PasskeyBridgeResponseMap[K]> {
const legacyOrigin = isPasskeyBridgeAvailable() ? resolvePasskeyBridgeLegacyOrigin(window.location.origin) : null;
const popup =
legacyOrigin === null
? null
: window.open(`${legacyOrigin}${PASSKEY_BRIDGE_PATH}`, '_blank', PASSKEY_BRIDGE_POPUP_FEATURES);
if (legacyOrigin === null || popup === null) {
options.catch(() => {});
const name = legacyOrigin === null ? 'NotSupportedError' : 'PopupBlockedError';
return Promise.reject(new PasskeyBridgeError(name, 'The passkey window could not be opened'));
}
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
let ready = false;
let settled = false;
let requestSent = false;
let closedSeen = false;
let resolvedOptions: PasskeyBridgeOptionsMap[K] | null = null;
const cleanup = () => {
window.removeEventListener('message', handleMessage);
window.clearInterval(closedPoll);
window.clearTimeout(timeout);
};
const fail = (error: unknown) => {
if (settled) {
return;
}
settled = true;
cleanup();
if (!popup.closed) {
popup.close();
}
reject(error);
};
const sendRequest = () => {
if (!ready || resolvedOptions === null || requestSent || settled) {
return;
}
requestSent = true;
const request = {
type: PASSKEY_BRIDGE_REQUEST_TYPE,
v: PASSKEY_BRIDGE_VERSION,
id,
kind,
options: resolvedOptions,
} as PasskeyBridgeRequest;
popup.postMessage(request, legacyOrigin);
popup.focus();
};
const handleMessage = (event: MessageEvent) => {
if (settled || event.origin !== legacyOrigin || event.source !== popup) {
return;
}
if (isPasskeyBridgeReady(event.data)) {
ready = true;
sendRequest();
return;
}
const result = parsePasskeyBridgeResult(event.data, id);
if (result === null) {
return;
}
if (!result.ok) {
fail(new PasskeyBridgeError(result.error.name, result.error.message));
return;
}
settled = true;
cleanup();
resolve(result.response as PasskeyBridgeResponseMap[K]);
};
const closedPoll = window.setInterval(() => {
if (!popup.closed) {
return;
}
if (closedSeen) {
fail(new PasskeyBridgeError('AbortError', 'The passkey window was closed'));
}
closedSeen = true;
}, PASSKEY_BRIDGE_CLOSED_POLL_MS);
const timeout = window.setTimeout(() => {
fail(new PasskeyBridgeError('TimeoutError', 'The passkey window timed out'));
}, PASSKEY_BRIDGE_TIMEOUT_MS);
window.addEventListener('message', handleMessage);
options.then(
(value) => {
resolvedOptions = value;
sendRequest();
},
(error: unknown) => fail(error),
);
});
}
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
PASSKEY_BRIDGE_REQUEST_TYPE,
PASSKEY_BRIDGE_RESULT_TYPE,
parsePasskeyBridgeRequest,
parsePasskeyBridgeResult,
resolvePasskeyBridgeLegacyOrigin,
resolvePasskeyBridgeOpenerOrigin,
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
import {describe, expect, it} from 'vitest';
function authenticateRequest(options: Record<string, unknown>): Record<string, unknown> {
return {type: PASSKEY_BRIDGE_REQUEST_TYPE, v: 1, id: 'req', kind: 'authenticate', options};
}
function registerRequest(options: Record<string, unknown>): Record<string, unknown> {
return {type: PASSKEY_BRIDGE_REQUEST_TYPE, v: 1, id: 'req', kind: 'register', options};
}
describe('resolvePasskeyBridgeOpenerOrigin', () => {
it('pairs each official legacy origin with its target only', () => {
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/passkey-bridge')).toBe('https://fluxer.com');
expect(resolvePasskeyBridgeOpenerOrigin('https://web.canary.fluxer.app', '/passkey-bridge')).toBe(
'https://canary.fluxer.com',
);
});
it('ignores other origins and paths', () => {
expect(resolvePasskeyBridgeOpenerOrigin('https://fluxer.com', '/passkey-bridge')).toBeNull();
expect(resolvePasskeyBridgeOpenerOrigin('https://fluxer.app', '/passkey-bridge')).toBeNull();
expect(resolvePasskeyBridgeOpenerOrigin('https://chat.example.com', '/passkey-bridge')).toBeNull();
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/passkey-bridge/')).toBeNull();
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/login')).toBeNull();
});
});
describe('resolvePasskeyBridgeLegacyOrigin', () => {
it('resolves only from official target origins', () => {
expect(resolvePasskeyBridgeLegacyOrigin('https://fluxer.com')).toBe('https://web.fluxer.app');
expect(resolvePasskeyBridgeLegacyOrigin('https://canary.fluxer.com')).toBe('https://web.canary.fluxer.app');
expect(resolvePasskeyBridgeLegacyOrigin('https://web.fluxer.app')).toBeNull();
expect(resolvePasskeyBridgeLegacyOrigin('https://chat.example.com')).toBeNull();
});
});
describe('parsePasskeyBridgeRequest', () => {
it('accepts requests for the fluxer.app relying party', () => {
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rpId: 'fluxer.app'}))).not.toBeNull();
expect(
parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rp: {id: 'fluxer.app', name: 'Fluxer'}, user: {}})),
).not.toBeNull();
});
it('rejects any other relying party', () => {
for (const rpId of ['evil.example', 'web.fluxer.app', 'fluxer.com', 'app', undefined]) {
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rpId}))).toBeNull();
expect(parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rp: {id: rpId}, user: {}}))).toBeNull();
}
expect(parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rpId: 'fluxer.app', user: {}}))).toBeNull();
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rp: {id: 'fluxer.app'}}))).toBeNull();
});
it('rejects malformed envelopes', () => {
const options = {challenge: 'c', rpId: 'fluxer.app'};
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), v: 2})).toBeNull();
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), type: 'other'})).toBeNull();
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), id: ''})).toBeNull();
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), kind: 'sign'})).toBeNull();
expect(parsePasskeyBridgeRequest(authenticateRequest({rpId: 'fluxer.app'}))).toBeNull();
expect(parsePasskeyBridgeRequest('request')).toBeNull();
});
});
describe('parsePasskeyBridgeResult', () => {
const response = {id: 'cred', rawId: 'cred', response: {}, type: 'public-key', clientExtensionResults: {}};
it('accepts only the matching request id', () => {
const result = {type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: true, response};
expect(parsePasskeyBridgeResult(result, 'req')).not.toBeNull();
expect(parsePasskeyBridgeResult(result, 'other')).toBeNull();
});
it('normalises failures and rejects malformed successes', () => {
expect(
parsePasskeyBridgeResult({type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: false, error: {}}, 'req'),
).toMatchObject({ok: false, error: {name: 'UnknownError', message: ''}});
expect(
parsePasskeyBridgeResult({type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: true, response: {}}, 'req'),
).toBeNull();
});
});
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
DOMAIN_MIGRATION_SOURCE_TO_TARGET,
DOMAIN_MIGRATION_TARGET_TO_SOURCE,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser';
export const PASSKEY_BRIDGE_PATH = '/passkey-bridge';
export const PASSKEY_BRIDGE_VERSION = 1;
export const PASSKEY_BRIDGE_RP_ID = 'fluxer.app';
export const PASSKEY_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
export const PASSKEY_BRIDGE_READY_TYPE = 'fluxer:passkey-bridge:ready';
export const PASSKEY_BRIDGE_REQUEST_TYPE = 'fluxer:passkey-bridge:request';
export const PASSKEY_BRIDGE_RESULT_TYPE = 'fluxer:passkey-bridge:result';
export type PasskeyBridgeKind = 'authenticate' | 'register';
export interface PasskeyBridgeOptionsMap {
authenticate: PublicKeyCredentialRequestOptionsJSON;
register: PublicKeyCredentialCreationOptionsJSON;
}
export interface PasskeyBridgeResponseMap {
authenticate: AuthenticationResponseJSON;
register: RegistrationResponseJSON;
}
export type PasskeyBridgeRequest = {
[K in PasskeyBridgeKind]: {
type: typeof PASSKEY_BRIDGE_REQUEST_TYPE;
v: typeof PASSKEY_BRIDGE_VERSION;
id: string;
kind: K;
options: PasskeyBridgeOptionsMap[K];
};
}[PasskeyBridgeKind];
export interface PasskeyBridgeErrorPayload {
name: string;
message: string;
}
export type PasskeyBridgeResult =
| {
type: typeof PASSKEY_BRIDGE_RESULT_TYPE;
v: typeof PASSKEY_BRIDGE_VERSION;
id: string;
ok: true;
response: PasskeyBridgeResponseMap[PasskeyBridgeKind];
}
| {
type: typeof PASSKEY_BRIDGE_RESULT_TYPE;
v: typeof PASSKEY_BRIDGE_VERSION;
id: string;
ok: false;
error: PasskeyBridgeErrorPayload;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
export function resolvePasskeyBridgeOpenerOrigin(origin: string, pathname: string): string | null {
if (pathname !== PASSKEY_BRIDGE_PATH) {
return null;
}
return DOMAIN_MIGRATION_SOURCE_TO_TARGET[origin] ?? null;
}
export function resolvePasskeyBridgeLegacyOrigin(origin: string): string | null {
return DOMAIN_MIGRATION_TARGET_TO_SOURCE[origin] ?? null;
}
export function isPasskeyBridgeReady(data: unknown): boolean {
return isRecord(data) && data.type === PASSKEY_BRIDGE_READY_TYPE && data.v === PASSKEY_BRIDGE_VERSION;
}
export function readPasskeyBridgeRequestId(data: unknown): string | null {
return isRecord(data) && isNonEmptyString(data.id) ? data.id : null;
}
function readRequestRpId(kind: unknown, options: Record<string, unknown>): unknown {
if (kind === 'authenticate') {
return options.rpId;
}
if (kind === 'register') {
return isRecord(options.rp) ? options.rp.id : undefined;
}
return undefined;
}
export function parsePasskeyBridgeRequest(data: unknown): PasskeyBridgeRequest | null {
if (
!isRecord(data) ||
data.type !== PASSKEY_BRIDGE_REQUEST_TYPE ||
data.v !== PASSKEY_BRIDGE_VERSION ||
!isNonEmptyString(data.id) ||
!isRecord(data.options) ||
!isNonEmptyString(data.options.challenge)
) {
return null;
}
if (readRequestRpId(data.kind, data.options) !== PASSKEY_BRIDGE_RP_ID) {
return null;
}
if (data.kind === 'register' && !isRecord(data.options.user)) {
return null;
}
return data as unknown as PasskeyBridgeRequest;
}
export function parsePasskeyBridgeResult(data: unknown, id: string): PasskeyBridgeResult | null {
if (
!isRecord(data) ||
data.type !== PASSKEY_BRIDGE_RESULT_TYPE ||
data.v !== PASSKEY_BRIDGE_VERSION ||
data.id !== id
) {
return null;
}
if (data.ok === true) {
return isRecord(data.response) && isNonEmptyString(data.response.id) && isRecord(data.response.response)
? (data as unknown as PasskeyBridgeResult)
: null;
}
if (data.ok === false && isRecord(data.error)) {
return {
type: PASSKEY_BRIDGE_RESULT_TYPE,
v: PASSKEY_BRIDGE_VERSION,
id,
ok: false,
error: {
name: typeof data.error.name === 'string' ? data.error.name : 'UnknownError',
message: typeof data.error.message === 'string' ? data.error.message : '',
},
};
}
return null;
}
export function toPasskeyBridgeErrorPayload(error: unknown): PasskeyBridgeErrorPayload {
if (error instanceof Error) {
return {name: error.name, message: error.message};
}
return {name: 'UnknownError', message: String(error)};
}