Auth Store
Store Zustand pour la gestion de l'authentification avec persistance sécurisée.
Overview
L'Auth Store est le cœur de la gestion d'authentification dans l'application mobile. Il utilise Zustand avec une persistance sécurisée via expo-secure-store pour stocker les tokens et informations sensibles.
Auth State
État utilisateur
Tokens
JWT sécurisé
Biometric
Face ID/Touch ID
Session
Gestion sessions
Store Definition
Définition complète du store d'authentification avec types TypeScript.
src/stores/authStore.tstypescript
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import * as SecureStore from 'expo-secure-store';
import { router } from 'expo-router';
// Types
export interface User {
id: string;
email: string;
phone?: string;
firstName: string;
lastName: string;
avatar?: string;
kycLevel: 'NONE' | 'BASIC' | 'INTERMEDIATE' | 'ADVANCED';
createdAt: string;
}
export interface AuthState {
// State
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
biometricEnabled: boolean;
sessionExpiresAt: number | null;
// Actions
login: (email: string, password: string) => Promise<void>;
loginDemo: () => Promise<void>;
loginWithBiometric: () => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
updateUser: (updates: Partial<User>) => void;
enableBiometric: () => Promise<void>;
disableBiometric: () => Promise<void>;
initialize: () => Promise<void>;
}
export interface RegisterData {
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
}
// Secure storage adapter for Zustand
const secureStorage = {
getItem: async (name: string): Promise<string | null> => {
return await SecureStore.getItemAsync(name);
},
setItem: async (name: string, value: string): Promise<void> => {
await SecureStore.setItemAsync(name, value);
},
removeItem: async (name: string): Promise<void> => {
await SecureStore.deleteItemAsync(name);
},
};
// Store creation
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// Initial state
user: null,
isAuthenticated: false,
isLoading: false,
biometricEnabled: false,
sessionExpiresAt: null,
// Actions defined below...
login: async () => {},
loginDemo: async () => {},
loginWithBiometric: async () => {},
register: async () => {},
logout: async () => {},
refreshSession: async () => {},
updateUser: () => {},
enableBiometric: async () => {},
disableBiometric: async () => {},
initialize: async () => {},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => secureStorage),
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
biometricEnabled: state.biometricEnabled,
sessionExpiresAt: state.sessionExpiresAt,
}),
}
)
);Actions
Implémentation des actions d'authentification.
src/stores/authStore.ts (actions)typescript
import { apiClient, tokenManager } from '@/services/api/client';
import * as LocalAuthentication from 'expo-local-authentication';
// Inside create()
{
// Login with email/password
login: async (email: string, password: string) => {
set({ isLoading: true });
try {
const { data } = await apiClient.post('/auth/login', { email, password });
// Store tokens securely
await tokenManager.setTokens(data.accessToken, data.refreshToken);
// Calculate session expiry (24h)
const sessionExpiresAt = Date.now() + 24 * 60 * 60 * 1000;
set({
user: data.user,
isAuthenticated: true,
sessionExpiresAt,
isLoading: false,
});
// Navigate to main app
router.replace('/(tabs)/home');
} catch (error) {
set({ isLoading: false });
throw error;
}
},
// Demo mode login (no backend required)
loginDemo: async () => {
set({ isLoading: true });
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
const demoUser: User = {
id: 'demo-user-001',
email: 'demo@yanipay.com',
firstName: 'Demo',
lastName: 'User',
kycLevel: 'BASIC',
createdAt: new Date().toISOString(),
};
set({
user: demoUser,
isAuthenticated: true,
sessionExpiresAt: Date.now() + 24 * 60 * 60 * 1000,
isLoading: false,
});
router.replace('/(tabs)/home');
},
// Biometric login
loginWithBiometric: async () => {
const { biometricEnabled } = get();
if (!biometricEnabled) {
throw new Error('Biometric not enabled');
}
// Authenticate with biometric
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Connectez-vous avec Face ID',
fallbackLabel: 'Utiliser le mot de passe',
cancelLabel: 'Annuler',
});
if (!result.success) {
throw new Error('Biometric authentication failed');
}
// Get stored credentials
const credentials = await SecureStore.getItemAsync('biometric_credentials');
if (!credentials) {
throw new Error('No stored credentials');
}
const { email, password } = JSON.parse(credentials);
await get().login(email, password);
},
// Register new user
register: async (data: RegisterData) => {
set({ isLoading: true });
try {
const { data: response } = await apiClient.post('/auth/register', data);
await tokenManager.setTokens(response.accessToken, response.refreshToken);
set({
user: response.user,
isAuthenticated: true,
sessionExpiresAt: Date.now() + 24 * 60 * 60 * 1000,
isLoading: false,
});
// Navigate to biometric setup or home
router.replace('/biometric-setup');
} catch (error) {
set({ isLoading: false });
throw error;
}
},
// Logout
logout: async () => {
try {
// Call logout endpoint to invalidate refresh token
await apiClient.post('/auth/logout');
} catch (error) {
// Continue logout even if API call fails
console.error('Logout API error:', error);
}
// Clear tokens
await tokenManager.clearTokens();
await SecureStore.deleteItemAsync('biometric_credentials');
// Reset state
set({
user: null,
isAuthenticated: false,
sessionExpiresAt: null,
});
// Navigate to login
router.replace('/login');
},
// Refresh session
refreshSession: async () => {
try {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
const { data } = await apiClient.post('/auth/refresh', { refreshToken });
await tokenManager.setTokens(data.accessToken, data.refreshToken);
set({
sessionExpiresAt: Date.now() + 24 * 60 * 60 * 1000,
});
} catch (error) {
// Refresh failed, logout
await get().logout();
}
},
// Update user in state
updateUser: (updates: Partial<User>) => {
const { user } = get();
if (user) {
set({ user: { ...user, ...updates } });
}
},
// Enable biometric authentication
enableBiometric: async () => {
const { user } = get();
if (!user) return;
// Check if device supports biometric
const compatible = await LocalAuthentication.hasHardwareAsync();
const enrolled = await LocalAuthentication.isEnrolledAsync();
if (!compatible || !enrolled) {
throw new Error('Biometric not available');
}
// Verify biometric first
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Activez Face ID',
});
if (!result.success) {
throw new Error('Biometric verification failed');
}
set({ biometricEnabled: true });
},
// Disable biometric
disableBiometric: async () => {
await SecureStore.deleteItemAsync('biometric_credentials');
set({ biometricEnabled: false });
},
// Initialize auth state on app start
initialize: async () => {
const accessToken = await tokenManager.getAccessToken();
const { sessionExpiresAt, isAuthenticated } = get();
if (accessToken && isAuthenticated) {
// Check if session expired
if (sessionExpiresAt && Date.now() > sessionExpiresAt) {
// Try to refresh
await get().refreshSession();
}
} else {
// Clear any stale state
set({
user: null,
isAuthenticated: false,
});
}
},
}Persistence
La persistance sécurisée utilise expo-secure-store pour le stockage chiffré.
Secure Storage Configurationtypescript
// The store uses Zustand's persist middleware with a custom storage adapter
// that wraps expo-secure-store for encrypted storage
// Data stored securely:
// - User object (id, email, name, etc.)
// - isAuthenticated flag
// - biometricEnabled flag
// - Session expiry timestamp
// Data stored separately (not in Zustand):
// - Access token (via tokenManager)
// - Refresh token (via tokenManager)
// - Biometric credentials (if enabled)
// Security considerations:
// 1. Tokens are never stored in Zustand state (memory)
// 2. All persistent data is encrypted at rest
// 3. Biometric credentials require device authentication to access
// 4. Session expiry is checked on app start
// Storage keys used:
const STORAGE_KEYS = {
AUTH_STORE: 'auth-storage', // Zustand persist
ACCESS_TOKEN: 'yanipay_access_token', // JWT access token
REFRESH_TOKEN: 'yanipay_refresh_token', // JWT refresh token
BIOMETRIC_CREDS: 'biometric_credentials', // Encrypted email/password
};Usage Examples
Exemples d'utilisation du store dans les composants.
Usage in Componentstsx
import { useAuthStore } from '@/stores/authStore';
// In a component
function ProfileHeader() {
const { user, logout } = useAuthStore();
return (
<View>
<Text>{user?.firstName} {user?.lastName}</Text>
<Button title="Logout" onPress={logout} />
</View>
);
}
// Login screen
function LoginScreen() {
const { login, loginDemo, loginWithBiometric, biometricEnabled, isLoading } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
try {
await login(email, password);
} catch (error) {
Alert.alert('Error', 'Invalid credentials');
}
};
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
<Button title="Login" onPress={handleLogin} loading={isLoading} />
{biometricEnabled && (
<TouchableOpacity onPress={loginWithBiometric}>
<Fingerprint size={48} />
</TouchableOpacity>
)}
<Button title="Demo Mode" onPress={loginDemo} variant="outline" />
</View>
);
}
// Protected route check
function useProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
const segments = useSegments();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
router.replace('/login');
} else if (isAuthenticated && inAuthGroup) {
router.replace('/(tabs)/home');
}
}, [isAuthenticated, isLoading, segments]);
}
// Selector for specific state
function BalanceDisplay() {
// Only re-render when kycLevel changes
const kycLevel = useAuthStore((state) => state.user?.kycLevel);
return <KYCBadge level={kycLevel} />;
}