From 4cdff95c263ab8e4c3dbe115b859fbbd98d73e04 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 11:53:44 -0700 Subject: [PATCH] feat: harden mobile session storage --- docs/ROADMAP.md | 4 +-- mobile/lib/secureSessionStorage.ts | 44 +++++++++++++++++++++++ mobile/lib/supabase.ts | 5 +-- mobile/package.json | 1 + mobile/providers/MobileAuthProvider.tsx | 45 ++++++++++++++++++++---- mobile/providers/TradingDataProvider.tsx | 17 +++++++-- pnpm-lock.yaml | 12 +++++++ 7 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 mobile/lib/secureSessionStorage.ts diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 08c3ceb..42c9825 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,7 +29,7 @@ It assumes: - [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates - [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution -- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, and startup/error telemetry capture +- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, and secure session storage with invalidation handling - [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals - [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary @@ -304,7 +304,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell. - [x] Add product config bootstrap - [-] Integrate `@bytelyst/react-native-platform-sdk` - [x] Implement auth flow and session restore -- [-] Define secure storage and session invalidation behavior +- [x] Define secure storage and session invalidation behavior - [x] Implement launch-time kill-switch and maintenance handling - [x] Add telemetry startup and error capture - [x] Define initial mobile scope diff --git a/mobile/lib/secureSessionStorage.ts b/mobile/lib/secureSessionStorage.ts new file mode 100644 index 0000000..b3c2f18 --- /dev/null +++ b/mobile/lib/secureSessionStorage.ts @@ -0,0 +1,44 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; + +export const MOBILE_SESSION_STORAGE_KEY = 'invttrdg-auth'; + +async function getFallbackValue(key: string) { + return AsyncStorage.getItem(key); +} + +export const secureSessionStorage = { + async getItem(key: string) { + try { + const value = await SecureStore.getItemAsync(key); + if (value != null) { + return value; + } + } catch { + // Fall back when SecureStore is unavailable in the current runtime. + } + + return getFallbackValue(key); + }, + + async setItem(key: string, value: string) { + try { + await SecureStore.setItemAsync(key, value); + await AsyncStorage.removeItem(key); + return; + } catch { + await AsyncStorage.setItem(key, value); + } + }, + + async removeItem(key: string) { + await Promise.allSettled([ + SecureStore.deleteItemAsync(key), + AsyncStorage.removeItem(key), + ]); + }, +}; + +export async function clearMobileSessionStorage() { + await secureSessionStorage.removeItem(MOBILE_SESSION_STORAGE_KEY); +} diff --git a/mobile/lib/supabase.ts b/mobile/lib/supabase.ts index 546ede8..a8c3453 100644 --- a/mobile/lib/supabase.ts +++ b/mobile/lib/supabase.ts @@ -1,6 +1,6 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { createClient } from '@supabase/supabase-js'; import { getMobileSupabaseConfig } from '../../shared/supabase-config.js'; +import { MOBILE_SESSION_STORAGE_KEY, secureSessionStorage } from '@/lib/secureSessionStorage'; const supabaseConfig = getMobileSupabaseConfig(); @@ -13,7 +13,8 @@ export const mobileSupabase = createClient( supabaseConfig.anonKey, { auth: { - storage: AsyncStorage, + storage: secureSessionStorage, + storageKey: MOBILE_SESSION_STORAGE_KEY, persistSession: true, autoRefreshToken: true, detectSessionInUrl: false, diff --git a/mobile/package.json b/mobile/package.json index 15aed1c..14126c0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -30,6 +30,7 @@ "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.8", "expo-router": "~6.0.8", + "expo-secure-store": "~15.0.7", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", diff --git a/mobile/providers/MobileAuthProvider.tsx b/mobile/providers/MobileAuthProvider.tsx index c301a8d..e7281e3 100644 --- a/mobile/providers/MobileAuthProvider.tsx +++ b/mobile/providers/MobileAuthProvider.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from ' import type { ReactNode } from 'react'; import type { Session, User } from '@supabase/supabase-js'; import { mobileSupabase } from '@/lib/supabase'; +import { clearMobileSessionStorage } from '@/lib/secureSessionStorage'; import { tableNameUsers } from '@/lib/tables'; import { mobileTelemetry, trackMobileError } from '@/lib/telemetry'; @@ -22,6 +23,7 @@ interface MobileAuthContextValue { error: string | null; signIn: (email: string, password: string) => Promise<{ error?: string }>; signOut: () => Promise; + invalidateSession: (reason: string) => Promise; refreshProfile: () => Promise; accessToken: string | null; } @@ -63,7 +65,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { void bootstrap(); - const { data: authListener } = mobileSupabase.auth.onAuthStateChange((_event, nextSession) => { + const { data: authListener } = mobileSupabase.auth.onAuthStateChange((event, nextSession) => { setSession(nextSession); setUser(nextSession?.user ?? null); if (nextSession?.user) { @@ -72,6 +74,9 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { }); void fetchProfile(nextSession.user.id); } else { + if (event === 'SIGNED_OUT') { + mobileTelemetry.trackEvent('info', 'auth', 'session_signed_out'); + } setProfile(null); setLoading(false); } @@ -124,14 +129,39 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { async function signOut() { setLoading(true); - await mobileSupabase.auth.signOut(); - mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', { + try { + await mobileSupabase.auth.signOut(); + mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', { + userId: user?.id, + }); + } finally { + await clearMobileSessionStorage(); + setProfile(null); + setSession(null); + setUser(null); + setLoading(false); + } + } + + async function invalidateSession(reason: string) { + setLoading(true); + mobileTelemetry.trackEvent('warn', 'auth', 'session_invalidated', { + message: reason, userId: user?.id, }); - setProfile(null); - setSession(null); - setUser(null); - setLoading(false); + + try { + await mobileSupabase.auth.signOut({ scope: 'local' }); + } catch (invalidateError) { + trackMobileError('auth', 'session_invalidation_signout_failed', invalidateError); + } finally { + await clearMobileSessionStorage(); + setProfile(null); + setSession(null); + setUser(null); + setError(reason); + setLoading(false); + } } async function refreshProfile() { @@ -151,6 +181,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { error, signIn, signOut, + invalidateSession, refreshProfile, accessToken: session?.access_token ?? null, }), diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx index 148217f..d8c9ac3 100644 --- a/mobile/providers/TradingDataProvider.tsx +++ b/mobile/providers/TradingDataProvider.tsx @@ -137,7 +137,7 @@ const EMPTY_STATE: TradingPortfolioSummary = { }; export function TradingDataProvider({ children }: { children: ReactNode }) { - const { accessToken, user } = useMobileAuth(); + const { accessToken, user, invalidateSession } = useMobileAuth(); const [botState, setBotState] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -160,6 +160,10 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { }); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + await invalidateSession(`Trading session expired (${response.status})`); + return; + } throw new Error(`Trading state request failed (${response.status})`); } @@ -174,7 +178,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - }, [accessToken, user]); + }, [accessToken, user, invalidateSession]); useEffect(() => { void fetchState(); @@ -221,6 +225,9 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { socket.on('connect_error', (socketError) => { setError(socketError.message); trackMobileError('realtime', 'socket_connect_failed', socketError); + if (socketError.message.toLowerCase().includes('unauthorized')) { + void invalidateSession(socketError.message); + } }); socket.on('state', (nextState: BotState) => { @@ -313,6 +320,10 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { const body = await response.json().catch(() => ({} as { error?: string })); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + await invalidateSession(body.error || `Trading control unauthorized (${response.status})`); + return { error: body.error || `Request failed (${response.status})` }; + } mobileTelemetry.trackEvent('warn', 'controls', 'trading_action_failed', { message: body.error || `Request failed (${response.status})`, tags: { path }, @@ -329,7 +340,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' }; } }, - [accessToken, fetchState] + [accessToken, fetchState, invalidateSession] ); const portfolio = useMemo(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a959adf..eb0e096 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: expo-router: specifier: ~6.0.8 version: 6.0.23(68e2fe297303e98ef2913faa2068e740) + expo-secure-store: + specifier: ~15.0.7 + version: 15.0.8(expo@54.0.33) expo-splash-screen: specifier: ~31.0.10 version: 31.0.13(expo@54.0.33) @@ -3601,6 +3604,11 @@ packages: react-server-dom-webpack: optional: true + expo-secure-store@15.0.8: + resolution: {integrity: sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==} + peerDependencies: + expo: '*' + expo-server@1.0.5: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} @@ -10208,6 +10216,10 @@ snapshots: - '@types/react-dom' - supports-color + expo-secure-store@15.0.8(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server@1.0.5: {} expo-splash-screen@31.0.13(expo@54.0.33):