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 { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
import { createMobilePasswordOperationTracker, loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
@@ -40,6 +40,24 @@ const testRelay: MobileRelayConfig = {
};
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 () => {
const result = await migrateLegacyInlineTokenRecords([
{ 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.
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';
// 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 connectionsRef = React.useRef(connections);
const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null);
const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker());
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
connectionsRef.current = next;
@@ -1496,6 +1511,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
setError(null);
beginBusy('password');
const operation = passwordOperationRef.current.begin();
const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation);
const { id, label, candidates } = pendingConnection;
// A chosen relay transport owns an open tunnel; close it unless the switch
// 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
// there. `issueClientToken` mints the device's token in one round-trip.
chosen = await establishLiveTransport(candidates);
if (!isCurrentOperation()) return;
if (!chosen) {
setError(t('mobile.connect.error.unreachable'));
return;
@@ -1522,12 +1540,14 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
const response = chosen.kind === 'relay'
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null))
: await requestWithTimeout(`${chosen.url}/auth/session`, loginInit);
if (!isCurrentOperation()) return;
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
if (!response?.ok) {
setError(t('mobile.connect.error.passwordFailed'));
return;
}
const body = await response.json().catch(() => null) as { clientToken?: unknown } | null;
if (!isCurrentOperation()) return;
const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
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).
if (isCapacitorApp()) {
if (!isCurrentOperation()) return;
await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken);
if (!isCurrentOperation()) return;
}
if (!isCurrentOperation()) return;
persistMetadata({ id, label, candidates, clientToken: issuedToken });
setPendingConnection(null);
// 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 }) },
);
adopted = chosen.kind === 'relay';
if (!isCurrentOperation()) return;
onConnected();
} catch (error) {
if (!isCurrentOperation()) return;
console.warn('[mobile-connect] password threw', error);
setError(t('mobile.connect.error.passwordFailed'));
} finally {
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
endBusy('password');
if (isCurrentOperation()) endBusy('password');
}
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
const cancelPassword = React.useCallback(() => {
passwordOperationRef.current.cancel();
endBusy('password');
setPendingConnection(null);
setError(null);
}, []);
}, [endBusy]);
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
setError(null);