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

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

No files matched your search

@@ -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;
}