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 */ 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 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 }; } catch (error) { logError('Sign in error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } }, /** * Sign up with email, password, and username */ signUp: async ( email: string, password: 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'); } const data: SignUpResponse = 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 }; } catch (error) { logError('Sign up error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } }, /** * Sign out the current user */ signOut: async (): Promise => { try { const refreshToken = await safeStorage.getItem(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(); } 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); } } }, /** * Refresh access token using refresh token * Updated signature to match TokenManager requirements */ refreshTokens: async ( 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 } }, /** * Legacy refreshToken method (for backward compatibility) */ refreshToken: async (): Promise => { try { const currentRefreshToken = await safeStorage.getItem(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 => { return await safeStorage.getItem(STORAGE_KEYS.APP_TOKEN); }, /** * Get current refresh token */ getRefreshToken: async (): Promise => { return await safeStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN); }, /** * Clear all auth storage */ clearAuthStorage: async (): Promise => { // 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))); }, /** * Check if user is authenticated (has valid token) */ isAuthenticated: async (): Promise => { const appToken = await safeStorage.getItem(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; } }, /** * 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; } }, /** * 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; } }, /** * Get user credits balance */ getCredits: async (): Promise => { 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); 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 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 }; } catch (error) { logError('Google sign-in error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } }, /** * 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 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 }; } catch (error) { logError('Apple sign-in error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } }, /** * 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, };