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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user