fix(mobile): ignore cancelled password completions

Track each native mobile password-unlock operation with a monotonic generation. Cancelling the password prompt immediately invalidates the active operation and releases the busy state.\n\nEvery asynchronous login boundary now verifies that its operation is still current before reporting errors, writing a client token, persisting connection metadata, switching the active runtime, or notifying the connected surface. A stale relay login still closes its unadopted tunnel, while its finally block cannot clear the busy state of a newer password attempt.\n\nAdd a focused regression test that models a deferred password completion after cancellation and verifies it cannot apply the runtime-switch side effect.
This commit is contained in:
Bohdan Triapitsyn
2026-07-30 17:43:39 +03:00
parent 3b00c91893
commit 7a16290ff3
2 changed files with 48 additions and 3 deletions
+19 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from 'bun:test'; import { describe, expect, mock, test } from 'bun:test';
import { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections'; import { createMobilePasswordOperationTracker, loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
@@ -40,6 +40,24 @@ const testRelay: MobileRelayConfig = {
}; };
describe('mobile connection storage', () => { describe('mobile connection storage', () => {
test('cancellation invalidates an in-flight password completion', async () => {
const tracker = createMobilePasswordOperationTracker();
const operation = tracker.begin();
let resolveLogin: () => void = () => {
throw new Error('Login was not started');
};
let switchedRuntime = false;
const completion = new Promise<void>((resolve) => { resolveLogin = resolve; }).then(() => {
if (tracker.isCurrent(operation)) switchedRuntime = true;
});
tracker.cancel();
resolveLogin();
await completion;
expect(switchedRuntime).toBe(false);
});
test('removes inline tokens only after each secure migration succeeds', async () => { test('removes inline tokens only after each secure migration succeeds', async () => {
const result = await migrateLegacyInlineTokenRecords([ const result = await migrateLegacyInlineTokenRecords([
{ id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' }, { id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' },
+29 -2
View File
@@ -72,6 +72,20 @@ const MOBILE_SECURE_TIMEOUT_MS = 3000;
// feels instant instead of hanging for seconds. // feels instant instead of hanging for seconds.
const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500; const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500;
export const createMobilePasswordOperationTracker = () => {
let current = 0;
return {
begin: (): number => {
current += 1;
return current;
},
cancel: (): void => {
current += 1;
},
isCurrent: (operation: number): boolean => operation === current,
};
};
export type MobileConnectionMode = 'direct' | 'relay'; export type MobileConnectionMode = 'direct' | 'relay';
// Persisted relay transport config. This is connection metadata, not a secret // Persisted relay transport config. This is connection metadata, not a secret
@@ -1313,6 +1327,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null); const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null);
const connectionsRef = React.useRef(connections); const connectionsRef = React.useRef(connections);
const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null); const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null);
const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker());
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
connectionsRef.current = next; connectionsRef.current = next;
@@ -1496,6 +1511,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
setError(null); setError(null);
beginBusy('password'); beginBusy('password');
const operation = passwordOperationRef.current.begin();
const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation);
const { id, label, candidates } = pendingConnection; const { id, label, candidates } = pendingConnection;
// A chosen relay transport owns an open tunnel; close it unless the switch // A chosen relay transport owns an open tunnel; close it unless the switch
// adopted it as the runtime tunnel. // adopted it as the runtime tunnel.
@@ -1506,6 +1523,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
// tunnel; cookies never cross it, so an issued bearer token is mandatory // tunnel; cookies never cross it, so an issued bearer token is mandatory
// there. `issueClientToken` mints the device's token in one round-trip. // there. `issueClientToken` mints the device's token in one round-trip.
chosen = await establishLiveTransport(candidates); chosen = await establishLiveTransport(candidates);
if (!isCurrentOperation()) return;
if (!chosen) { if (!chosen) {
setError(t('mobile.connect.error.unreachable')); setError(t('mobile.connect.error.unreachable'));
return; return;
@@ -1522,12 +1540,14 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
const response = chosen.kind === 'relay' const response = chosen.kind === 'relay'
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null)) ? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null))
: await requestWithTimeout(`${chosen.url}/auth/session`, loginInit); : await requestWithTimeout(`${chosen.url}/auth/session`, loginInit);
if (!isCurrentOperation()) return;
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
if (!response?.ok) { if (!response?.ok) {
setError(t('mobile.connect.error.passwordFailed')); setError(t('mobile.connect.error.passwordFailed'));
return; return;
} }
const body = await response.json().catch(() => null) as { clientToken?: unknown } | null; const body = await response.json().catch(() => null) as { clientToken?: unknown } | null;
if (!isCurrentOperation()) return;
const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : ''; const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
logConnect('password:token', { issued: Boolean(issuedToken) }); logConnect('password:token', { issued: Boolean(issuedToken) });
@@ -1548,8 +1568,11 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
// Persist the token BEFORE switching (no fire-and-forget). // Persist the token BEFORE switching (no fire-and-forget).
if (isCapacitorApp()) { if (isCapacitorApp()) {
if (!isCurrentOperation()) return;
await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken); await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken);
if (!isCurrentOperation()) return;
} }
if (!isCurrentOperation()) return;
persistMetadata({ id, label, candidates, clientToken: issuedToken }); persistMetadata({ id, label, candidates, clientToken: issuedToken });
setPendingConnection(null); setPendingConnection(null);
// A relay transport hands its live login tunnel to the runtime (adopted // A relay transport hands its live login tunnel to the runtime (adopted
@@ -1560,20 +1583,24 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
{ runtimeKey: secureTokenKeyOf({ candidates }) }, { runtimeKey: secureTokenKeyOf({ candidates }) },
); );
adopted = chosen.kind === 'relay'; adopted = chosen.kind === 'relay';
if (!isCurrentOperation()) return;
onConnected(); onConnected();
} catch (error) { } catch (error) {
if (!isCurrentOperation()) return;
console.warn('[mobile-connect] password threw', error); console.warn('[mobile-connect] password threw', error);
setError(t('mobile.connect.error.passwordFailed')); setError(t('mobile.connect.error.passwordFailed'));
} finally { } finally {
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close(); if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
endBusy('password'); if (isCurrentOperation()) endBusy('password');
} }
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
const cancelPassword = React.useCallback(() => { const cancelPassword = React.useCallback(() => {
passwordOperationRef.current.cancel();
endBusy('password');
setPendingConnection(null); setPendingConnection(null);
setError(null); setError(null);
}, []); }, [endBusy]);
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => { const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
setError(null); setError(null);