Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ describe('Voice transcription section', () => {
it('rolls back the switch and toasts when the write fails', async () => {
voiceInputControllerMock.supportsOnDevice.mockReturnValue(false);
voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('unset');
voiceNetworkConsentMock.writeVoiceNetworkConsent.mockRejectedValue(new Error('boom'));
// The real write resolves `false` on a keychain failure (it reports at
// warning level and stays total); the row owns the rollback.
voiceNetworkConsentMock.writeVoiceNetworkConsent.mockResolvedValue(false);
const renderer = mountVoiceControl();
await TestRenderer.act(flush);

Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/components/consent/consent-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,8 @@ export function VoiceTranscriptionControl() {
const value: 'granted' | 'declined' = next ? 'granted' : 'declined';
const previous = consent;
setConsent(value);
try {
await writeVoiceNetworkConsent(userId, value);
} catch {
const stored = await writeVoiceNetworkConsent(userId, value);
if (!stored) {
// Roll back the optimistic flip so the switch reflects the stored value.
setConsent(previous);
toast.error(t('consent.couldNotSaveChoice'));
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/glanceable-ios/adopt-activity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';

// The adoption hands an update token straight to the delivery, and it runs from
// the root layout's import. The route group that would otherwise install the
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/i18n/return-target.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';

import { LANGUAGE_RETURN_TARGET_KEY } from '@/lib/storage-keys';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';
import * as z from 'zod';

import { PICKER_LAUNCH_CONTEXT_KEY } from '@/lib/storage-keys';
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/app-unlock-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';
import {
createContext,
type ReactNode,
Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/src/lib/auth/account-metadata-write.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as SecureStore from 'expo-secure-store';

import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch';
import { deleteStoredValue, writeStoredValue } from '@/lib/auth/secure-store-value';
import { chainSave } from '@/lib/hooks/save-chain';

/**
Expand All @@ -19,10 +18,12 @@ export async function writeAccountMetadata(key: string, write: () => Promise<voi
});
}

/** Epoch-fenced, per-key serialized write of one string value. */
/** Epoch-fenced, per-key serialized write of one string value. A failed write
* is reported at warning level with the stable write fingerprint and still
* rejects, so a preference write can surface its own recoverable state. */
export async function setAccountMetadata(key: string, value: string): Promise<void> {
await writeAccountMetadata(key, async () => {
await SecureStore.setItemAsync(key, value);
await writeStoredValue(key, value);
});
}

Expand All @@ -34,6 +35,6 @@ export async function setAccountMetadata(key: string, value: string): Promise<vo
*/
export async function deleteAccountMetadata(key: string): Promise<void> {
await chainSave(key, async () => {
await SecureStore.deleteItemAsync(key);
await deleteStoredValue(key);
});
}
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/auth/admission.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as AppIntegrity from '@expo/app-integrity';
import { CryptoDigestAlgorithm, CryptoEncoding, digestStringAsync } from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';
import { Platform } from 'react-native';
import * as z from 'zod';

Expand Down
29 changes: 17 additions & 12 deletions apps/mobile/src/lib/auth/auth-context.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- sign-out teardown ordering, stale sign-in fencing, and the consent-outcome clear are kept together with the provider mount */
import * as SecureStore from 'expo-secure-store';
import { z } from 'zod';
import {
createContext,
Expand Down Expand Up @@ -43,6 +42,11 @@ import {
setSignOutTeardownActive,
} from '@/lib/auth/token-owner';
import { readStoredValueWithRetry } from '@/lib/auth/secure-store-read';
import {
deleteStoredValue,
readStoredValue,
readStoredValueSafe,
} from '@/lib/auth/secure-store-value';
import { type AuthSignOutCause, reportAuthBranch } from '@/lib/auth/sign-out-telemetry';
import { chainSave } from '@/lib/hooks/save-chain';
import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model';
Expand Down Expand Up @@ -93,9 +97,13 @@ import { purgePostHogPersistence } from '@/lib/telemetry/posthog-storage';
import { AppState } from 'react-native';
import { beginAuthenticatedOwner, markRestoredAuthenticatedOwner } from '@/lib/context-scope';

// Pre-load tokens at module level so they're available before React mounts
export const preloadedAuthToken = SecureStore.getItemAsync(AUTH_TOKEN_KEY);
const preloadedRefreshToken = SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
// Pre-load tokens at module level so they're available before React mounts.
// The raw throwing read is deliberate: a rejection here must reach the
// bootstrap read below, not settle as "nothing stored", so the retry helper —
// not this preload — owns the failure outcome (it reports the exhausted read at
// warning level with the stable read fingerprint).
export const preloadedAuthToken = readStoredValue(AUTH_TOKEN_KEY);
const preloadedRefreshToken = readStoredValue(REFRESH_TOKEN_KEY);
// A keychain failure at process start rejects these before any consumer can
// await them, and the runtime would report that as an unhandled rejection
// before AuthProvider even mounts. Observe it here; the bootstrap read below
Expand Down Expand Up @@ -503,13 +511,10 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {
// deletion are members of the same always-attempted batch.
await Promise.allSettled([
writeCredentials(async () => {
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await SecureStore.deleteItemAsync(
TOKEN_EXPIRES_AT_KEY,
IOS_BEARER_SECURE_STORE_OPTIONS
);
await SecureStore.deleteItemAsync(LEGACY_EXCHANGE_DONE_KEY);
await deleteStoredValue(AUTH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await deleteStoredValue(REFRESH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await deleteStoredValue(TOKEN_EXPIRES_AT_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await deleteStoredValue(LEGACY_EXCHANGE_DONE_KEY);
}),
deleteAccountMetadata(ACTIVE_USER_ID_KEY),
deleteAccountMetadata(ORGANIZATION_STORAGE_KEY),
Expand Down Expand Up @@ -641,7 +646,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {

void (async () => {
try {
const expiresAtStr = await SecureStore.getItemAsync(TOKEN_EXPIRES_AT_KEY);
const expiresAtStr = await readStoredValueSafe(TOKEN_EXPIRES_AT_KEY);
if (!expiresAtStr) {
return;
}
Expand Down
14 changes: 9 additions & 5 deletions apps/mobile/src/lib/auth/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
readStoredValueRetryingNull,
readStoredValueWithRetry,
} from '@/lib/auth/secure-store-read';
import { deleteStoredValue, writeStoredValue } from '@/lib/auth/secure-store-value';
import { getActiveToken, isSignOutTeardownActive, setActiveToken } from '@/lib/auth/token-owner';
import { chainSave } from '@/lib/hooks/save-chain';
import { AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY, TOKEN_EXPIRES_AT_KEY } from '@/lib/storage-keys';
Expand Down Expand Up @@ -101,9 +102,9 @@ export async function persistSignInCredentialsAtEpoch(
// a newer sign-in or sign-out: their own credential write is queued
// strictly behind this one.
const clearPartialCredentials = async (): Promise<void> => {
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await SecureStore.deleteItemAsync(TOKEN_EXPIRES_AT_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await deleteStoredValue(AUTH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await deleteStoredValue(REFRESH_TOKEN_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
await deleteStoredValue(TOKEN_EXPIRES_AT_KEY, IOS_BEARER_SECURE_STORE_OPTIONS);
};

// Fence one credential operation: skip it when the epoch moved before the
Expand All @@ -112,9 +113,12 @@ export async function persistSignInCredentialsAtEpoch(
if (!isCurrentAuthEpoch(epoch)) {
return false;
}
// A keychain write/delete that rejects is reported at warning level with
// the stable operation fingerprint and then propagates, so sign-in lands
// on its persist-error state instead of publishing a half-written pair.
await (value === undefined
? SecureStore.deleteItemAsync(key, IOS_BEARER_SECURE_STORE_OPTIONS)
: SecureStore.setItemAsync(key, value, IOS_BEARER_SECURE_STORE_OPTIONS));
? deleteStoredValue(key, IOS_BEARER_SECURE_STORE_OPTIONS)
: writeStoredValue(key, value, IOS_BEARER_SECURE_STORE_OPTIONS));
Comment thread
iscekic marked this conversation as resolved.
Resolved
if (!isCurrentAuthEpoch(epoch)) {
await clearPartialCredentials();
return false;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/auth/exchange-legacy-token.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';

import { API_BASE_URL } from '@/lib/config';
import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch';
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/auth/logout-cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';
import * as Sentry from '@sentry/react-native';
import * as z from 'zod';

Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/lib/auth/pending-external-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as SecureStore from 'expo-secure-store';
import * as Sentry from '@sentry/react-native';

import { setTelemetrySink, type TelemetryEvent } from '@/lib/telemetry/error-sink';
import {
_resetPendingExternalAuthForTests,
clearPendingExternalAuth,
Expand Down Expand Up @@ -55,6 +56,35 @@ describe('pending-external-auth', () => {
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
});

it('treats a rejected read as absent instead of rejecting into the caller', async () => {
const events: TelemetryEvent[] = [];
setTelemetrySink(event => {
events.push(event);
});
try {
vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(
new Error("Calling the 'getValueWithKeyAsync' function has failed")
);

// The mount effect `void`s this read: a rejection would be an unhandled
// error, so an unreadable record must read as absent.
await expect(readPendingExternalAuth()).resolves.toEqual({ kind: 'none' });
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();

// The guarded store reports the failure once, at warning level, under the
// stable operation fingerprint — not the raw native message.
expect(events).toHaveLength(1);
expect(events[0]?.level).toBe('warning');
expect(events[0]?.fingerprint).toEqual(['secure-store-failure', 'read']);
expect(events[0]?.tags).toEqual({
'error.subsystem': 'secure_store',
'error.operation': 'read',
});
} finally {
setTelemetrySink(null);
}
});

it('returns stale for a record past the TTL without deleting', async () => {
const expired = { ...record, startedAt: Date.now() - PENDING_EXTERNAL_AUTH_TTL_MS - 1000 };
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(JSON.stringify(expired));
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/lib/auth/pending-external-auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/react-native';
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from '@/lib/auth/secure-store';
import * as z from 'zod';

// Module-local key: this slice does not own storage-keys.ts, so the key is
Expand Down Expand Up @@ -60,8 +60,20 @@ export type PendingExternalAuthReadResult =
// Reads WITHOUT clearing. The caller must clear a `stale` record itself, after
// its own epoch check, so a concurrent `start()` write is never deleted by a
// restore read that observed the older record.
//
// Total: a rejected read is the same recoverable outcome as an absent record.
// The guarded store reports the failure at warning level with the stable read
// fingerprint before rethrowing, so catching here leaves exactly one event. It
// must not reject — the mount effect `void`s this read, so a rejection would
// surface as an unhandled error instead of the idle screen, and the person
// could still start sign-in again.
export async function readPendingExternalAuth(): Promise<PendingExternalAuthReadResult> {
const raw = await SecureStore.getItemAsync(PENDING_EXTERNAL_AUTH_KEY);
let raw: string | null = null;
try {
raw = await SecureStore.getItemAsync(PENDING_EXTERNAL_AUTH_KEY);
} catch {
return { kind: 'none' };
}
if (!raw) {
return { kind: 'none' };
}
Expand Down
58 changes: 56 additions & 2 deletions apps/mobile/src/lib/auth/secure-store-read.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { INJECTED_FAULT_ERROR_NAME } from '@/lib/telemetry/e2e-fault';
import { setTelemetrySink, type TelemetryEvent } from '@/lib/telemetry/error-sink';

const getItemAsync = vi.hoisted(() =>
vi.fn<(key: string, options?: unknown) => Promise<string | null>>()
Expand Down Expand Up @@ -123,13 +124,20 @@ describe('readStoredValueRetryingNull', () => {
});

describe('readStoredValueWithRetry', () => {
let events: TelemetryEvent[] = [];

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
events = [];
setTelemetrySink(event => {
events.push(event);
});
});

afterEach(() => {
vi.useRealTimers();
setTelemetrySink(null);
});

it('returns the value on a healthy first read without waiting', async () => {
Expand All @@ -138,7 +146,7 @@ describe('readStoredValueWithRetry', () => {

await expect(readStoredValueWithRetry('auth-token')).resolves.toBe('stored-token');
expect(getItemAsync).toHaveBeenCalledTimes(1);
expect(getItemAsync).toHaveBeenCalledWith('auth-token', undefined);
expect(getItemAsync).toHaveBeenCalledWith('auth-token');
});

it('passes a null resolution straight through and never retries it', async () => {
Expand Down Expand Up @@ -188,6 +196,40 @@ describe('readStoredValueWithRetry', () => {
expect(getItemAsync).toHaveBeenCalledTimes(4);
});

it('reports the exhausted read exactly once at warning level with the stable fingerprint', async () => {
getItemAsync.mockRejectedValue(new Error('keychain unavailable'));
const readStoredValueWithRetry = await loadHelper();

const settled = expect(readStoredValueWithRetry('auth-token')).rejects.toThrow(
'keychain unavailable'
);
await vi.advanceTimersByTimeAsync(2000);
await settled;

expect(events).toHaveLength(1);
expect(events[0]?.level).toBe('warning');
expect(events[0]?.fingerprint).toEqual(['secure-store-failure', 'read']);
expect(events[0]?.tags).toEqual({
'error.subsystem': 'secure_store',
'error.operation': 'read',
});
// The key is never attached to the report.
expect(JSON.stringify(events[0])).not.toContain('auth-token');
});

it('reports nothing when a retry recovers the read', async () => {
getItemAsync
.mockRejectedValueOnce(new Error('keychain unavailable'))
.mockResolvedValue('stored-token');
const readStoredValueWithRetry = await loadHelper();

const read = readStoredValueWithRetry('auth-token');
await vi.advanceTimersByTimeAsync(2000);

await expect(read).resolves.toBe('stored-token');
expect(events).toHaveLength(0);
});

it('uses the handed-over first attempt and issues fresh reads for the retries', async () => {
getItemAsync.mockResolvedValue('fresh-token');
const readStoredValueWithRetry = await loadHelper();
Expand Down Expand Up @@ -230,17 +272,29 @@ describe('readStoredValueWithRetry', () => {
vi.resetModules();
vi.doMock('@/lib/config', () => ({ E2E_SECURE_STORE_FAULT_MS: 60_000 }));
getItemAsync.mockResolvedValue('stored-token');
// `resetModules` gives the reloaded helper its own error-sink registry, so
// the sink is re-installed on the fresh module before the read runs.
const { setTelemetrySink: setFreshTelemetrySink } = await import('@/lib/telemetry/error-sink');
setFreshTelemetrySink(event => {
events.push(event);
});
const readStoredValueWithRetry = await loadHelper();

const settled = expect(readStoredValueWithRetry('auth-token')).rejects.toMatchObject({
name: INJECTED_FAULT_ERROR_NAME,
message: 'E2E secure-store fault window is open: read of auth-token rejected',
message: 'E2E secure-store fault window is open: read rejected',
});
await vi.advanceTimersByTimeAsync(2000);

await settled;
// The fault is the reason: the real store is never asked.
expect(getItemAsync).not.toHaveBeenCalled();
// The exhausted-read report attaches the fault error, so that error must
// carry no key — the same invariant the real store's error keeps.
expect(events).toHaveLength(1);
expect(events[0]?.level).toBe('warning');
expect(events[0]?.fingerprint).toEqual(['secure-store-failure', 'read']);
expect(JSON.stringify(events[0])).not.toContain('auth-token');

vi.doUnmock('@/lib/config');
vi.resetModules();
Expand Down
Loading
Loading