feat: harden mobile session storage
This commit is contained in:
parent
856a683f18
commit
4cdff95c26
@ -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] 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] 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] 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
|
- [-] 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
|
- [!] 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
|
- [x] Add product config bootstrap
|
||||||
- [-] Integrate `@bytelyst/react-native-platform-sdk`
|
- [-] Integrate `@bytelyst/react-native-platform-sdk`
|
||||||
- [x] Implement auth flow and session restore
|
- [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] Implement launch-time kill-switch and maintenance handling
|
||||||
- [x] Add telemetry startup and error capture
|
- [x] Add telemetry startup and error capture
|
||||||
- [x] Define initial mobile scope
|
- [x] Define initial mobile scope
|
||||||
|
|||||||
44
mobile/lib/secureSessionStorage.ts
Normal file
44
mobile/lib/secureSessionStorage.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import { getMobileSupabaseConfig } from '../../shared/supabase-config.js';
|
import { getMobileSupabaseConfig } from '../../shared/supabase-config.js';
|
||||||
|
import { MOBILE_SESSION_STORAGE_KEY, secureSessionStorage } from '@/lib/secureSessionStorage';
|
||||||
|
|
||||||
const supabaseConfig = getMobileSupabaseConfig();
|
const supabaseConfig = getMobileSupabaseConfig();
|
||||||
|
|
||||||
@ -13,7 +13,8 @@ export const mobileSupabase = createClient(
|
|||||||
supabaseConfig.anonKey,
|
supabaseConfig.anonKey,
|
||||||
{
|
{
|
||||||
auth: {
|
auth: {
|
||||||
storage: AsyncStorage,
|
storage: secureSessionStorage,
|
||||||
|
storageKey: MOBILE_SESSION_STORAGE_KEY,
|
||||||
persistSession: true,
|
persistSession: true,
|
||||||
autoRefreshToken: true,
|
autoRefreshToken: true,
|
||||||
detectSessionInUrl: false,
|
detectSessionInUrl: false,
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
"expo-router": "~6.0.8",
|
"expo-router": "~6.0.8",
|
||||||
|
"expo-secure-store": "~15.0.7",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from '
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { Session, User } from '@supabase/supabase-js';
|
import type { Session, User } from '@supabase/supabase-js';
|
||||||
import { mobileSupabase } from '@/lib/supabase';
|
import { mobileSupabase } from '@/lib/supabase';
|
||||||
|
import { clearMobileSessionStorage } from '@/lib/secureSessionStorage';
|
||||||
import { tableNameUsers } from '@/lib/tables';
|
import { tableNameUsers } from '@/lib/tables';
|
||||||
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
|
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ interface MobileAuthContextValue {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
invalidateSession: (reason: string) => Promise<void>;
|
||||||
refreshProfile: () => Promise<void>;
|
refreshProfile: () => Promise<void>;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
}
|
}
|
||||||
@ -63,7 +65,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|
||||||
const { data: authListener } = mobileSupabase.auth.onAuthStateChange((_event, nextSession) => {
|
const { data: authListener } = mobileSupabase.auth.onAuthStateChange((event, nextSession) => {
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setUser(nextSession?.user ?? null);
|
setUser(nextSession?.user ?? null);
|
||||||
if (nextSession?.user) {
|
if (nextSession?.user) {
|
||||||
@ -72,6 +74,9 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
void fetchProfile(nextSession.user.id);
|
void fetchProfile(nextSession.user.id);
|
||||||
} else {
|
} else {
|
||||||
|
if (event === 'SIGNED_OUT') {
|
||||||
|
mobileTelemetry.trackEvent('info', 'auth', 'session_signed_out');
|
||||||
|
}
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -124,14 +129,39 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await mobileSupabase.auth.signOut();
|
try {
|
||||||
mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', {
|
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,
|
userId: user?.id,
|
||||||
});
|
});
|
||||||
setProfile(null);
|
|
||||||
setSession(null);
|
try {
|
||||||
setUser(null);
|
await mobileSupabase.auth.signOut({ scope: 'local' });
|
||||||
setLoading(false);
|
} 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() {
|
async function refreshProfile() {
|
||||||
@ -151,6 +181,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
error,
|
error,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
|
invalidateSession,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
accessToken: session?.access_token ?? null,
|
accessToken: session?.access_token ?? null,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -137,7 +137,7 @@ const EMPTY_STATE: TradingPortfolioSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function TradingDataProvider({ children }: { children: ReactNode }) {
|
export function TradingDataProvider({ children }: { children: ReactNode }) {
|
||||||
const { accessToken, user } = useMobileAuth();
|
const { accessToken, user, invalidateSession } = useMobileAuth();
|
||||||
const [botState, setBotState] = useState<BotState | null>(null);
|
const [botState, setBotState] = useState<BotState | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -160,6 +160,10 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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})`);
|
throw new Error(`Trading state request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,7 +178,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [accessToken, user]);
|
}, [accessToken, user, invalidateSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchState();
|
void fetchState();
|
||||||
@ -221,6 +225,9 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
socket.on('connect_error', (socketError) => {
|
socket.on('connect_error', (socketError) => {
|
||||||
setError(socketError.message);
|
setError(socketError.message);
|
||||||
trackMobileError('realtime', 'socket_connect_failed', socketError);
|
trackMobileError('realtime', 'socket_connect_failed', socketError);
|
||||||
|
if (socketError.message.toLowerCase().includes('unauthorized')) {
|
||||||
|
void invalidateSession(socketError.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('state', (nextState: BotState) => {
|
socket.on('state', (nextState: BotState) => {
|
||||||
@ -313,6 +320,10 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const body = await response.json().catch(() => ({} as { error?: string }));
|
const body = await response.json().catch(() => ({} as { error?: string }));
|
||||||
if (!response.ok) {
|
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', {
|
mobileTelemetry.trackEvent('warn', 'controls', 'trading_action_failed', {
|
||||||
message: body.error || `Request failed (${response.status})`,
|
message: body.error || `Request failed (${response.status})`,
|
||||||
tags: { path },
|
tags: { path },
|
||||||
@ -329,7 +340,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
|
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[accessToken, fetchState]
|
[accessToken, fetchState, invalidateSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const portfolio = useMemo<TradingPortfolioSummary>(() => {
|
const portfolio = useMemo<TradingPortfolioSummary>(() => {
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -142,6 +142,9 @@ importers:
|
|||||||
expo-router:
|
expo-router:
|
||||||
specifier: ~6.0.8
|
specifier: ~6.0.8
|
||||||
version: 6.0.23(68e2fe297303e98ef2913faa2068e740)
|
version: 6.0.23(68e2fe297303e98ef2913faa2068e740)
|
||||||
|
expo-secure-store:
|
||||||
|
specifier: ~15.0.7
|
||||||
|
version: 15.0.8(expo@54.0.33)
|
||||||
expo-splash-screen:
|
expo-splash-screen:
|
||||||
specifier: ~31.0.10
|
specifier: ~31.0.10
|
||||||
version: 31.0.13(expo@54.0.33)
|
version: 31.0.13(expo@54.0.33)
|
||||||
@ -3601,6 +3604,11 @@ packages:
|
|||||||
react-server-dom-webpack:
|
react-server-dom-webpack:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
expo-secure-store@15.0.8:
|
||||||
|
resolution: {integrity: sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-server@1.0.5:
|
expo-server@1.0.5:
|
||||||
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
||||||
engines: {node: '>=20.16.0'}
|
engines: {node: '>=20.16.0'}
|
||||||
@ -10208,6 +10216,10 @@ snapshots:
|
|||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
- supports-color
|
- 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-server@1.0.5: {}
|
||||||
|
|
||||||
expo-splash-screen@31.0.13(expo@54.0.33):
|
expo-splash-screen@31.0.13(expo@54.0.33):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user