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

@@ -21,6 +21,11 @@ export const Endpoints = {
AUTH_HANDOFF_INFO: (code: string) => `/auth/handoff/${code}/info`,
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
AUTH_PASSKEY_BRIDGE: '/auth/passkey-bridge',
AUTH_PASSKEY_BRIDGE_OPTIONS: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/options`,
AUTH_PASSKEY_BRIDGE_COMPLETE: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/complete`,
AUTH_PASSKEY_BRIDGE_CANCEL: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/cancel`,
AUTH_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/redeem`,
AUTH_FORGOT_PASSWORD: '/auth/forgot',
AUTH_RESET_PASSWORD: '/auth/reset',
AUTH_VALIDATE_RESET_PASSWORD_TOKEN: (token: string) => `/auth/reset/${encodeURIComponent(token)}`,
@@ -173,6 +178,10 @@ export const Endpoints = {
USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/credentials/registration-options',
USER_MFA_WEBAUTHN_CREDENTIAL: (credentialId: string) => `/users/@me/mfa/webauthn/credentials/${credentialId}`,
USER_MFA_WEBAUTHN_TWO_FACTOR: '/users/@me/mfa/webauthn/two-factor',
USER_MFA_WEBAUTHN_MIGRATION: '/users/@me/mfa/webauthn/migration',
USER_MFA_WEBAUTHN_MIGRATION_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/migration/registration-options',
USER_PASSKEY_BRIDGE: '/users/@me/passkey-bridge',
USER_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/users/@me/passkey-bridge/${ceremonyId}/redeem`,
USER_PHONE_SEND_VERIFICATION: '/users/@me/phone/send-verification',
USER_PHONE_INBOUND_CHALLENGE: '/users/@me/phone/inbound-challenge',
USER_PHONE_VERIFY: '/users/@me/phone/verify',
@@ -60,7 +60,7 @@ function environment(
installKind: core.DomainMigrationInstallKind,
overrides: Partial<core.DomainMigrationEnvironment> = {},
): core.DomainMigrationEnvironment {
return {installKind, electron: false, electronMigrationVersion: null, ...overrides};
return {installKind, electron: false, electronMigrationVersion: null, electronPasskeyRpIds: [], ...overrides};
}
function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core.DomainMigrationGateInput {
@@ -70,7 +70,6 @@ function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core
discovery: ENABLED_DISCOVERY,
marker: null,
now: NOW,
relatedOriginsSupported: true,
voiceActive: false,
oneShotRoute: false,
...overrides,
@@ -255,7 +254,13 @@ describe('migration gate', () => {
expect(core.shouldStartDomainMigration(gateInput())).toBe(true);
expect(
core.shouldStartDomainMigration(
gateInput({environment: environment('none', {electron: true, electronMigrationVersion: 1})}),
gateInput({
environment: environment('none', {
electron: true,
electronMigrationVersion: 1,
electronPasskeyRpIds: ['fluxer.app', 'fluxer.com'],
}),
}),
),
).toBe(true);
});
@@ -272,7 +277,16 @@ describe('migration gate', () => {
['Firefox web app', {environment: environment('firefox')}],
['other web app', {environment: environment('other')}],
['old desktop', {environment: environment('none', {electron: true})}],
['no related origins', {relatedOriginsSupported: false}],
[
'desktop that cannot create fluxer.com passkeys',
{
environment: environment('none', {
electron: true,
electronMigrationVersion: 1,
electronPasskeyRpIds: ['fluxer.app'],
}),
},
],
['in a voice call', {voiceActive: true}],
['on a one-shot token route', {oneShotRoute: true}],
])('blocks when %s', (_label, overrides) => {
@@ -27,10 +27,6 @@ const DISPLAY_MODES: ReadonlyArray<DomainMigrationDisplayMode> = [
'minimal-ui',
];
interface PublicKeyCredentialWithCapabilities {
getClientCapabilities?: () => Promise<Record<string, boolean | undefined>>;
}
export function readDomainMigrationDiscovery(): DomainMigrationDiscoveryResponse | null {
return window.__FLUXER_BOOTSTRAP__?.instance.domain_migration ?? null;
}
@@ -68,20 +64,16 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
installKind: detectDomainMigrationInstallKind(),
electron: isElectronEnvironment(),
electronMigrationVersion: window.electron?.domainMigration?.version ?? null,
electronPasskeyRpIds: window.electron?.passkeyRpIds ?? [],
};
}
export async function browserSupportsRelatedOrigins(): Promise<boolean> {
if (typeof PublicKeyCredential === 'undefined') {
return false;
}
const credential = PublicKeyCredential as unknown as PublicKeyCredentialWithCapabilities;
if (typeof credential.getClientCapabilities !== 'function') {
return false;
export async function desktopPasskeysSupported(): Promise<boolean> {
if (!isElectronEnvironment()) {
return true;
}
try {
const capabilities = await credential.getClientCapabilities();
return capabilities.relatedOrigins === true;
return (await window.electron?.passkeyIsSupported?.()) === true;
} catch {
return false;
}
@@ -3,6 +3,7 @@
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
@@ -256,6 +257,7 @@ export interface DomainMigrationEnvironment {
installKind: DomainMigrationInstallKind;
electron: boolean;
electronMigrationVersion: number | null;
electronPasskeyRpIds: ReadonlyArray<string>;
}
export function environmentAllowsDomainMigration(environment: DomainMigrationEnvironment): boolean {
@@ -263,7 +265,11 @@ export function environmentAllowsDomainMigration(environment: DomainMigrationEnv
return false;
}
if (environment.electron) {
return environment.electronMigrationVersion !== null && environment.electronMigrationVersion >= 1;
return (
environment.electronMigrationVersion !== null &&
environment.electronMigrationVersion >= 1 &&
environment.electronPasskeyRpIds.includes(PASSKEY_MIGRATION_RP_ID)
);
}
return true;
}
@@ -284,7 +290,6 @@ export interface DomainMigrationGateInput {
discovery: DomainMigrationDiscoveryResponse | null;
marker: DomainMigrationMarker | null;
now: number;
relatedOriginsSupported: boolean;
voiceActive: boolean;
oneShotRoute: boolean;
}
@@ -295,7 +300,6 @@ export function shouldStartDomainMigration(input: DomainMigrationGateInput): boo
input.discovery?.enabled === true &&
markerAllowsDomainMigration(input.marker, input.now) &&
environmentAllowsDomainMigration(input.environment) &&
input.relatedOriginsSupported &&
!input.voiceActive &&
!input.oneShotRoute
);
@@ -51,7 +51,6 @@ import {
randomBase64Url,
sha256Hex,
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
import {resolvePasskeyBridgeOpenerOrigin} from '@app/features/auth/utils/PasskeyBridgeProtocol';
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -593,7 +592,7 @@ export async function runDomainMigrationPreMount(): Promise<boolean> {
return false;
}
const side = resolveDomainMigrationSide(window.location.origin);
if (side === null || resolvePasskeyBridgeOpenerOrigin(window.location.origin, window.location.pathname) !== null) {
if (side === null) {
return false;
}
try {
@@ -2,7 +2,7 @@
import {Routes} from '@app/app/Routes';
import {
browserSupportsRelatedOrigins,
desktopPasskeysSupported,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
@@ -50,28 +50,24 @@ function isOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
function readGateInput(assignmentEnabled: boolean, relatedOriginsSupported: boolean): DomainMigrationGateInput {
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
relatedOriginsSupported,
voiceActive: isVoiceActive(),
oneShotRoute: isOneShotRoute(window.location.pathname),
};
}
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled, true))) {
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
return;
}
const relatedOriginsSupported = await browserSupportsRelatedOrigins();
if (
navigating ||
!shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled, relatedOriginsSupported))
) {
const passkeysSupported = await desktopPasskeysSupported();
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
return;
}
navigating = true;