mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(app): add passkey popup bridge for password managers (#2957)
This commit is contained in:
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)};
|
||||
}
|
||||
Reference in new issue
Block a user