mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
refactor(manadeck-mobile): migrate from custom auth to @manacore/shared-auth
Replace 900+ lines of custom auth implementation (authService, tokenManager, deviceManager, safeStorage) with ~280 lines wrapping @manacore/shared-auth. Auth now goes through mana-core-auth directly instead of manadeck backend. Backward-compatible API: all consumers (stores, apiClient, hooks) work without changes thanks to wrapper maintaining the same export interface. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c59eba7285
commit
71277ba7aa
8 changed files with 835 additions and 3029 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@expo/ui": "~0.2.0-beta.6",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* API Client for ManaDeck Backend
|
||||
* Replaces direct Supabase access with backend API calls
|
||||
* Uses shared-auth TokenManager for automatic token handling
|
||||
*/
|
||||
|
||||
import { authService } from './authService';
|
||||
import { tokenManager } from './tokenManager';
|
||||
|
||||
const API_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3000';
|
||||
const API_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3009';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T | null;
|
||||
|
|
@ -14,32 +14,11 @@ interface ApiResponse<T> {
|
|||
|
||||
class ApiClient {
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const token = await authService.getAppToken();
|
||||
const token = await tokenManager.getValidToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// Check if token is valid, try to refresh if not
|
||||
if (!authService.isTokenValidLocally(token)) {
|
||||
const refreshToken = await authService.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await authService.refreshTokens(refreshToken);
|
||||
const newToken = await authService.getAppToken();
|
||||
if (newToken) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
}
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
|
@ -87,8 +66,7 @@ class ApiClient {
|
|||
|
||||
// ============ Decks ============
|
||||
async getDecks() {
|
||||
const response = await this.request<{ decks: any[]; count: number }>('/api/decks');
|
||||
return response;
|
||||
return this.request<{ decks: any[]; count: number }>('/api/decks');
|
||||
}
|
||||
|
||||
async getDeck(id: string) {
|
||||
|
|
|
|||
|
|
@ -1,87 +1,115 @@
|
|||
import { Platform } from 'react-native';
|
||||
import type {
|
||||
ManaUser,
|
||||
AuthTokens,
|
||||
SignInResponse,
|
||||
SignUpResponse,
|
||||
JwtPayload,
|
||||
DeviceInfo,
|
||||
} from '../types/auth';
|
||||
import { DeviceManager } from '../utils/deviceManager';
|
||||
import { safeStorage } from '../utils/safeStorage';
|
||||
import { debug, info, warn, error as logError } from '../utils/logger';
|
||||
|
||||
// Get backend URL from environment with fallback
|
||||
const BASE_API_URL =
|
||||
process.env.EXPO_PUBLIC_API_URL || 'https://manadeck-backend-111768794939.europe-west3.run.app';
|
||||
|
||||
// Handle localhost for Android emulator
|
||||
const getApiUrl = () => {
|
||||
const url = BASE_API_URL;
|
||||
if (Platform.OS === 'android' && (url.includes('localhost') || url.includes('127.0.0.1'))) {
|
||||
return url.replace(/localhost|127\.0\.0\.1/, '10.0.2.2');
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const API_URL = getApiUrl().replace(/\/$/, ''); // Remove trailing slash
|
||||
|
||||
info(`Using backend URL (${Platform.OS}):`, API_URL);
|
||||
|
||||
// Storage keys for auth tokens
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: '@mana/appToken',
|
||||
REFRESH_TOKEN: '@mana/refreshToken',
|
||||
USER_EMAIL: '@mana/userEmail',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Mana Core Authentication Service
|
||||
* Handles all authentication operations through the NestJS backend
|
||||
* Uses @manacore/shared-auth for unified auth across all apps
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import {
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
isTokenValidLocally as sharedIsTokenValidLocally,
|
||||
getUserFromToken as sharedGetUserFromToken,
|
||||
decodeToken as sharedDecodeToken,
|
||||
} from '@manacore/shared-auth';
|
||||
import type { ManaUser, JwtPayload } from '../types/auth';
|
||||
|
||||
// Mana Core Auth URL
|
||||
const AUTH_URL = process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
// --- Adapters ---
|
||||
|
||||
const secureStoreAdapter = {
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setItem(key: string, value: unknown): Promise<void> {
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(value));
|
||||
},
|
||||
async removeItem(key: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
},
|
||||
};
|
||||
|
||||
const deviceAdapter = (() => {
|
||||
let deviceId: string | null = null;
|
||||
return {
|
||||
async getDeviceInfo() {
|
||||
if (!deviceId) {
|
||||
deviceId = await SecureStore.getItemAsync('@device/id');
|
||||
if (!deviceId) {
|
||||
deviceId = `rn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await SecureStore.setItemAsync('@device/id', deviceId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
deviceId,
|
||||
deviceName: `${Platform.OS} Device`,
|
||||
platform: 'react-native',
|
||||
deviceType: Platform.OS,
|
||||
};
|
||||
},
|
||||
async getStoredDeviceId() {
|
||||
return deviceId || (await SecureStore.getItemAsync('@device/id'));
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
const networkAdapter = {
|
||||
async isDeviceConnected() {
|
||||
return true;
|
||||
},
|
||||
async hasStableConnection() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// --- Initialize shared-auth ---
|
||||
|
||||
setStorageAdapter(secureStoreAdapter);
|
||||
setDeviceAdapter(deviceAdapter);
|
||||
setNetworkAdapter(networkAdapter);
|
||||
|
||||
const _sharedAuth = createAuthService({ baseUrl: AUTH_URL });
|
||||
const _sharedTokenManager = createTokenManager(_sharedAuth);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function toManaUser(
|
||||
userData: { id: string; email: string; role?: string } | null
|
||||
): ManaUser | null {
|
||||
if (!userData) return null;
|
||||
return {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
role: userData.role || 'user',
|
||||
name: userData.email?.split('@')[0] || 'User',
|
||||
};
|
||||
}
|
||||
|
||||
// --- Auth Service (backward-compatible API) ---
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
signIn: async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
|
||||
try {
|
||||
// Get device information
|
||||
const deviceInfo = await DeviceManager.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/auth/signin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
deviceInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Authentication failed');
|
||||
const result = await _sharedAuth.signIn(email, password);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Authentication failed' };
|
||||
}
|
||||
|
||||
const data: SignInResponse = await response.json();
|
||||
const { appToken, refreshToken } = data;
|
||||
|
||||
// Store tokens securely
|
||||
await Promise.all([
|
||||
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
|
||||
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
|
||||
safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, email),
|
||||
]);
|
||||
|
||||
// Extract user from JWT token
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
|
||||
return { success: true, user: user || undefined };
|
||||
const userData = await _sharedAuth.getUserFromToken();
|
||||
return { success: true, user: toManaUser(userData) || undefined };
|
||||
} catch (error) {
|
||||
logError('Sign in error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -89,50 +117,25 @@ export const authService = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email, password, and username
|
||||
*/
|
||||
signUp: async (
|
||||
email: string,
|
||||
password: string,
|
||||
username: string
|
||||
_username?: string
|
||||
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
|
||||
try {
|
||||
// Get device information
|
||||
const deviceInfo = await DeviceManager.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
deviceInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Sign up failed');
|
||||
// TODO: username is not supported by mana-core-auth signUp - add profile update after registration
|
||||
const result = await _sharedAuth.signUp(email, password);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Sign up failed' };
|
||||
}
|
||||
|
||||
const data: SignUpResponse = await response.json();
|
||||
const { appToken, refreshToken } = data;
|
||||
if (result.needsVerification) {
|
||||
return { success: true, error: 'Please verify your email before signing in.' };
|
||||
}
|
||||
|
||||
// Store tokens securely
|
||||
await Promise.all([
|
||||
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
|
||||
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
|
||||
safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, email),
|
||||
]);
|
||||
|
||||
// Extract user from JWT token
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
|
||||
return { success: true, user: user || undefined };
|
||||
// Auto sign-in after successful signup
|
||||
return authService.signIn(email, password);
|
||||
} catch (error) {
|
||||
logError('Sign up error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -140,371 +143,97 @@ export const authService = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out the current user
|
||||
*/
|
||||
signOut: async (): Promise<void> => {
|
||||
try {
|
||||
const refreshToken = await safeStorage.getItem<string>(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
|
||||
if (refreshToken) {
|
||||
// Notify backend of logout
|
||||
await fetch(`${API_URL}/v1/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch((err) => logError('Error logging out on server:', err));
|
||||
}
|
||||
|
||||
// Clear all auth data and device ID
|
||||
await authService.clearAuthStorage();
|
||||
|
||||
// Also clear device ID so a new one is generated on next login
|
||||
await DeviceManager.clearDeviceId();
|
||||
await _sharedAuth.signOut();
|
||||
} catch (error) {
|
||||
logError('Error signing out:', error);
|
||||
// Still clear local storage even if server request fails
|
||||
await authService.clearAuthStorage();
|
||||
try {
|
||||
await DeviceManager.clearDeviceId();
|
||||
} catch (e) {
|
||||
logError('Error clearing device ID:', e);
|
||||
}
|
||||
console.error('Sign out error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
* Updated signature to match TokenManager requirements
|
||||
*/
|
||||
resetPassword: async (email: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const result = await _sharedAuth.forgotPassword(email);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to send reset email' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getAppToken: async (): Promise<string | null> => {
|
||||
return await _sharedAuth.getAppToken();
|
||||
},
|
||||
|
||||
getRefreshToken: async (): Promise<string | null> => {
|
||||
return await _sharedAuth.getRefreshToken();
|
||||
},
|
||||
|
||||
refreshTokens: async (
|
||||
currentRefreshToken: string
|
||||
_currentRefreshToken: string
|
||||
): Promise<{
|
||||
appToken: string;
|
||||
refreshToken: string;
|
||||
userData?: { id: string; email: string; role: string } | null;
|
||||
}> => {
|
||||
try {
|
||||
// Check if device ID has changed (which would invalidate the refresh token)
|
||||
const storedDeviceId = await DeviceManager.getStoredDeviceId();
|
||||
const deviceInfo = await DeviceManager.getDeviceInfo();
|
||||
|
||||
if (storedDeviceId && deviceInfo.deviceId !== storedDeviceId) {
|
||||
logError('Device ID mismatch detected during token refresh', {
|
||||
stored: storedDeviceId,
|
||||
current: deviceInfo.deviceId,
|
||||
});
|
||||
|
||||
throw new Error('Device ID has changed. Please sign in again.');
|
||||
}
|
||||
|
||||
// Debug log device info
|
||||
debug('Device info for token refresh:', deviceInfo);
|
||||
debug('Refreshing tokens');
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
debug('Token refresh failed with status:', response.status, errorData);
|
||||
|
||||
// If refresh fails with 401, it might be due to old tokens without device info
|
||||
if (response.status === 401 && errorData.message === 'Invalid refresh token') {
|
||||
debug('Refresh token invalid - likely due to old token without device info');
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
debug('Token refresh response:', responseData);
|
||||
|
||||
// Note: If grace period was used, backend returns the previously generated token
|
||||
// This is normal and expected - the frontend should always save whatever token is returned
|
||||
const { appToken, refreshToken, deviceId, grace_period_used, grace_expires_at } =
|
||||
responseData;
|
||||
|
||||
// Log grace period usage if present
|
||||
if (grace_period_used) {
|
||||
debug('Token refresh used grace period - returning previously rotated token');
|
||||
debug('Grace period expires at:', grace_expires_at);
|
||||
} else if (grace_expires_at) {
|
||||
debug('Token refreshed with new grace period until:', grace_expires_at);
|
||||
}
|
||||
|
||||
debug(
|
||||
'Extracted tokens - appToken:',
|
||||
!!appToken,
|
||||
'refreshToken:',
|
||||
!!refreshToken,
|
||||
'deviceId:',
|
||||
deviceId
|
||||
);
|
||||
|
||||
// Validate that we received tokens
|
||||
if (!appToken || !refreshToken) {
|
||||
logError('Missing tokens in refresh response:', {
|
||||
appToken: !!appToken,
|
||||
refreshToken: !!refreshToken,
|
||||
});
|
||||
throw new Error('Invalid response from token refresh - missing tokens');
|
||||
}
|
||||
|
||||
debug('Tokens refreshed successfully');
|
||||
|
||||
// Store new tokens in local storage
|
||||
await safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken);
|
||||
await safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
|
||||
// Extract user data from new token
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
const userData = user ? { id: user.id, email: user.email, role: user.role } : null;
|
||||
|
||||
return { appToken, refreshToken, userData };
|
||||
} catch (error) {
|
||||
debug('Token refresh error:', error);
|
||||
throw error; // TokenManager will handle error classification
|
||||
}
|
||||
// shared-auth handles refresh internally - just trigger it
|
||||
await _sharedAuth.refreshTokens();
|
||||
const appToken = (await _sharedAuth.getAppToken()) || '';
|
||||
const refreshToken = (await _sharedAuth.getRefreshToken()) || '';
|
||||
const user = appToken ? sharedGetUserFromToken(appToken) : null;
|
||||
return {
|
||||
appToken,
|
||||
refreshToken,
|
||||
userData: user ? { id: user.id, email: user.email, role: user.role || 'user' } : null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Legacy refreshToken method (for backward compatibility)
|
||||
*/
|
||||
refreshToken: async (): Promise<AuthTokens | null> => {
|
||||
try {
|
||||
const currentRefreshToken = await safeStorage.getItem<string>(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
|
||||
if (!currentRefreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const result = await authService.refreshTokens(currentRefreshToken);
|
||||
return { appToken: result.appToken, refreshToken: result.refreshToken };
|
||||
} catch (error) {
|
||||
logError('Token refresh error:', error);
|
||||
await authService.clearAuthStorage();
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset password via email
|
||||
*/
|
||||
resetPassword: async (email: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to send reset email');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logError('Password reset error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current app token
|
||||
*/
|
||||
getAppToken: async (): Promise<string | null> => {
|
||||
return await safeStorage.getItem<string>(STORAGE_KEYS.APP_TOKEN);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current refresh token
|
||||
*/
|
||||
getRefreshToken: async (): Promise<string | null> => {
|
||||
return await safeStorage.getItem<string>(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all auth storage
|
||||
*/
|
||||
clearAuthStorage: async (): Promise<void> => {
|
||||
// Clear Manadeck auth keys
|
||||
await Promise.all(Object.values(STORAGE_KEYS).map((key) => safeStorage.removeItem(key)));
|
||||
|
||||
// Also clear any legacy Memoro/Mana auth keys if they exist
|
||||
const legacyKeys = [
|
||||
'appToken',
|
||||
'refreshToken',
|
||||
'manaToken',
|
||||
'deviceId',
|
||||
'@auth/appToken',
|
||||
'@auth/refreshToken',
|
||||
'@auth/userEmail',
|
||||
];
|
||||
await Promise.all(legacyKeys.map((key) => safeStorage.removeItem(key)));
|
||||
await _sharedAuth.signOut();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated (has valid token)
|
||||
*/
|
||||
isAuthenticated: async (): Promise<boolean> => {
|
||||
const appToken = await safeStorage.getItem<string>(STORAGE_KEYS.APP_TOKEN);
|
||||
if (!appToken) return false;
|
||||
|
||||
// Check if token is expired
|
||||
try {
|
||||
const payload = authService.decodeToken(appToken);
|
||||
if (!payload) return false;
|
||||
|
||||
// Check expiration (with 30 second buffer)
|
||||
return Date.now() < payload.exp * 1000 - 30000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return await _sharedAuth.isAuthenticated();
|
||||
},
|
||||
|
||||
/**
|
||||
* Decode JWT token payload
|
||||
*/
|
||||
decodeToken: (token: string): JwtPayload | null => {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
if (!base64Url) return null;
|
||||
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
|
||||
return payload as JwtPayload;
|
||||
} catch (error) {
|
||||
logError('Token decode error:', error);
|
||||
return null;
|
||||
}
|
||||
return sharedDecodeToken(token) as JwtPayload | null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract user information from JWT token
|
||||
*/
|
||||
getUserFromToken: (token: string): ManaUser | null => {
|
||||
try {
|
||||
const payload = authService.decodeToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role,
|
||||
name: payload.email?.split('@')[0] || 'User',
|
||||
organizationId: payload.app_settings?.b2b?.organizationId,
|
||||
metadata: payload.app_settings,
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Error extracting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
const userData = sharedGetUserFromToken(token);
|
||||
return toManaUser(userData);
|
||||
},
|
||||
|
||||
isTokenValidLocally: (token: string): boolean => {
|
||||
return sharedIsTokenValidLocally(token);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user credits balance
|
||||
*/
|
||||
getCredits: async (): Promise<number | null> => {
|
||||
try {
|
||||
const token = await authService.getAppToken();
|
||||
if (!token) return null;
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/auth/credits`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch credits');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.credits || 0;
|
||||
} catch (error) {
|
||||
logError('Error fetching credits:', error);
|
||||
const credits = await _sharedAuth.getUserCredits();
|
||||
return credits?.credits ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if token is valid locally (without HTTP calls)
|
||||
* Used by TokenManager to avoid unnecessary network requests
|
||||
* @param token JWT token to validate
|
||||
* @returns True if token exists and is not expired
|
||||
*/
|
||||
isTokenValidLocally: (token: string): boolean => {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
|
||||
// Check if token is expired (with 10 second buffer for safety)
|
||||
const bufferTime = 10 * 1000; // 10 seconds in milliseconds
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
|
||||
return currentTime < expiryTime - bufferTime;
|
||||
} catch (e) {
|
||||
console.debug('Error validating token locally:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google
|
||||
*/
|
||||
signInWithGoogle: async (
|
||||
idToken: string
|
||||
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
|
||||
try {
|
||||
// Get device information
|
||||
const deviceInfo = await DeviceManager.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/auth/google-signin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: idToken,
|
||||
deviceInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Google sign-in failed');
|
||||
const result = await _sharedAuth.signInWithGoogle(idToken);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Google sign-in failed' };
|
||||
}
|
||||
|
||||
const data: SignInResponse = await response.json();
|
||||
const { appToken, refreshToken } = data;
|
||||
|
||||
// Store tokens securely
|
||||
await Promise.all([
|
||||
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
|
||||
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
|
||||
]);
|
||||
|
||||
// Extract user from JWT token
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
if (user?.email) {
|
||||
await safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, user.email);
|
||||
}
|
||||
|
||||
return { success: true, user: user || undefined };
|
||||
const userData = await _sharedAuth.getUserFromToken();
|
||||
return { success: true, user: toManaUser(userData) || undefined };
|
||||
} catch (error) {
|
||||
logError('Google sign-in error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -512,48 +241,17 @@ export const authService = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple
|
||||
*/
|
||||
signInWithApple: async (
|
||||
identityToken: string
|
||||
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
|
||||
try {
|
||||
// Get device information
|
||||
const deviceInfo = await DeviceManager.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/auth/apple-signin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: identityToken,
|
||||
deviceInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Apple sign-in failed');
|
||||
const result = await _sharedAuth.signInWithApple(identityToken);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Apple sign-in failed' };
|
||||
}
|
||||
|
||||
const data: SignInResponse = await response.json();
|
||||
const { appToken, refreshToken } = data;
|
||||
|
||||
// Store tokens securely
|
||||
await Promise.all([
|
||||
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
|
||||
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
|
||||
]);
|
||||
|
||||
// Extract user from JWT token
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
if (user?.email) {
|
||||
await safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, user.email);
|
||||
}
|
||||
|
||||
return { success: true, user: user || undefined };
|
||||
const userData = await _sharedAuth.getUserFromToken();
|
||||
return { success: true, user: toManaUser(userData) || undefined };
|
||||
} catch (error) {
|
||||
logError('Apple sign-in error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -561,9 +259,25 @@ export const authService = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Event callback for token refresh
|
||||
* Set this from AuthContext to receive user data updates after token refresh
|
||||
*/
|
||||
onTokenRefresh: null as ((userData: { id: string; email: string; role: string }) => void) | null,
|
||||
};
|
||||
|
||||
// Wire up token refresh callback
|
||||
_sharedAuth.onTokenRefresh = async () => {
|
||||
if (authService.onTokenRefresh) {
|
||||
const token = await _sharedAuth.getAppToken();
|
||||
if (token) {
|
||||
const user = sharedGetUserFromToken(token);
|
||||
if (user) {
|
||||
authService.onTokenRefresh({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role || 'user',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Export shared-auth instances for direct use
|
||||
export { _sharedAuth, _sharedTokenManager };
|
||||
|
|
|
|||
|
|
@ -1,620 +1,51 @@
|
|||
import { authService } from './authService';
|
||||
import { debug, info, warn, error as logError } from '../utils/logger';
|
||||
|
||||
// Token state management
|
||||
export enum TokenState {
|
||||
IDLE = 'idle',
|
||||
REFRESHING = 'refreshing',
|
||||
EXPIRED = 'expired',
|
||||
VALID = 'valid',
|
||||
}
|
||||
|
||||
// Request queue item
|
||||
interface QueuedRequest {
|
||||
id: string;
|
||||
input: RequestInfo | URL;
|
||||
init?: RequestInit;
|
||||
resolve: (value: Response) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Token refresh result
|
||||
interface TokenRefreshResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Observer for token state changes
|
||||
type TokenStateObserver = (state: TokenState, token?: string) => void;
|
||||
|
||||
/**
|
||||
* Centralized token manager to handle all authentication token operations
|
||||
* and eliminate race conditions in token refresh
|
||||
* Token Manager - wraps @manacore/shared-auth TokenManager
|
||||
* Maintains backward-compatible API for existing consumers
|
||||
*/
|
||||
class TokenManager {
|
||||
private state: TokenState = TokenState.IDLE;
|
||||
private refreshPromise: Promise<TokenRefreshResult> | null = null;
|
||||
private requestQueue: QueuedRequest[] = [];
|
||||
private observers: Set<TokenStateObserver> = new Set();
|
||||
|
||||
// Configuration
|
||||
private readonly MAX_QUEUE_SIZE = 50;
|
||||
private readonly QUEUE_TIMEOUT_MS = 30000; // 30 seconds
|
||||
private readonly MAX_REFRESH_ATTEMPTS = 3;
|
||||
private refreshAttempts = 0;
|
||||
private lastRefreshTime = 0;
|
||||
private readonly REFRESH_COOLDOWN_MS = 5000; // 5 second cooldown
|
||||
import { _sharedTokenManager } from './authService';
|
||||
export { TokenState } from '@manacore/shared-auth';
|
||||
|
||||
private static instance: TokenManager;
|
||||
type TokenStateObserver = (state: string, token?: string | null) => void;
|
||||
|
||||
private constructor() {
|
||||
// Start with initial state check
|
||||
this.checkInitialState();
|
||||
}
|
||||
|
||||
static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
export const tokenManager = {
|
||||
/**
|
||||
* Get a valid access token, automatically refreshing if needed
|
||||
*/
|
||||
getValidToken: (): Promise<string | null> => {
|
||||
return _sharedTokenManager.getValidToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe to token state changes
|
||||
*/
|
||||
subscribe(observer: TokenStateObserver): () => void {
|
||||
this.observers.add(observer);
|
||||
return () => this.observers.delete(observer);
|
||||
}
|
||||
subscribe: (callback: TokenStateObserver) => {
|
||||
return _sharedTokenManager.subscribe(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Notify all observers of state changes
|
||||
* Clear all tokens (triggers sign-out)
|
||||
*/
|
||||
private notifyObservers(state: TokenState, token?: string): void {
|
||||
this.observers.forEach((observer) => {
|
||||
try {
|
||||
observer(state, token);
|
||||
} catch (err) {
|
||||
debug('Error in token state observer:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
clearTokens: async (): Promise<void> => {
|
||||
const { authService } = await import('./authService');
|
||||
await authService.signOut();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the current token state
|
||||
* Handle a 401 response by refreshing the token and retrying the request
|
||||
*/
|
||||
private setState(newState: TokenState, token?: string): void {
|
||||
if (this.state !== newState) {
|
||||
debug(`TokenManager: State transition ${this.state} -> ${newState}`);
|
||||
this.state = newState;
|
||||
this.notifyObservers(newState, token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token state
|
||||
*/
|
||||
getState(): TokenState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check initial token state on startup
|
||||
*/
|
||||
private async checkInitialState(): Promise<void> {
|
||||
try {
|
||||
const token = await authService.getAppToken();
|
||||
if (!token) {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
this.setState(TokenState.VALID, token);
|
||||
} else {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
}
|
||||
} catch (error) {
|
||||
debug('Error checking initial token state:', error);
|
||||
this.setState(TokenState.EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid token, refreshing if necessary
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const currentToken = await authService.getAppToken();
|
||||
|
||||
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
||||
this.setState(TokenState.VALID, currentToken);
|
||||
return currentToken;
|
||||
handle401Response: async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const token = await _sharedTokenManager.getValidToken();
|
||||
if (!token) {
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
// If there's no token at all (fresh install), don't attempt refresh
|
||||
if (!currentToken) {
|
||||
debug('TokenManager: No token available, skipping refresh (fresh install)');
|
||||
this.setState(TokenState.EXPIRED);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token is expired or invalid, attempt refresh
|
||||
debug('TokenManager: Current token invalid, attempting refresh...');
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
debug('TokenManager: Token refresh successful in getValidToken');
|
||||
return refreshResult.token;
|
||||
} else {
|
||||
debug('TokenManager: Token refresh failed in getValidToken:', refreshResult.error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 401 response by either refreshing token or queueing request
|
||||
*/
|
||||
async handle401Response(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
// Check if we're already refreshing
|
||||
if (this.state === TokenState.REFRESHING && this.refreshPromise) {
|
||||
return this.queueRequest(input, init);
|
||||
}
|
||||
|
||||
// Start token refresh
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
// Retry the request with new token
|
||||
return this.retryRequestWithToken(input, init, refreshResult.token);
|
||||
} else {
|
||||
// Check if we're offline before throwing error
|
||||
if (refreshResult.error === 'offline') {
|
||||
debug('TokenManager: Offline during 401 handling, throwing network error');
|
||||
throw new Error('Network request failed: Device offline');
|
||||
}
|
||||
// Refresh failed, propagate error
|
||||
throw new Error(refreshResult.error || 'Token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a request during token refresh
|
||||
*/
|
||||
private async queueRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check queue size limit
|
||||
if (this.requestQueue.length >= this.MAX_QUEUE_SIZE) {
|
||||
reject(new Error('Request queue full'));
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItem: QueuedRequest = {
|
||||
id: Math.random().toString(36).substring(2, 11),
|
||||
input,
|
||||
init,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.requestQueue.push(queueItem);
|
||||
|
||||
// Set timeout for queued request
|
||||
setTimeout(() => {
|
||||
this.removeFromQueue(queueItem.id);
|
||||
reject(new Error('Queued request timeout'));
|
||||
}, this.QUEUE_TIMEOUT_MS);
|
||||
|
||||
debug(
|
||||
`TokenManager: Queued request ${queueItem.id}, queue size: ${this.requestQueue.length}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove request from queue by ID
|
||||
*/
|
||||
private removeFromQueue(requestId: string): void {
|
||||
const index = this.requestQueue.findIndex((item) => item.id === requestId);
|
||||
if (index !== -1) {
|
||||
this.requestQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the authentication token with progressive backoff retry logic
|
||||
*/
|
||||
private async refreshToken(): Promise<TokenRefreshResult> {
|
||||
// Check cooldown to prevent rapid successive refresh attempts
|
||||
const now = Date.now();
|
||||
if (now - this.lastRefreshTime < this.REFRESH_COOLDOWN_MS) {
|
||||
debug('TokenManager: Refresh cooldown active, skipping refresh');
|
||||
return { success: false, error: 'Refresh cooldown active' };
|
||||
}
|
||||
|
||||
// Check max attempts
|
||||
if (this.refreshAttempts >= this.MAX_REFRESH_ATTEMPTS) {
|
||||
debug('TokenManager: Max refresh attempts reached');
|
||||
await this.handleRefreshFailure();
|
||||
return { success: false, error: 'Max refresh attempts reached' };
|
||||
}
|
||||
|
||||
// If already refreshing, wait for existing promise
|
||||
if (this.refreshPromise) {
|
||||
debug('TokenManager: Waiting for existing refresh to complete');
|
||||
return await this.refreshPromise;
|
||||
}
|
||||
|
||||
this.setState(TokenState.REFRESHING);
|
||||
this.lastRefreshTime = now;
|
||||
|
||||
// Use enhanced refresh with retry logic
|
||||
this.refreshPromise = this.performTokenRefreshWithRetry();
|
||||
|
||||
try {
|
||||
const result = await this.refreshPromise;
|
||||
|
||||
if (result.success) {
|
||||
this.refreshAttempts = 0; // Reset on success
|
||||
this.setState(TokenState.VALID, result.token);
|
||||
await this.processQueuedRequests(result.token!);
|
||||
} else {
|
||||
this.refreshAttempts++;
|
||||
this.setState(TokenState.EXPIRED);
|
||||
await this.rejectQueuedRequests(result.error || 'Token refresh failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced token refresh with progressive backoff for network issues
|
||||
*/
|
||||
private async performTokenRefreshWithRetry(): Promise<TokenRefreshResult> {
|
||||
const retryDelays = [0, 1000, 2000, 5000]; // Progressive backoff: 0ms, 1s, 2s, 5s
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
||||
try {
|
||||
// Wait for retry delay (except first attempt)
|
||||
if (retryDelays[attempt] > 0) {
|
||||
debug(
|
||||
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
|
||||
}
|
||||
|
||||
const result = await this.performTokenRefresh();
|
||||
|
||||
if (result.success) {
|
||||
if (attempt > 0) {
|
||||
debug(`TokenManager: Token refresh succeeded on attempt ${attempt + 1}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle specific server-side errors that shouldn't be retried
|
||||
if (
|
||||
result.error === 'invalid_token' ||
|
||||
result.error === 'token_expired' ||
|
||||
result.error === 'invalid_token_state' ||
|
||||
result.error === 'token_collision' ||
|
||||
(result.error && result.error.includes('Device ID has changed'))
|
||||
) {
|
||||
debug('TokenManager: Non-retryable error:', result.error);
|
||||
return result; // Don't retry permanent auth errors
|
||||
}
|
||||
|
||||
// Handle offline state - don't count as failure
|
||||
if (result.error === 'offline') {
|
||||
debug('TokenManager: Device offline, preserving auth state');
|
||||
return { success: false, error: 'offline' }; // Return without clearing tokens
|
||||
}
|
||||
|
||||
// Handle unstable connection - should retry with longer delay
|
||||
if (result.error === 'unstable_connection') {
|
||||
debug('TokenManager: Connection unstable, will retry with longer delay');
|
||||
// Use a longer delay for unstable connections
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
// Continue to next retry attempt
|
||||
}
|
||||
|
||||
// Handle refresh_in_progress or rotation_in_progress with shorter delay
|
||||
if (result.error === 'refresh_in_progress' || result.error === 'rotation_in_progress') {
|
||||
debug('TokenManager: Token rotation in progress, waiting...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1s for other refresh
|
||||
// Try one more time after waiting
|
||||
const retryResult = await this.performTokenRefresh();
|
||||
if (retryResult.success) {
|
||||
return retryResult;
|
||||
}
|
||||
}
|
||||
|
||||
lastError = new Error(result.error || 'Token refresh failed');
|
||||
|
||||
// If this is the last attempt, return the error
|
||||
if (attempt === retryDelays.length - 1) {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Check if this is a recoverable network error
|
||||
const isRecoverable = this.isRecoverableError(error);
|
||||
|
||||
if (!isRecoverable) {
|
||||
debug('TokenManager: Non-recoverable error, stopping retries:', error);
|
||||
break; // Don't retry non-network errors
|
||||
}
|
||||
|
||||
debug(`TokenManager: Network error on attempt ${attempt + 1}, will retry:`, error);
|
||||
|
||||
// If this is the last attempt, break out
|
||||
if (attempt === retryDelays.length - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
debug('TokenManager: All retry attempts failed');
|
||||
return {
|
||||
success: false,
|
||||
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual token refresh operation
|
||||
*/
|
||||
private async performTokenRefresh(): Promise<TokenRefreshResult> {
|
||||
try {
|
||||
debug('TokenManager: Starting token refresh');
|
||||
|
||||
// Check network status first - use stable connection check for critical operations
|
||||
const { hasStableConnection, isDeviceConnected } = await import('~/utils/networkErrorUtils');
|
||||
|
||||
// First check basic connectivity
|
||||
const isOnline = await isDeviceConnected();
|
||||
|
||||
if (!isOnline) {
|
||||
debug('TokenManager: Device offline, skipping refresh');
|
||||
// Return success with current token if it's not expired locally
|
||||
const currentToken = await authService.getAppToken();
|
||||
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
||||
return { success: true, token: currentToken };
|
||||
}
|
||||
return { success: false, error: 'offline' };
|
||||
}
|
||||
|
||||
// For token refresh, ensure we have a stable connection
|
||||
const isStable = await hasStableConnection();
|
||||
if (!isStable) {
|
||||
debug('TokenManager: Connection not stable yet, will retry');
|
||||
// Return a specific error that indicates we should retry
|
||||
return { success: false, error: 'unstable_connection' };
|
||||
}
|
||||
|
||||
const refreshToken = await authService.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const refreshResult = await authService.refreshTokens(refreshToken);
|
||||
const { appToken, refreshToken: newRefreshToken, userData } = refreshResult;
|
||||
|
||||
if (!appToken || !newRefreshToken) {
|
||||
throw new Error('Invalid tokens received from refresh');
|
||||
}
|
||||
|
||||
// Note: authService.refreshTokens() already saves tokens to storage
|
||||
// No need to call updateTokens() again - this was causing race conditions
|
||||
|
||||
// If we have user data from the refresh, notify via the callback
|
||||
if (userData && authService.onTokenRefresh) {
|
||||
debug('TokenManager: Notifying auth context with fresh user data');
|
||||
authService.onTokenRefresh(userData);
|
||||
}
|
||||
|
||||
debug('TokenManager: Token refresh successful');
|
||||
return { success: true, token: appToken };
|
||||
} catch (error) {
|
||||
debug('TokenManager: Token refresh failed:', error);
|
||||
|
||||
// Determine if this is a recoverable error
|
||||
const isRecoverable = this.isRecoverableError(error);
|
||||
|
||||
if (!isRecoverable) {
|
||||
await this.handleRefreshFailure();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown refresh error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable (network issues vs auth failures)
|
||||
*/
|
||||
private isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const networkErrors = [
|
||||
'network',
|
||||
'Network',
|
||||
'fetch',
|
||||
'connection',
|
||||
'timeout',
|
||||
'Failed to fetch',
|
||||
'NetworkError',
|
||||
'TypeError',
|
||||
'ERR_NETWORK',
|
||||
'ERR_INTERNET_DISCONNECTED',
|
||||
'ECONNREFUSED',
|
||||
'ENOTFOUND',
|
||||
'ETIMEDOUT',
|
||||
'Unable to resolve host',
|
||||
'Request failed',
|
||||
];
|
||||
|
||||
const authErrors = [
|
||||
'401',
|
||||
'403',
|
||||
'Unauthorized',
|
||||
'Forbidden',
|
||||
'Invalid token',
|
||||
'Token expired',
|
||||
'jwt expired',
|
||||
'jwt malformed',
|
||||
];
|
||||
|
||||
const errorString = `${error.message} ${error.name}`.toLowerCase();
|
||||
|
||||
const isNetworkError = networkErrors.some((keyword) =>
|
||||
errorString.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const isAuthError = authErrors.some((keyword) => errorString.includes(keyword.toLowerCase()));
|
||||
|
||||
// Network errors are recoverable unless they also contain auth errors
|
||||
return isNetworkError && !isAuthError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle permanent refresh failure
|
||||
*/
|
||||
private async handleRefreshFailure(): Promise<void> {
|
||||
debug('TokenManager: Handling permanent refresh failure');
|
||||
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
this.setState(TokenState.EXPIRED);
|
||||
|
||||
// Don't automatically redirect here - let the AuthContext handle logout
|
||||
// The AuthContext will handle the logout flow properly
|
||||
} catch (error) {
|
||||
debug('Error in handleRefreshFailure:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should attempt token refresh (has valid refresh token)
|
||||
*/
|
||||
async canAttemptRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const refreshToken = await authService.getRefreshToken();
|
||||
return !!refreshToken;
|
||||
} catch (error) {
|
||||
debug('Error checking refresh token availability:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all queued requests with the new token
|
||||
*/
|
||||
private async processQueuedRequests(token: string): Promise<void> {
|
||||
debug(`TokenManager: Processing ${this.requestQueue.length} queued requests`);
|
||||
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
const response = await this.retryRequestWithToken(request.input, request.init, token);
|
||||
request.resolve(response);
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all queued requests with error
|
||||
*/
|
||||
private async rejectQueuedRequests(error: string): Promise<void> {
|
||||
debug(`TokenManager: Rejecting ${this.requestQueue.length} queued requests`);
|
||||
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a request with a new token
|
||||
*/
|
||||
private async retryRequestWithToken(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
token: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the token manager state (for testing or logout)
|
||||
*/
|
||||
reset(): void {
|
||||
this.state = TokenState.IDLE;
|
||||
this.refreshPromise = null;
|
||||
this.refreshAttempts = 0;
|
||||
this.lastRefreshTime = 0;
|
||||
|
||||
// Reject all queued requests
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error('Token manager reset'));
|
||||
}
|
||||
|
||||
debug('TokenManager: Reset completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tokens and reset state (for logout)
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
// Skip EXPIRED state transition during logout to prevent observer loops
|
||||
// Go directly to reset which sets IDLE state
|
||||
this.reset();
|
||||
} catch (error) {
|
||||
debug('Error clearing tokens:', error);
|
||||
// On error, still reset to ensure clean state
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue status for debugging
|
||||
*/
|
||||
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
|
||||
return {
|
||||
size: this.requestQueue.length,
|
||||
state: this.state,
|
||||
refreshAttempts: this.refreshAttempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export default tokenManager;
|
||||
return fetch(input, { ...init, headers });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { create } from 'zustand';
|
||||
import { authService } from '../services/authService';
|
||||
import { tokenManager, TokenState } from '../services/tokenManager';
|
||||
import type { ManaUser } from '../types/auth';
|
||||
|
||||
interface AuthState {
|
||||
|
|
@ -37,7 +36,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
|
||||
// Register callback for token refresh to update user data
|
||||
authService.onTokenRefresh = (userData) => {
|
||||
console.debug('Token refreshed, updating user data in store');
|
||||
const currentUser = get().user;
|
||||
if (currentUser) {
|
||||
set({
|
||||
|
|
@ -51,45 +49,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
}
|
||||
};
|
||||
|
||||
// Subscribe to TokenManager state changes
|
||||
tokenManager.subscribe((state, token) => {
|
||||
console.debug(`TokenManager state changed: ${state}`);
|
||||
if (state === TokenState.EXPIRED) {
|
||||
// Token expired, clear user
|
||||
const currentUser = get().user;
|
||||
if (currentUser) {
|
||||
console.debug('Token expired, clearing user from store');
|
||||
set({ user: null });
|
||||
// Check if user is authenticated
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
|
||||
if (authenticated) {
|
||||
const appToken = await authService.getAppToken();
|
||||
if (appToken) {
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
if (user) {
|
||||
set({ user, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user is authenticated via Mana Core
|
||||
const appToken = await authService.getAppToken();
|
||||
|
||||
if (appToken && authService.isTokenValidLocally(appToken)) {
|
||||
// Token exists and is valid locally
|
||||
const user = authService.getUserFromToken(appToken);
|
||||
if (user) {
|
||||
set({
|
||||
user,
|
||||
isInitialized: true,
|
||||
});
|
||||
} else {
|
||||
set({ isInitialized: true });
|
||||
}
|
||||
} else if (appToken) {
|
||||
// Token exists but might be expired - let TokenManager handle refresh
|
||||
console.debug('Token exists but may be expired, will attempt refresh on first API call');
|
||||
set({ isInitialized: true });
|
||||
} else {
|
||||
// No token at all
|
||||
await authService.clearAuthStorage();
|
||||
set({ isInitialized: true });
|
||||
}
|
||||
|
||||
set({ isInitialized: true });
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
// Don't throw - continue with unauthenticated state
|
||||
set({ error: null, isInitialized: true, user: null });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
|
|
@ -106,9 +82,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
throw new Error(result.error || 'Failed to sign in');
|
||||
}
|
||||
|
||||
set({
|
||||
user: result.user || null,
|
||||
});
|
||||
set({ user: result.user || null });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to sign in' });
|
||||
throw error;
|
||||
|
|
@ -127,9 +101,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
throw new Error(result.error || 'Failed to sign up');
|
||||
}
|
||||
|
||||
set({
|
||||
user: result.user || null,
|
||||
});
|
||||
set({ user: result.user || null });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to sign up' });
|
||||
throw error;
|
||||
|
|
@ -148,9 +120,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
throw new Error(result.error || 'Google sign-in failed');
|
||||
}
|
||||
|
||||
set({
|
||||
user: result.user || null,
|
||||
});
|
||||
set({ user: result.user || null });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Google sign-in failed' });
|
||||
throw error;
|
||||
|
|
@ -169,9 +139,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
throw new Error(result.error || 'Apple sign-in failed');
|
||||
}
|
||||
|
||||
set({
|
||||
user: result.user || null,
|
||||
});
|
||||
set({ user: result.user || null });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Apple sign-in failed' });
|
||||
throw error;
|
||||
|
|
@ -186,12 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
|
||||
await authService.signOut();
|
||||
|
||||
// Clear TokenManager state
|
||||
await tokenManager.clearTokens();
|
||||
|
||||
set({
|
||||
user: null,
|
||||
});
|
||||
set({ user: null });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to sign out' });
|
||||
throw error;
|
||||
|
|
@ -224,11 +187,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||
const user = get().user;
|
||||
if (!user) throw new Error('No user logged in');
|
||||
|
||||
// TODO: Implement profile update via backend API
|
||||
// For now, profiles are managed via Mana Core Auth
|
||||
console.warn('Profile update not yet implemented via backend API');
|
||||
// TODO: Implement profile update via mana-core-auth API
|
||||
console.warn('Profile update not yet implemented via mana-core-auth API');
|
||||
|
||||
// Update local user state with the new values
|
||||
set({
|
||||
user: {
|
||||
...user,
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
import * as Device from 'expo-device';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import Constants from 'expo-constants';
|
||||
import { Platform } from 'react-native';
|
||||
import type { DeviceInfo } from '../types/auth';
|
||||
|
||||
// Storage wrapper that uses localStorage on web and SecureStore on native
|
||||
const storage = {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
try {
|
||||
if (Platform.OS === 'web') {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
return await SecureStore.getItemAsync(key);
|
||||
} catch (error) {
|
||||
console.error('Error reading from storage:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
if (Platform.OS === 'web') {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error writing to storage:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteItem(key: string): Promise<void> {
|
||||
try {
|
||||
if (Platform.OS === 'web') {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting from storage:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export class DeviceManager {
|
||||
private static DEVICE_ID_KEY = 'mana_device_id';
|
||||
|
||||
static async getDeviceInfo(): Promise<DeviceInfo> {
|
||||
// Get or generate device ID
|
||||
let deviceId = await storage.getItem(this.DEVICE_ID_KEY);
|
||||
|
||||
// Debug log for loaded device ID
|
||||
if (deviceId) {
|
||||
console.debug(`Loaded existing device ID: ${deviceId}`);
|
||||
}
|
||||
|
||||
if (!deviceId) {
|
||||
// Generate a new UUID
|
||||
deviceId = await this.generateDeviceId();
|
||||
|
||||
// Try to store it persistently with retry logic
|
||||
let stored = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await storage.setItem(this.DEVICE_ID_KEY, deviceId);
|
||||
|
||||
// Verify it was actually stored
|
||||
const verifiedId = await storage.getItem(this.DEVICE_ID_KEY);
|
||||
if (verifiedId === deviceId) {
|
||||
console.debug(`Device ID stored successfully on attempt ${attempt}`);
|
||||
stored = true;
|
||||
break;
|
||||
} else {
|
||||
console.warn(`Device ID verification failed on attempt ${attempt}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to store device ID on attempt ${attempt}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
console.error('Failed to persist device ID after 3 attempts - using session-only ID');
|
||||
// The ID will work for this session but may change on app restart
|
||||
} else {
|
||||
console.debug('New device ID created and stored successfully');
|
||||
}
|
||||
}
|
||||
|
||||
// Always validate we have a device ID
|
||||
if (!deviceId) {
|
||||
throw new Error('Unable to generate device identifier');
|
||||
}
|
||||
|
||||
// Get device name
|
||||
const deviceName = await this.getDeviceName();
|
||||
|
||||
const deviceInfo: DeviceInfo = {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: Platform.OS === 'web' ? 'web' : (Platform.OS as 'ios' | 'android'),
|
||||
userAgent: this.getUserAgent(),
|
||||
};
|
||||
|
||||
console.debug('Device info:', {
|
||||
deviceId: deviceId.substring(0, 8) + '...',
|
||||
deviceName,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
});
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
private static async getDeviceName(): Promise<string> {
|
||||
if (Device.deviceName) {
|
||||
return Device.deviceName;
|
||||
}
|
||||
|
||||
// Fallback device names
|
||||
const deviceModel = Device.modelName || 'Unknown';
|
||||
const osName = Device.osName || Platform.OS;
|
||||
return `${deviceModel} (${osName})`;
|
||||
}
|
||||
|
||||
private static getUserAgent(): string {
|
||||
// For Expo SDK 51+, use expoConfig instead of manifest
|
||||
const appName = Constants.expoConfig?.name || Constants.manifest?.name || 'Manadeck';
|
||||
const appVersion = Constants.expoConfig?.version || Constants.manifest?.version || '1.0.0';
|
||||
const osName = Device.osName || Platform.OS;
|
||||
const osVersion = Device.osVersion || 'Unknown';
|
||||
const modelName = Device.modelName || 'Unknown Device';
|
||||
|
||||
return `${appName}/${appVersion} (${osName} ${osVersion}; ${modelName})`;
|
||||
}
|
||||
|
||||
private static async generateDeviceId(): Promise<string> {
|
||||
// Always generate a stable UUID instead of using unreliable platform-specific IDs
|
||||
// Constants.deviceId is deprecated and can change between app launches/installs
|
||||
return this.generateUUID();
|
||||
}
|
||||
|
||||
private static generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
static async clearDeviceId(): Promise<void> {
|
||||
await storage.deleteItem(this.DEVICE_ID_KEY);
|
||||
}
|
||||
|
||||
static async getStoredDeviceId(): Promise<string | null> {
|
||||
try {
|
||||
return await storage.getItem(this.DEVICE_ID_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving stored device ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for AsyncStorage with error handling
|
||||
*/
|
||||
export const safeStorage = {
|
||||
/**
|
||||
* Store a value in AsyncStorage
|
||||
* @param key Storage key
|
||||
* @param value Value to store (automatically converted to JSON)
|
||||
*/
|
||||
setItem: async <T>(key: string, value: T): Promise<void> => {
|
||||
try {
|
||||
// Skip saving if value is undefined
|
||||
if (value === undefined) {
|
||||
console.warn(`Attempted to save undefined value for key: ${key}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
const jsonValue = JSON.stringify(value);
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
} catch (e) {
|
||||
console.error('Error saving data to storage:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a value from AsyncStorage
|
||||
* @param key Storage key
|
||||
* @returns The stored value or null if not found
|
||||
*/
|
||||
getItem: async <T>(key: string): Promise<T | null> => {
|
||||
try {
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
return jsonValue != null ? JSON.parse(jsonValue) : null;
|
||||
} catch (e) {
|
||||
console.error('Error reading data from storage:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a value from AsyncStorage
|
||||
* @param key Storage key
|
||||
*/
|
||||
removeItem: async (key: string): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error('Error removing data from storage:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all values from AsyncStorage
|
||||
*/
|
||||
clear: async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.clear();
|
||||
} catch (e) {
|
||||
console.error('Error clearing storage:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
2231
pnpm-lock.yaml
generated
2231
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue