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}