mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(auth): migrate passkeys to fluxer.com (#2964)
This commit is contained in:
1 parent
08e65d41c0
commit
841fb7af41
197 files changed
+17147
-12336
No files matched your search
@@ -0,0 +1,278 @@
|
||||
// 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 {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import type {DomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
readPasskeyBridgePageLoginReturnPath,
|
||||
readPasskeyBridgeReturn,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import {
|
||||
CANCEL_DESCRIPTOR,
|
||||
CONTINUE_DESCRIPTOR,
|
||||
TRY_AGAIN_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 {PASSKEY_BRIDGE_CHANNEL} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {PasskeyBridgeFinishResponse} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {type PublicKeyCredentialRequestOptionsJSON, startAuthentication} from '@simplewebauthn/browser';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const CEREMONY_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
||||
|
||||
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use your passkey',
|
||||
comment: 'Heading of the page that asks the user to confirm with their passkey.',
|
||||
});
|
||||
const PRESS_CONTINUE_DESCRIPTOR = msg({
|
||||
message: 'Press Continue to use your passkey.',
|
||||
comment: 'Body of the page that asks the user to confirm with their passkey. Continue is the button label.',
|
||||
});
|
||||
const THAT_DIDN_T_WORK_DESCRIPTOR = msg({
|
||||
message: "That didn't work. Try again or cancel.",
|
||||
comment: 'Body of the passkey page after the passkey prompt failed or was dismissed. Keep plain.',
|
||||
});
|
||||
const REQUEST_EXPIRED_DESCRIPTOR = msg({
|
||||
message: 'This request has expired. Go back and try again.',
|
||||
comment: 'Body of the passkey page when the passkey request is no longer valid. Keep plain.',
|
||||
});
|
||||
const BACK_TO_PRODUCT_DESCRIPTOR = msg({
|
||||
message: 'Back to {productName}',
|
||||
comment: 'Button on the expired passkey page that returns to the app. productName is the app name.',
|
||||
});
|
||||
const YOU_CAN_CLOSE_THIS_TAB_DESCRIPTOR = msg({
|
||||
message: 'You can close this tab',
|
||||
comment: 'Heading of the page shown after a passkey confirmation finished in a separate tab.',
|
||||
});
|
||||
const GO_BACK_TO_CONTINUE_DESCRIPTOR = msg({
|
||||
message: 'Go back to {productName} to continue.',
|
||||
comment:
|
||||
'Body of the page shown after a passkey confirmation finished in a separate tab. productName is the app name.',
|
||||
});
|
||||
|
||||
type LegacyBridgeState =
|
||||
| {status: 'loading'}
|
||||
| {status: 'ready'; options: PublicKeyCredentialRequestOptionsJSON}
|
||||
| {status: 'running'}
|
||||
| {status: 'failed'}
|
||||
| {status: 'expired'};
|
||||
|
||||
class PasskeyBridgeExpiredError extends Error {
|
||||
constructor() {
|
||||
super('Passkey bridge ceremony is unknown or expired');
|
||||
this.name = 'PasskeyBridgeExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
async function postBridge<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${window.location.origin}/api/v1${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
...(body === undefined ? {} : {headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
throw new PasskeyBridgeExpiredError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Passkey bridge request failed with status ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function leave(finish: PasskeyBridgeFinishResponse): void {
|
||||
if (finish.return_url === null) {
|
||||
throw new Error('Passkey bridge finished without a return address');
|
||||
}
|
||||
window.location.replace(finish.return_url);
|
||||
}
|
||||
|
||||
const isDismissed = (error: unknown): boolean =>
|
||||
error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError');
|
||||
|
||||
interface PasskeyBridgePageProps {
|
||||
side: DomainMigrationSide;
|
||||
hash: string;
|
||||
opensInOwnTab: boolean;
|
||||
}
|
||||
|
||||
const LegacyPasskeyBridgePage: React.FC<PasskeyBridgePageProps> = ({side, hash, opensInOwnTab}) => {
|
||||
const {i18n} = useLingui();
|
||||
const ceremonyId = CEREMONY_ID_PATTERN.test(hash.slice(1)) ? hash.slice(1) : null;
|
||||
const [state, setState] = useState<LegacyBridgeState>(() =>
|
||||
ceremonyId === null ? {status: 'expired'} : {status: 'loading'},
|
||||
);
|
||||
const fail = useCallback((error: unknown) => {
|
||||
setState(error instanceof PasskeyBridgeExpiredError ? {status: 'expired'} : {status: 'failed'});
|
||||
}, []);
|
||||
const loadOptions = useCallback(() => {
|
||||
if (ceremonyId === null) {
|
||||
return;
|
||||
}
|
||||
setState({status: 'loading'});
|
||||
postBridge<{options: PublicKeyCredentialRequestOptionsJSON}>(Endpoints.AUTH_PASSKEY_BRIDGE_OPTIONS(ceremonyId))
|
||||
.then((response) => {
|
||||
setState({status: 'ready', options: response.options});
|
||||
})
|
||||
.catch(fail);
|
||||
}, [ceremonyId, fail]);
|
||||
useEffect(loadOptions, [loadOptions]);
|
||||
const cancel = useCallback(() => {
|
||||
if (ceremonyId === null) {
|
||||
return;
|
||||
}
|
||||
setState({status: 'running'});
|
||||
postBridge<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_CANCEL(ceremonyId))
|
||||
.then(leave)
|
||||
.catch(() => {
|
||||
setState({status: 'expired'});
|
||||
});
|
||||
}, [ceremonyId]);
|
||||
const handleContinue = useCallback(() => {
|
||||
if (ceremonyId === null || state.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
setState({status: 'running'});
|
||||
startAuthentication({optionsJSON: state.options}).then(
|
||||
(response) =>
|
||||
postBridge<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_COMPLETE(ceremonyId), {response})
|
||||
.then(leave)
|
||||
.catch((error: unknown) => {
|
||||
if (opensInOwnTab || error instanceof PasskeyBridgeExpiredError) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
}),
|
||||
(error: unknown) => {
|
||||
if (!opensInOwnTab && isDismissed(error)) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
setState({status: 'failed'});
|
||||
},
|
||||
);
|
||||
}, [cancel, ceremonyId, fail, opensInOwnTab, state]);
|
||||
const handleBack = useCallback(() => {
|
||||
window.close();
|
||||
window.location.replace(`${side.target}/`);
|
||||
}, [side.target]);
|
||||
const expired = state.status === 'expired';
|
||||
const failed = state.status === 'failed';
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-page.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-page.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-page.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-page.title">
|
||||
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
role={expired || failed ? 'alert' : undefined}
|
||||
data-flx="auth.passkey-bridge-page.description"
|
||||
>
|
||||
{i18n._(
|
||||
expired ? REQUEST_EXPIRED_DESCRIPTOR : failed ? THAT_DIDN_T_WORK_DESCRIPTOR : PRESS_CONTINUE_DESCRIPTOR,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-page.actions">
|
||||
{expired ? (
|
||||
<Button onClick={handleBack} autoFocus data-flx="auth.passkey-bridge-page.button.back">
|
||||
{i18n._(BACK_TO_PRODUCT_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</Button>
|
||||
) : failed ? (
|
||||
<>
|
||||
<Button
|
||||
variant={opensInOwnTab ? 'secondary' : 'primary'}
|
||||
onClick={loadOptions}
|
||||
autoFocus={!opensInOwnTab}
|
||||
data-flx="auth.passkey-bridge-page.button.try-again"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={opensInOwnTab ? 'primary' : 'ghost'}
|
||||
onClick={cancel}
|
||||
autoFocus={opensInOwnTab}
|
||||
data-flx="auth.passkey-bridge-page.button.cancel"
|
||||
>
|
||||
{i18n._(CANCEL_DESCRIPTOR)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
submitting={state.status !== 'ready'}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-bridge-page.button.continue"
|
||||
>
|
||||
{i18n._(CONTINUE_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={cancel}
|
||||
disabled={state.status === 'running'}
|
||||
data-flx="auth.passkey-bridge-page.button.cancel"
|
||||
>
|
||||
{i18n._(CANCEL_DESCRIPTOR)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const TargetPasskeyBridgePage: React.FC<{hash: string}> = ({hash}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [bridgeReturn] = useState(() => readPasskeyBridgeReturn(hash));
|
||||
const [loginReturnPath] = useState(() =>
|
||||
bridgeReturn === null ? null : readPasskeyBridgePageLoginReturnPath(bridgeReturn.ceremonyId),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (loginReturnPath !== null) {
|
||||
window.location.replace(`${window.location.origin}${loginReturnPath}${hash}`);
|
||||
return;
|
||||
}
|
||||
if (bridgeReturn !== null) {
|
||||
const channel = new BroadcastChannel(PASSKEY_BRIDGE_CHANNEL);
|
||||
channel.postMessage({ceremony_id: bridgeReturn.ceremonyId, completion_code: bridgeReturn.completionCode});
|
||||
channel.close();
|
||||
}
|
||||
window.close();
|
||||
}, [bridgeReturn, hash, loginReturnPath]);
|
||||
if (loginReturnPath !== null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-page.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-page.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-page.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-page.title">
|
||||
{i18n._(YOU_CAN_CLOSE_THIS_TAB_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription} data-flx="auth.passkey-bridge-page.description">
|
||||
{i18n._(GO_BACK_TO_CONTINUE_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const PasskeyBridgePage: React.FC<PasskeyBridgePageProps> = ({side, hash, opensInOwnTab}) =>
|
||||
side.role === 'source' ? (
|
||||
<LegacyPasskeyBridgePage
|
||||
side={side}
|
||||
hash={hash}
|
||||
opensInOwnTab={opensInOwnTab}
|
||||
data-flx="auth.passkey-bridge-page.legacy"
|
||||
/>
|
||||
) : (
|
||||
<TargetPasskeyBridgePage hash={hash} data-flx="auth.passkey-bridge-page.target" />
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readPasskeyBridgeReturn} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const CEREMONY_ID = 'a'.repeat(43);
|
||||
const COMPLETION_CODE = 'B_-'.repeat(14).concat('c');
|
||||
|
||||
describe('readPasskeyBridgeReturn', () => {
|
||||
it('reads the ceremony id and completion code from the fragment', () => {
|
||||
expect(readPasskeyBridgeReturn(`#passkey-bridge=${CEREMONY_ID}.${COMPLETION_CODE}`)).toEqual({
|
||||
ceremonyId: CEREMONY_ID,
|
||||
completionCode: COMPLETION_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty fragment', ''],
|
||||
['another fragment', '#section'],
|
||||
['a missing completion code', `#passkey-bridge=${CEREMONY_ID}`],
|
||||
['an extra segment', `#passkey-bridge=${CEREMONY_ID}.${COMPLETION_CODE}.${COMPLETION_CODE}`],
|
||||
['a short ceremony id', `#passkey-bridge=abc.${COMPLETION_CODE}`],
|
||||
['characters outside base64url', `#passkey-bridge=${CEREMONY_ID}.${'+'.repeat(43)}`],
|
||||
])('rejects %s', (_label, hash) => {
|
||||
expect(readPasskeyBridgeReturn(hash)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MfaChallenge} from '@app/features/auth/state/AuthFlow';
|
||||
import {getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {PasskeyBridgeLoginStartRequest} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
const BRIDGE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
||||
const STORED_LOGIN_KEY_PREFIX = 'fluxer:passkey-bridge:';
|
||||
const STORED_LOGIN_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export type PasskeyBridgeLoginPurpose = PasskeyBridgeLoginStartRequest['purpose'];
|
||||
|
||||
interface StoredPasskeyBridgePageLogin {
|
||||
nonce: string;
|
||||
purpose: PasskeyBridgeLoginPurpose;
|
||||
return_path: string;
|
||||
mfa: MfaChallenge | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PasskeyBridgePageLogin {
|
||||
nonce: string;
|
||||
mfa: MfaChallenge | null;
|
||||
}
|
||||
|
||||
export interface PasskeyBridgeReturn {
|
||||
ceremonyId: string;
|
||||
completionCode: string;
|
||||
}
|
||||
|
||||
export function readPasskeyBridgeReturn(hash: string): PasskeyBridgeReturn | null {
|
||||
const value = new URLSearchParams(hash.startsWith('#') ? hash.slice(1) : hash).get(
|
||||
PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY,
|
||||
);
|
||||
const parts = value?.split('.') ?? [];
|
||||
if (parts.length !== 2 || !parts.every((part) => BRIDGE_TOKEN_PATTERN.test(part))) {
|
||||
return null;
|
||||
}
|
||||
return {ceremonyId: parts[0], completionCode: parts[1]};
|
||||
}
|
||||
|
||||
function parseMfaChallenge(value: unknown): MfaChallenge | null {
|
||||
const mfa = value as Partial<MfaChallenge> | null;
|
||||
if (
|
||||
typeof mfa?.ticket !== 'string' ||
|
||||
typeof mfa.totp !== 'boolean' ||
|
||||
typeof mfa.webauthn !== 'boolean' ||
|
||||
typeof mfa.backupCodes !== 'boolean'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {ticket: mfa.ticket, totp: mfa.totp, webauthn: mfa.webauthn, backupCodes: mfa.backupCodes};
|
||||
}
|
||||
|
||||
function parseStoredLogin(raw: string | null): StoredPasskeyBridgePageLogin | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<StoredPasskeyBridgePageLogin> | null;
|
||||
if (
|
||||
typeof value?.nonce !== 'string' ||
|
||||
(value.purpose !== 'login' && value.purpose !== 'login_mfa') ||
|
||||
typeof value.return_path !== 'string' ||
|
||||
!value.return_path.startsWith('/') ||
|
||||
typeof value.created_at !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const mfa = parseMfaChallenge(value.mfa);
|
||||
if ((value.purpose === 'login_mfa') !== (mfa !== null)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nonce: value.nonce,
|
||||
purpose: value.purpose,
|
||||
return_path: value.return_path,
|
||||
mfa,
|
||||
created_at: value.created_at,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredLogin(ceremonyId: string): StoredPasskeyBridgePageLogin | null {
|
||||
try {
|
||||
return parseStoredLogin(getProtectedSessionStorage()?.getItem(`${STORED_LOGIN_KEY_PREFIX}${ceremonyId}`) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storePasskeyBridgePageLogin(ceremonyId: string, login: StoredPasskeyBridgePageLogin): void {
|
||||
const storage = getProtectedSessionStorage();
|
||||
if (storage === null) {
|
||||
throw new Error('Session storage is unavailable');
|
||||
}
|
||||
storage.setItem(`${STORED_LOGIN_KEY_PREFIX}${ceremonyId}`, JSON.stringify(login));
|
||||
}
|
||||
|
||||
export function readPasskeyBridgePageLoginReturnPath(ceremonyId: string): string | null {
|
||||
return readStoredLogin(ceremonyId)?.return_path ?? null;
|
||||
}
|
||||
|
||||
export function takePasskeyBridgePageLogin(ceremonyId: string): PasskeyBridgePageLogin | null {
|
||||
const stored = readStoredLogin(ceremonyId);
|
||||
try {
|
||||
getProtectedSessionStorage()?.removeItem(`${STORED_LOGIN_KEY_PREFIX}${ceremonyId}`);
|
||||
} catch {}
|
||||
return stored === null ? null : {nonce: stored.nonce, mfa: stored.mfa};
|
||||
}
|
||||
|
||||
export function prunePasskeyBridgePageLogins(now: number): void {
|
||||
try {
|
||||
const storage = getProtectedSessionStorage();
|
||||
if (storage === null) {
|
||||
return;
|
||||
}
|
||||
const expired: Array<string> = [];
|
||||
for (let index = 0; index < storage.length; index++) {
|
||||
const key = storage.key(index);
|
||||
if (key === null || !key.startsWith(STORED_LOGIN_KEY_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const stored = parseStoredLogin(storage.getItem(key));
|
||||
if (stored === null || now - stored.created_at > STORED_LOGIN_MAX_AGE_MS) {
|
||||
expired.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of expired) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {randomBase64Url, sha256Hex} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import {
|
||||
type PasskeyBridgeLoginPurpose,
|
||||
type PasskeyBridgePageLogin,
|
||||
storePasskeyBridgePageLogin,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import type {MfaChallenge} from '@app/features/auth/state/AuthFlow';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {PASSKEY_BRIDGE_CHANNEL} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
|
||||
const NONCE_BYTES = 32;
|
||||
const LINK_CEREMONY_RESTART_MS = 9 * 60 * 1000;
|
||||
|
||||
interface LegacyCeremony {
|
||||
id: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
async function createNonce(): Promise<{nonce: string; nonceHash: string}> {
|
||||
const nonce = randomBase64Url(NONCE_BYTES);
|
||||
return {nonce, nonceHash: await sha256Hex(nonce)};
|
||||
}
|
||||
|
||||
export function isPasskeyCeremonyDismissed(error: unknown): boolean {
|
||||
if (Platform.isElectron) {
|
||||
return !(error instanceof HttpError);
|
||||
}
|
||||
return error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError');
|
||||
}
|
||||
|
||||
async function startLogin(request: PasskeyBridgeLoginStartRequest): Promise<PasskeyBridgeStartResponse> {
|
||||
const response = await http.post<PasskeyBridgeStartResponse>(Endpoints.AUTH_PASSKEY_BRIDGE, {body: request});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function startSudo(runner: 'page' | 'native', nonceHash: string): Promise<PasskeyBridgeStartResponse> {
|
||||
const response = await http.post<PasskeyBridgeStartResponse>(Endpoints.USER_PASSKEY_BRIDGE, {
|
||||
body: {runner, nonce_hash: nonceHash},
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function redeemLogin(
|
||||
ceremony: LegacyCeremony,
|
||||
completionCode: string,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const response = await http.post<PasskeyBridgeLoginRedeemResponse>(
|
||||
Endpoints.AUTH_PASSKEY_BRIDGE_REDEEM(ceremony.id),
|
||||
{
|
||||
body: {nonce: ceremony.nonce, completion_code: completionCode},
|
||||
},
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function redeemSudo(ceremony: LegacyCeremony, completionCode: string): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const response = await http.post<PasskeyBridgeSudoRedeemResponse>(Endpoints.USER_PASSKEY_BRIDGE_REDEEM(ceremony.id), {
|
||||
body: {nonce: ceremony.nonce, completion_code: completionCode},
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
function requireCompletionCode(finish: PasskeyBridgeFinishResponse): string {
|
||||
if (finish.completion_code === null) {
|
||||
throw new Error('Passkey bridge finished without a completion code');
|
||||
}
|
||||
return finish.completion_code;
|
||||
}
|
||||
|
||||
async function runNativeCeremony(ceremonyId: string): Promise<string> {
|
||||
const optionsResponse = await http.post<{options: PublicKeyCredentialRequestOptionsJSON}>(
|
||||
Endpoints.AUTH_PASSKEY_BRIDGE_OPTIONS(ceremonyId),
|
||||
);
|
||||
let credential: AuthenticationResponseJSON;
|
||||
try {
|
||||
credential = await WebAuthnUtils.performAuthentication(optionsResponse.body.options);
|
||||
} catch (error) {
|
||||
if (!isPasskeyCeremonyDismissed(error)) {
|
||||
throw error;
|
||||
}
|
||||
const cancelled = await http.post<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_CANCEL(ceremonyId));
|
||||
return requireCompletionCode(cancelled.body);
|
||||
}
|
||||
const completed = await http.post<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_COMPLETE(ceremonyId), {
|
||||
body: {response: credential},
|
||||
});
|
||||
return requireCompletionCode(completed.body);
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgePageLogin(mfa: MfaChallenge | null, returnPath: string): Promise<void> {
|
||||
const purpose: PasskeyBridgeLoginPurpose = mfa === null ? 'login' : 'login_mfa';
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startLogin({
|
||||
purpose,
|
||||
runner: 'page',
|
||||
...(mfa === null ? {} : {ticket: mfa.ticket}),
|
||||
nonce_hash: nonceHash,
|
||||
});
|
||||
if (started.bridge_url === null) {
|
||||
throw new Error('Passkey bridge did not return a page');
|
||||
}
|
||||
storePasskeyBridgePageLogin(started.ceremony_id, {
|
||||
nonce,
|
||||
purpose,
|
||||
return_path: returnPath,
|
||||
mfa,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
window.location.assign(started.bridge_url);
|
||||
}
|
||||
|
||||
export function redeemPasskeyBridgePageLogin(
|
||||
ceremonyId: string,
|
||||
login: PasskeyBridgePageLogin,
|
||||
completionCode: string,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
return redeemLogin({id: ceremonyId, nonce: login.nonce}, completionCode);
|
||||
}
|
||||
|
||||
export async function runPasskeyBridgeNativeLogin(
|
||||
purpose: PasskeyBridgeLoginPurpose,
|
||||
ticket?: string,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startLogin({
|
||||
purpose,
|
||||
runner: 'native',
|
||||
...(ticket === undefined ? {} : {ticket}),
|
||||
nonce_hash: nonceHash,
|
||||
});
|
||||
const completionCode = await runNativeCeremony(started.ceremony_id);
|
||||
return redeemLogin({id: started.ceremony_id, nonce}, completionCode);
|
||||
}
|
||||
|
||||
export async function runPasskeyBridgeNativeSudo(): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startSudo('native', nonceHash);
|
||||
const completionCode = await runNativeCeremony(started.ceremony_id);
|
||||
return redeemSudo({id: started.ceremony_id, nonce}, completionCode);
|
||||
}
|
||||
|
||||
interface PasskeyBridgeSudoLinkHandlers {
|
||||
onLink(url: string | null): void;
|
||||
onCompleted(sudoToken: string): void;
|
||||
onError(error: unknown): void;
|
||||
}
|
||||
|
||||
export class PasskeyBridgeSudoLink {
|
||||
private readonly channel = new BroadcastChannel(PASSKEY_BRIDGE_CHANNEL);
|
||||
private ceremony: LegacyCeremony | null = null;
|
||||
private restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private generation = 0;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly handlers: PasskeyBridgeSudoLinkHandlers) {
|
||||
this.channel.onmessage = this.handleMessage;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const generation = this.reset();
|
||||
this.handlers.onLink(null);
|
||||
try {
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startSudo('page', nonceHash);
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (started.bridge_url === null) {
|
||||
throw new Error('Passkey bridge did not return a page');
|
||||
}
|
||||
this.ceremony = {id: started.ceremony_id, nonce};
|
||||
this.restartTimer = setTimeout(() => void this.start(), LINK_CEREMONY_RESTART_MS);
|
||||
this.handlers.onLink(started.bridge_url);
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.handlers.onError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.reset();
|
||||
this.disposed = true;
|
||||
this.channel.close();
|
||||
}
|
||||
|
||||
private reset(): number {
|
||||
if (this.restartTimer !== null) {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
}
|
||||
this.ceremony = null;
|
||||
this.generation += 1;
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
private readonly handleMessage = (event: MessageEvent<unknown>): void => {
|
||||
const ceremony = this.ceremony;
|
||||
const data = event.data as {ceremony_id?: unknown; completion_code?: unknown} | null;
|
||||
if (
|
||||
this.disposed ||
|
||||
ceremony === null ||
|
||||
data?.ceremony_id !== ceremony.id ||
|
||||
typeof data.completion_code !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const generation = this.reset();
|
||||
redeemSudo(ceremony, data.completion_code).then(
|
||||
(result) => {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (result.status === 'completed') {
|
||||
this.handlers.onCompleted(result.sudo_token);
|
||||
return;
|
||||
}
|
||||
void this.start();
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
this.handlers.onError(error);
|
||||
void this.start();
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readPasskeyLoginRoute, writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('PasskeyLoginRoute', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to the page passkey when nothing is stored', () => {
|
||||
expect(readPasskeyLoginRoute()).toBe('native');
|
||||
});
|
||||
|
||||
it('remembers the previous address after it is chosen and forgets it again', () => {
|
||||
writePasskeyLoginRoute('legacy');
|
||||
expect(readPasskeyLoginRoute()).toBe('legacy');
|
||||
expect(window.localStorage.getItem('fluxer:passkey-login-route')).toBe('legacy');
|
||||
writePasskeyLoginRoute('native');
|
||||
expect(readPasskeyLoginRoute()).toBe('native');
|
||||
expect(window.localStorage.getItem('fluxer:passkey-login-route')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores unknown stored values', () => {
|
||||
window.localStorage.setItem('fluxer:passkey-login-route', 'something');
|
||||
expect(readPasskeyLoginRoute()).toBe('native');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
|
||||
const PASSKEY_LOGIN_ROUTE_KEY = 'fluxer:passkey-login-route';
|
||||
|
||||
export type PasskeyLoginRoute = 'legacy' | 'native';
|
||||
|
||||
export function readPasskeyLoginRoute(): PasskeyLoginRoute {
|
||||
try {
|
||||
return getProtectedLocalStorage()?.getItem(PASSKEY_LOGIN_ROUTE_KEY) === 'legacy' ? 'legacy' : 'native';
|
||||
} catch {
|
||||
return 'native';
|
||||
}
|
||||
}
|
||||
|
||||
export function writePasskeyLoginRoute(route: PasskeyLoginRoute): void {
|
||||
try {
|
||||
if (route === 'legacy') {
|
||||
getProtectedLocalStorage()?.setItem(PASSKEY_LOGIN_ROUTE_KEY, route);
|
||||
} else {
|
||||
getProtectedLocalStorage()?.removeItem(PASSKEY_LOGIN_ROUTE_KEY);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {readDomainMigrationDiscovery} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {openPasskeyUpdateModal} from '@app/features/auth/passkey_migration/PasskeyUpdateModal';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import Modal from '@app/features/ui/state/Modal';
|
||||
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {makeAutoObservable, runInAction, when} from 'mobx';
|
||||
|
||||
const logger = new Logger('PasskeyMigration');
|
||||
|
||||
export type PendingPasskeyMigration = NonNullable<PasskeyMigrationResponse['pending']>;
|
||||
|
||||
class PasskeyMigration {
|
||||
pending: PendingPasskeyMigration | null = null;
|
||||
private checkedUserId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
async check(): Promise<void> {
|
||||
try {
|
||||
const response = await http.get<PasskeyMigrationResponse>(Endpoints.USER_MFA_WEBAUTHN_MIGRATION);
|
||||
const pending = response.body.pending;
|
||||
runInAction(() => {
|
||||
this.pending = pending;
|
||||
});
|
||||
if (pending !== null) {
|
||||
openPasskeyUpdateModal(pending);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check for a passkey to update', error);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
checkAfterSudo(sudoModalKey: string): void {
|
||||
const requester = Modal.modals.findLast((entry) => entry.key !== sudoModalKey && !entry.isBackground);
|
||||
if (requester === undefined) {
|
||||
void this.check();
|
||||
return;
|
||||
}
|
||||
when(
|
||||
() => !Modal.hasModal(requester.key),
|
||||
() => void this.check(),
|
||||
);
|
||||
}
|
||||
|
||||
handleGatewayReady(userId: string): void {
|
||||
if (
|
||||
this.checkedUserId === userId ||
|
||||
!isPasskeyMigrationOrigin() ||
|
||||
readDomainMigrationDiscovery()?.enabled !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.checkedUserId = userId;
|
||||
if (WebAuthnCredentials.credentials.some((credential) => credential.rp_id !== PASSKEY_MIGRATION_RP_ID)) {
|
||||
void this.check();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PasskeyMigration();
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {rpIdMatchesPage} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/app/domain_migration/DomainMigrationRollout', () => ({default: {enabled: false}}));
|
||||
|
||||
describe('rpIdMatchesPage', () => {
|
||||
it('matches the page host and its parent domains', () => {
|
||||
expect(rpIdMatchesPage('fluxer.com', 'fluxer.com')).toBe(true);
|
||||
expect(rpIdMatchesPage('fluxer.com', 'canary.fluxer.com')).toBe(true);
|
||||
expect(rpIdMatchesPage('fluxer.app', 'web.canary.fluxer.app')).toBe(true);
|
||||
expect(rpIdMatchesPage('Fluxer.COM', 'fluxer.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects other domains', () => {
|
||||
expect(rpIdMatchesPage('fluxer.app', 'fluxer.com')).toBe(false);
|
||||
expect(rpIdMatchesPage('fluxer.com', 'notfluxer.com')).toBe(false);
|
||||
expect(rpIdMatchesPage('canary.fluxer.com', 'fluxer.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveDomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
|
||||
export function isPasskeyMigrationOrigin(): boolean {
|
||||
return resolveDomainMigrationSide(window.location.origin)?.role === 'target';
|
||||
}
|
||||
|
||||
export function rpIdMatchesPage(rpId: string, hostname: string = window.location.hostname): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
const normalizedRpId = rpId.toLowerCase();
|
||||
return host === normalizedRpId || host.endsWith(`.${normalizedRpId}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import PasskeyMigration, {type PendingPasskeyMigration} from '@app/features/auth/passkey_migration/PasskeyMigration';
|
||||
import styles from '@app/features/auth/passkey_migration/PasskeyUpdateModal.module.css';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {PublicKeyCredentialCreationOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const PASSKEY_UPDATE_MODAL_KEY = 'passkey-update-modal';
|
||||
const logger = new Logger('PasskeyUpdateModal');
|
||||
|
||||
const UPDATE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Update your passkey',
|
||||
comment: 'Title of the modal that asks the user to save an updated passkey after signing in with an older one.',
|
||||
});
|
||||
const UPDATE_PASSKEY_BODY_DESCRIPTOR = msg({
|
||||
message: 'Your device will ask you to save an updated passkey for {name}. It replaces the old one.',
|
||||
comment:
|
||||
'Body of the modal that asks the user to save an updated passkey. name is the name the user gave the passkey. Keep plain.',
|
||||
});
|
||||
const UPDATE_PASSKEY_CROSS_DEVICE_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Your device will ask you to save an updated passkey for {name}. Use the same phone or security key you just used.',
|
||||
comment:
|
||||
'Body of the modal that asks the user to save an updated passkey when they signed in with a phone or security key. name is the name the user gave the passkey. Keep plain.',
|
||||
});
|
||||
const UPDATE_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Update passkey',
|
||||
comment: 'Primary button in the modal that saves an updated passkey.',
|
||||
});
|
||||
const NOT_NOW_DESCRIPTOR = msg({
|
||||
message: 'Not now',
|
||||
comment: 'Secondary button that closes the passkey update modal after an attempt failed.',
|
||||
});
|
||||
const COULDN_T_UPDATE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: "Couldn't update your passkey. Try again.",
|
||||
comment: 'Error shown in the passkey update modal when saving the updated passkey failed. Keep plain.',
|
||||
});
|
||||
const PASSKEY_UPDATED_DESCRIPTOR = msg({
|
||||
message: 'Passkey updated',
|
||||
comment: 'Toast shown after the user saved an updated passkey.',
|
||||
});
|
||||
|
||||
const isAlreadyRegisteredError = (error: unknown): boolean =>
|
||||
error instanceof Error && error.name === 'InvalidStateError';
|
||||
|
||||
const PasskeyUpdateModal = observer(({pending}: {pending: PendingPasskeyMigration}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(null);
|
||||
const [optionsRequest, setOptionsRequest] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setOptions(null);
|
||||
http
|
||||
.post<PublicKeyCredentialCreationOptionsJSON>(Endpoints.USER_MFA_WEBAUTHN_MIGRATION_REGISTRATION_OPTIONS)
|
||||
.then((response) => {
|
||||
if (current) {
|
||||
setOptions(response.body);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (current) {
|
||||
logger.error('Failed to get registration options for the passkey update', error);
|
||||
setFailed(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [optionsRequest]);
|
||||
const close = useCallback(() => {
|
||||
PasskeyMigration.clear();
|
||||
ModalCommands.popWithKey(PASSKEY_UPDATE_MODAL_KEY);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
if (failed && !submitting) {
|
||||
close();
|
||||
}
|
||||
}, [close, failed, submitting]);
|
||||
const handleUpdate = async () => {
|
||||
if (options === null) {
|
||||
setOptionsRequest((request) => request + 1);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const credential = await WebAuthnUtils.performRegistration(options);
|
||||
await http.post(Endpoints.USER_MFA_WEBAUTHN_MIGRATION, {
|
||||
body: {response: credential, challenge: options.challenge},
|
||||
});
|
||||
close();
|
||||
ToastCommands.success(i18n._(PASSKEY_UPDATED_DESCRIPTOR));
|
||||
} catch (error) {
|
||||
if (isAlreadyRegisteredError(error)) {
|
||||
writePasskeyLoginRoute('native');
|
||||
close();
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to update passkey', error);
|
||||
setFailed(true);
|
||||
setOptionsRequest((request) => request + 1);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={handleClose} data-flx="auth.passkey-update-modal.modal-root">
|
||||
<Modal.Header
|
||||
title={i18n._(UPDATE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
hideCloseButton={!failed}
|
||||
onClose={handleClose}
|
||||
data-flx="auth.passkey-update-modal.modal-header"
|
||||
/>
|
||||
<Modal.Content data-flx="auth.passkey-update-modal.modal-content">
|
||||
<Modal.ContentLayout data-flx="auth.passkey-update-modal.modal-content-layout">
|
||||
<Modal.Description data-flx="auth.passkey-update-modal.description">
|
||||
{i18n._(
|
||||
pending.cross_device ? UPDATE_PASSKEY_CROSS_DEVICE_BODY_DESCRIPTOR : UPDATE_PASSKEY_BODY_DESCRIPTOR,
|
||||
{name: pending.name},
|
||||
)}
|
||||
</Modal.Description>
|
||||
{failed && (
|
||||
<p className={styles.error} role="alert" data-flx="auth.passkey-update-modal.error">
|
||||
{i18n._(COULDN_T_UPDATE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</p>
|
||||
)}
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="auth.passkey-update-modal.modal-footer">
|
||||
{failed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={close}
|
||||
disabled={submitting}
|
||||
data-flx="auth.passkey-update-modal.button.not-now"
|
||||
>
|
||||
{i18n._(NOT_NOW_DESCRIPTOR)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
submitting={submitting || (options === null && !failed)}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-update-modal.button.update"
|
||||
>
|
||||
{i18n._(UPDATE_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export function openPasskeyUpdateModal(pending: PendingPasskeyMigration): void {
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => <PasskeyUpdateModal pending={pending} data-flx="auth.passkey-update-modal" />),
|
||||
PASSKEY_UPDATE_MODAL_KEY,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
prunePasskeyBridgePageLogins,
|
||||
readPasskeyBridgeReturn,
|
||||
takePasskeyBridgePageLogin,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import {redeemPasskeyBridgePageLogin} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import {writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {type LoginSuccessPayload, type MfaChallenge, toLoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('usePasskeyBridgeReturn');
|
||||
|
||||
interface PasskeyBridgeReturnOptions {
|
||||
redirectPath?: string;
|
||||
onLoginSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
|
||||
onRequireMfa: (challenge: MfaChallenge) => void;
|
||||
onFailure: () => void;
|
||||
}
|
||||
|
||||
export function usePasskeyBridgeReturn(options: PasskeyBridgeReturnOptions): boolean {
|
||||
const [isRedeeming, setIsRedeeming] = useState(false);
|
||||
const handledRef = useRef(false);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
useEffect(() => {
|
||||
if (handledRef.current || !isPasskeyMigrationOrigin()) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = true;
|
||||
prunePasskeyBridgePageLogins(Date.now());
|
||||
const bridgeReturn = readPasskeyBridgeReturn(window.location.hash);
|
||||
if (bridgeReturn === null) {
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
|
||||
const login = takePasskeyBridgePageLogin(bridgeReturn.ceremonyId);
|
||||
if (login === null) {
|
||||
optionsRef.current.onFailure();
|
||||
return;
|
||||
}
|
||||
const restoreMfa = () => {
|
||||
if (login.mfa !== null) {
|
||||
optionsRef.current.onRequireMfa(login.mfa);
|
||||
}
|
||||
};
|
||||
setIsRedeeming(true);
|
||||
redeemPasskeyBridgePageLogin(bridgeReturn.ceremonyId, login, bridgeReturn.completionCode)
|
||||
.then(async (result) => {
|
||||
if (result.status === 'cancelled') {
|
||||
writePasskeyLoginRoute('native');
|
||||
restoreMfa();
|
||||
return;
|
||||
}
|
||||
await optionsRef.current.onLoginSuccess(toLoginSuccessPayload(result));
|
||||
if (optionsRef.current.redirectPath) {
|
||||
RouterUtils.replaceWith(optionsRef.current.redirectPath);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Passkey sign-in on the previous address failed', error);
|
||||
restoreMfa();
|
||||
optionsRef.current.onFailure();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRedeeming(false);
|
||||
});
|
||||
}, []);
|
||||
return isRedeeming;
|
||||
}
|
||||
Reference in new issue
Block a user