@oxyhq/services
Version:
305 lines (288 loc) • 10.4 kB
JavaScript
;
import { useCallback, useRef } from 'react';
import { DeviceManager } from '@oxyhq/core';
import { fetchSessionsWithFallback } from "../../utils/sessionHelpers.js";
import { handleAuthError, isInvalidSessionError } from "../../utils/errorHandlers.js";
import { SignatureService } from '@oxyhq/core';
import * as Crypto from 'expo-crypto';
const LOGIN_ERROR_CODE = 'LOGIN_ERROR';
const LOGOUT_ERROR_CODE = 'LOGOUT_ERROR';
const LOGOUT_ALL_ERROR_CODE = 'LOGOUT_ALL_ERROR';
/**
* Authentication operations using public key cryptography.
* Accepts public key as parameter - identity management is handled by the app layer.
*/
export const useAuthOperations = ({
oxyServices,
storage,
sessions,
activeSessionId,
setActiveSessionId,
updateSessions,
saveActiveSessionId,
clearSessionState,
switchSession,
applyLanguagePreference,
onAuthStateChange,
onError,
loginSuccess,
loginFailure,
logoutStore,
setAuthState,
logger
}) => {
// Ref to avoid recreating callbacks when sessions change
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
/**
* Internal function to perform challenge-response sign in (works offline)
*/
const performSignIn = useCallback(async publicKey => {
const deviceFingerprintObj = DeviceManager.getDeviceFingerprint();
const deviceFingerprint = JSON.stringify(deviceFingerprintObj);
const deviceInfo = await DeviceManager.getDeviceInfo();
const deviceName = deviceInfo.deviceName || DeviceManager.getDefaultDeviceName();
let challenge;
let isOffline = false;
// Try to request challenge from server (online)
try {
const challengeResponse = await oxyServices.requestChallenge(publicKey);
challenge = challengeResponse.challenge;
} catch (error) {
// Network error - generate challenge locally for offline sign-in
const errorMessage = error instanceof Error ? error.message : String(error);
const isNetworkError = errorMessage.includes('Network') || errorMessage.includes('network') || errorMessage.includes('Failed to fetch') || errorMessage.includes('fetch failed') || error?.code === 'NETWORK_ERROR' || error?.status === 0;
if (isNetworkError) {
if (__DEV__ && logger) {
logger('Network unavailable, performing offline sign-in');
}
// Generate challenge locally
challenge = await SignatureService.generateChallenge();
isOffline = true;
} else {
// Re-throw non-network errors
throw error;
}
}
// Note: Biometric authentication check should be handled by the app layer
// (e.g., accounts app) before calling signIn. The biometric preference is stored
// in local storage as 'oxy_biometric_enabled' and can be checked there.
// Sign the challenge
const {
challenge: signature,
timestamp
} = await SignatureService.signChallenge(challenge);
let fullUser;
let sessionResponse;
if (isOffline) {
// Offline sign-in: create local session and minimal user object
if (__DEV__ && logger) {
logger('Creating offline session');
}
// Generate a local session ID using cryptographically secure randomness
const localSessionId = `offline_${Crypto.getRandomUUID()}`;
const localDeviceId = `device_${Crypto.getRandomUUID()}`;
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
// Create minimal user object with publicKey as id
fullUser = {
id: publicKey,
// Use publicKey as id (per migration document)
publicKey,
username: '',
privacySettings: {}
};
sessionResponse = {
sessionId: localSessionId,
deviceId: localDeviceId,
expiresAt,
user: {
id: publicKey,
username: ''
}
};
// Store offline session locally
const offlineSession = {
sessionId: localSessionId,
deviceId: localDeviceId,
expiresAt,
lastActive: new Date().toISOString(),
userId: publicKey,
isCurrent: true
};
setActiveSessionId(localSessionId);
await saveActiveSessionId(localSessionId);
updateSessions([offlineSession], {
merge: true
});
// Mark session as offline for later sync
if (storage) {
await storage.setItem(`oxy_session_${localSessionId}_offline`, 'true');
}
if (__DEV__ && logger) {
logger('Offline sign-in successful');
}
} else {
// Online sign-in: use normal flow
// Verify and create session
sessionResponse = await oxyServices.verifyChallenge(publicKey, challenge, signature, timestamp, deviceName, deviceFingerprint);
// Get token for the session
try {
await oxyServices.getTokenBySession(sessionResponse.sessionId);
} catch (tokenError) {
const errorMessage = tokenError instanceof Error ? tokenError.message : String(tokenError);
const status = tokenError?.status;
if (status === 404 || errorMessage.includes('404')) {
throw new Error(`Session was created but token could not be retrieved. Session ID: ${sessionResponse.sessionId.substring(0, 8)}...`);
}
throw tokenError;
}
// Get full user data
fullUser = await oxyServices.getUserBySession(sessionResponse.sessionId);
// Fetch device sessions
let allDeviceSessions = [];
try {
allDeviceSessions = await fetchSessionsWithFallback(oxyServices, sessionResponse.sessionId, {
fallbackDeviceId: sessionResponse.deviceId,
fallbackUserId: fullUser.id,
logger
});
} catch (error) {
if (__DEV__ && logger) {
logger('Failed to fetch device sessions after login', error);
}
}
// Check for existing session for same user and switch to it to avoid duplicates
const existingSession = allDeviceSessions.find(session => session.userId?.toString() === fullUser.id?.toString() && session.sessionId !== sessionResponse.sessionId);
if (existingSession) {
// Switch to existing session instead of creating duplicate
try {
await oxyServices.logoutSession(sessionResponse.sessionId, sessionResponse.sessionId);
} catch (logoutError) {
// Non-critical - continue to switch session even if logout fails
if (__DEV__ && logger) {
logger('Failed to logout duplicate session, continuing with switch', logoutError);
}
}
await switchSession(existingSession.sessionId);
updateSessions(allDeviceSessions.filter(session => session.sessionId !== sessionResponse.sessionId), {
merge: false
});
onAuthStateChange?.(fullUser);
return fullUser;
}
setActiveSessionId(sessionResponse.sessionId);
await saveActiveSessionId(sessionResponse.sessionId);
updateSessions(allDeviceSessions, {
merge: true
});
}
await applyLanguagePreference(fullUser);
loginSuccess(fullUser);
onAuthStateChange?.(fullUser);
return fullUser;
}, [applyLanguagePreference, logger, loginSuccess, onAuthStateChange, oxyServices, saveActiveSessionId, setActiveSessionId, switchSession, updateSessions, storage]);
/**
* Sign in with existing public key
*/
const signIn = useCallback(async (publicKey, deviceName) => {
setAuthState({
isLoading: true,
error: null
});
try {
return await performSignIn(publicKey);
} catch (error) {
const message = handleAuthError(error, {
defaultMessage: 'Sign in failed',
code: LOGIN_ERROR_CODE,
onError,
setAuthError: msg => setAuthState({
error: msg
}),
logger
});
loginFailure(message);
throw error;
} finally {
setAuthState({
isLoading: false
});
}
}, [setAuthState, performSignIn, loginFailure, onError, logger]);
/**
* Logout from session
*/
const logout = useCallback(async targetSessionId => {
if (!activeSessionId) return;
try {
const sessionToLogout = targetSessionId || activeSessionId;
await oxyServices.logoutSession(activeSessionId, sessionToLogout);
const filteredSessions = sessionsRef.current.filter(session => session.sessionId !== sessionToLogout);
updateSessions(filteredSessions, {
merge: false
});
if (sessionToLogout === activeSessionId) {
if (filteredSessions.length > 0) {
await switchSession(filteredSessions[0].sessionId);
} else {
await clearSessionState();
return;
}
}
} catch (error) {
const isInvalid = isInvalidSessionError(error);
if (isInvalid && targetSessionId === activeSessionId) {
await clearSessionState();
return;
}
handleAuthError(error, {
defaultMessage: 'Logout failed',
code: LOGOUT_ERROR_CODE,
onError,
setAuthError: msg => setAuthState({
error: msg
}),
logger,
status: isInvalid ? 401 : undefined
});
}
}, [activeSessionId, clearSessionState, logger, onError, oxyServices, setAuthState, switchSession, updateSessions]);
/**
* Logout from all sessions
*/
const logoutAll = useCallback(async () => {
if (!activeSessionId) {
const error = new Error('No active session found');
setAuthState({
error: error.message
});
onError?.({
message: error.message,
code: LOGOUT_ALL_ERROR_CODE,
status: 404
});
throw error;
}
try {
await oxyServices.logoutAllSessions(activeSessionId);
await clearSessionState();
} catch (error) {
handleAuthError(error, {
defaultMessage: 'Logout all failed',
code: LOGOUT_ALL_ERROR_CODE,
onError,
setAuthError: msg => setAuthState({
error: msg
}),
logger
});
throw error instanceof Error ? error : new Error('Logout all failed');
}
}, [activeSessionId, clearSessionState, logger, onError, oxyServices, setAuthState]);
return {
signIn,
logout,
logoutAll
};
};
//# sourceMappingURL=useAuthOperations.js.map