feat(auth): migrate passkeys to fluxer.com (#2964)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-25 22:33:50 +02:00
1 parent 08e65d41c0
commit 841fb7af41
197 files changed
+17147 -12336

No files matched your search

@@ -1,55 +0,0 @@
// 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,12 +1,6 @@
// 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,
} from '@app/features/auth/utils/WebAuthnUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
@@ -16,8 +10,6 @@ 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({
@@ -41,58 +33,16 @@ interface FormInputs {
name: string;
}
interface PasskeyNameModalProps {
onSubmit: (name: string) => void | Promise<void>;
onSubmitWithPasswordManager?: (name: string) => Promise<void>;
}
export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager}: PasskeyNameModalProps) => {
export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string) => void | Promise<void>}) => {
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) {
form.setError('name', {type: 'server', message: i18n._(PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR)});
} else {
form.setError('name', {type: 'server', message: FormUtils.extractErrorMessage(i18n, error)});
}
@@ -123,14 +73,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
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">
@@ -140,7 +82,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
<Button
type="submit"
submitting={form.formState.isSubmitting}
disabled={passkeyBridgeSubmitting}
data-flx="auth.passkey-name-modal.button.submit"
>
<Trans>Save</Trans>
@@ -51,3 +51,9 @@
white-space: nowrap;
border: 0;
}
.passkeyHint {
margin: 0;
font-size: 0.8125rem;
color: var(--text-primary-muted);
}
@@ -3,25 +3,37 @@
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';
isPasskeyCeremonyDismissed,
PasskeyBridgeSudoLink,
runPasskeyBridgeNativeSudo,
} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
import PasskeyMigration from '@app/features/auth/passkey_migration/PasskeyMigration';
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
import AccountManager from '@app/features/auth/state/AccountManager';
import Sudo from '@app/features/auth/state/AuthSudo';
import SudoPrompt, {SUDO_MODAL_KEY, SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
import {PASSWORD_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {
COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR,
PASSWORD_DESCRIPTOR,
VERIFY_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {http} from '@app/features/platform/transport/RestTransport';
import {Platform} from '@app/features/platform/types/Platform';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
import buttonStyles from '@app/features/ui/button/Button.module.css';
import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Spinner} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
import * as FormUtils from '@app/lib/forms';
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useRef, useState} from 'react';
@@ -33,10 +45,6 @@ const PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR = msg({
comment:
'Sudo (re-auth) modal body shown on unsigned macOS desktop bundles where passkeys cannot work. Direct the user to install the signed client.',
});
const COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: "Couldn't verify with passkey. Try again.",
comment: 'Sudo (re-auth) modal toast error shown when passkey verification fails. Keep plain.',
});
const ENTER_YOUR_PASSWORD_DESCRIPTOR = msg({
message: 'Enter your password.',
comment: 'Body text in the authentication sudo verification modal. Keep the tone plain and specific.',
@@ -72,6 +80,11 @@ const BACKUP_CODE_DESCRIPTOR = msg({
'Label and placeholder for the code field in the authentication sudo verification modal when the only code the account can use is a backup code.',
});
const VERIFICATION_FAILED_DESCRIPTOR = msg({message: 'Verification failed'});
const FINISH_IN_THE_NEW_TAB_DESCRIPTOR = msg({
message: 'Finish in the new tab. If you closed it, press Continue with passkey again.',
comment:
'Sudo (re-auth) modal hint shown after the passkey button opened a new tab to confirm the passkey. "Continue with passkey" is the button label. Keep plain.',
});
const logger = new Logger('SudoVerificationModal');
interface FormInputs {
@@ -83,14 +96,24 @@ const isMacAppIdentifierError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : '';
return message.toLowerCase().includes('application identifier');
};
const holdsPasskeyFor = (matches: (rpId: string) => boolean): boolean =>
WebAuthnCredentials.credentials.some((credential) => matches(credential.rp_id));
const holdsMigratedPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId === PASSKEY_MIGRATION_RP_ID);
const holdsLegacyPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId !== PASSKEY_MIGRATION_RP_ID);
const SudoVerificationModal: React.FC = observer(() => {
const {i18n} = useLingui();
const {availableMethods, isVerifying, verificationFailed, rawError, lastUsedMfaMethod} = SudoPrompt;
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 [legacyLinkUrl, setLegacyLinkUrl] = useState<string | null>(null);
const [legacyLinkActive, setLegacyLinkActive] = useState(false);
const [legacyLinkFollowed, setLegacyLinkFollowed] = useState(false);
const legacyLinkRef = useRef<PasskeyBridgeSudoLink | null>(null);
const preferLegacyRef = useRef(false);
const openRef = useRef(true);
const userIdAtOpenRef = useRef(AccountManager.currentUserId);
const showPasskey = availableMethods.webauthn;
const showTotp = availableMethods.totp;
const backupCodeOnly = !showTotp && availableMethods.backupCodes;
@@ -113,12 +136,59 @@ const SudoVerificationModal: React.FC = observer(() => {
}
setWebAuthnInFlight(false);
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showCode]);
const finishLegacySudo = (sudoToken: string) => {
if (!openRef.current || AccountManager.currentUserId !== userIdAtOpenRef.current) return;
Sudo.setToken(sudoToken);
PasskeyMigration.checkAfterSudo(SUDO_MODAL_KEY);
SudoPrompt.submit({});
};
const startLegacyLink = () => {
if (legacyLinkRef.current === null) {
legacyLinkRef.current = new PasskeyBridgeSudoLink({
onLink: (url) => {
setLegacyLinkUrl(url);
if (url === null) {
setLegacyLinkFollowed(false);
}
},
onCompleted: finishLegacySudo,
onError: (error) => {
logger.error('WebAuthn verification in a new tab failed', error);
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
},
});
}
setLegacyLinkActive(true);
void legacyLinkRef.current.start();
};
useEffect(() => {
if (showPasskey && isPasskeyMigrationOrigin() && !Platform.isElectron && !holdsMigratedPasskey()) {
startLegacyLink();
}
return () => {
openRef.current = false;
legacyLinkRef.current?.dispose();
};
}, []);
const handleWebAuthn = async () => {
if (webAuthnInFlight || isVerifying) return;
setWebAuthnError(null);
form.clearErrors();
setWebAuthnInFlight(true);
const migrationOrigin = isPasskeyMigrationOrigin();
const runsLegacyNatively =
migrationOrigin && Platform.isElectron && (preferLegacyRef.current || !holdsMigratedPasskey());
try {
if (runsLegacyNatively) {
preferLegacyRef.current = false;
const result = await runPasskeyBridgeNativeSudo();
if (result.status === 'completed') {
finishLegacySudo(result.sudo_token);
return;
}
setWebAuthnInFlight(false);
return;
}
await WebAuthnUtils.assertWebAuthnSupported();
const optionsResponse = await http.post<{challenge: string}>(Endpoints.SUDO_WEBAUTHN_OPTIONS);
const credential = await WebAuthnUtils.performAuthentication(optionsResponse.body);
@@ -130,49 +200,22 @@ const SudoVerificationModal: React.FC = observer(() => {
} catch (err) {
logger.error('WebAuthn verification failed', err);
setWebAuthnInFlight(false);
if (migrationOrigin && !runsLegacyNatively && isPasskeyCeremonyDismissed(err) && holdsLegacyPasskey()) {
if (!Platform.isElectron) {
startLegacyLink();
return;
}
preferLegacyRef.current = true;
}
if (isMacAppIdentifierError(err)) {
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;
}
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR));
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_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 (autoTriggeredRef.current || legacyLinkRef.current !== null) return;
if (!showPasskey || showPassword || showCode) return;
if (lastUsedMfaMethod && lastUsedMfaMethod !== 'webauthn') return;
autoTriggeredRef.current = true;
@@ -263,7 +306,39 @@ const SudoVerificationModal: React.FC = observer(() => {
{showPasskey && (
<>
{webAuthnInFlight ? (
{legacyLinkActive && legacyLinkUrl !== null ? (
<FocusRing offset={-2} data-flx="auth.sudo-verification-modal.focus-ring.legacy-link">
<a
href={legacyLinkUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
setWebAuthnError(null);
setLegacyLinkFollowed(true);
}}
className={clsx(
buttonStyles.button,
buttonStyles[showCode || showPassword ? 'secondary' : 'primary'],
buttonStyles.fitContainer,
)}
data-flx="auth.sudo-verification-modal.link.web-authn"
>
<Trans>Continue with passkey</Trans>
</a>
</FocusRing>
) : legacyLinkActive ? (
<Button
type="button"
onClick={startLegacyLink}
submitting={webAuthnError === null}
disabled={isVerifying}
fitContainer
variant={showCode || showPassword ? 'secondary' : 'primary'}
data-flx="auth.sudo-verification-modal.button.web-authn-starting"
>
<Trans>Continue with passkey</Trans>
</Button>
) : webAuthnInFlight ? (
<div
className={styles.passkeyVerifying}
role="status"
@@ -287,13 +362,10 @@ 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"
/>
{legacyLinkActive && legacyLinkFollowed && !webAuthnError && (
<p className={styles.passkeyHint} data-flx="auth.sudo-verification-modal.passkey-hint">
{i18n._(FINISH_IN_THE_NEW_TAB_DESCRIPTOR)}
</p>
)}
{webAuthnError && (
<p className={styles.formError} role="alert" data-flx="auth.sudo-verification-modal.form-error">