@oxyhq/auth 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,4 @@
1
1
  import { useEffect, useRef, useMemo } from 'react';
2
- import io from 'socket.io-client';
3
2
  import { toast } from 'sonner';
4
3
  import { logger } from '@oxyhq/core';
5
4
  import { createDebugLogger } from '@oxyhq/core';
@@ -8,6 +7,46 @@ import { useQueryClient } from '@tanstack/react-query';
8
7
  import { useSessions } from './queries/useServicesQueries';
9
8
  import { invalidateSessionQueries } from './queries/queryKeys';
10
9
  const debug = createDebugLogger('SessionSocket');
10
+ /** localStorage key used by AuthManager for persisting access tokens. */
11
+ const LS_ACCESS_TOKEN_KEY = 'oxy_access_token';
12
+ /** Delay before retrying socket connection after an auth failure (ms). */
13
+ const AUTH_RETRY_DELAY_MS = 2000;
14
+ /** Maximum number of consecutive auth-failure retries. */
15
+ const MAX_AUTH_RETRIES = 3;
16
+ /**
17
+ * Read the access token from localStorage directly.
18
+ * Used as a fallback when the in-memory token is empty (e.g., during a
19
+ * cross-tab token refresh race).
20
+ */
21
+ function readTokenFromStorage() {
22
+ if (typeof window === 'undefined')
23
+ return null;
24
+ try {
25
+ return window.localStorage.getItem(LS_ACCESS_TOKEN_KEY);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ let _io = null;
32
+ let _ioLoadAttempted = false;
33
+ async function getSocketIO() {
34
+ if (_io)
35
+ return _io;
36
+ if (_ioLoadAttempted)
37
+ return null;
38
+ _ioLoadAttempted = true;
39
+ try {
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const mod = await import('socket.io-client');
42
+ _io = (mod.io ?? mod.default);
43
+ return _io;
44
+ }
45
+ catch {
46
+ debug.warn('socket.io-client is not installed. useSessionSocket will be disabled. Install it with: bun add socket.io-client');
47
+ return null;
48
+ }
49
+ }
11
50
  export function useSessionSocket(options) {
12
51
  const { user, activeSessionId, oxyServices, signOut, clearSessionState } = useWebOxy();
13
52
  const queryClient = useQueryClient();
@@ -52,137 +91,216 @@ export function useSessionSocket(options) {
52
91
  socketRef.current.disconnect();
53
92
  socketRef.current = null;
54
93
  }
55
- // Connect with auth token; use callback so reconnections get a fresh token
56
- socketRef.current = io(baseURL, {
57
- transports: ['websocket'],
58
- auth: (cb) => {
59
- const token = oxyServices.getAccessToken();
60
- cb({ token: token ?? '' });
61
- },
62
- });
63
- const socket = socketRef.current;
64
- // Server auto-joins the user to `user:<userId>` room on connection
65
- const handleConnect = () => {
66
- debug.log('Socket connected:', socket.id);
67
- };
68
- const refreshSessions = () => {
69
- invalidateSessionQueries(queryClientRef.current);
70
- return Promise.resolve();
94
+ let cancelled = false;
95
+ let authRetryCount = 0;
96
+ let authRetryTimer = null;
97
+ /**
98
+ * Resolve the best available access token.
99
+ * Prefers the in-memory token from OxyServices; falls back to
100
+ * localStorage which may have been updated by another tab.
101
+ */
102
+ const resolveToken = () => {
103
+ return oxyServices.getAccessToken() || readTokenFromStorage();
71
104
  };
72
- const handleSessionUpdate = async (data) => {
73
- debug.log('Received session_update:', data);
74
- const currentActiveSessionId = activeSessionIdRef.current;
75
- const deviceId = currentDeviceIdRef.current;
76
- // Handle different event types
77
- if (data.type === 'session_removed') {
78
- // Track removed session
79
- if (data.sessionId && onSessionRemovedRef.current) {
80
- onSessionRemovedRef.current(data.sessionId);
81
- }
82
- // If the removed sessionId matches the current activeSessionId, immediately clear state
83
- if (data.sessionId === currentActiveSessionId) {
84
- if (onRemoteSignOutRef.current) {
85
- onRemoteSignOutRef.current();
86
- }
87
- else {
88
- toast.info('You have been signed out remotely.');
89
- }
90
- try {
91
- await clearSessionStateRef.current();
92
- }
93
- catch (error) {
94
- if (__DEV__) {
95
- logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
105
+ getSocketIO().then((ioFn) => {
106
+ if (cancelled || !ioFn)
107
+ return;
108
+ // Connect with auth token; use callback so reconnections get a fresh token.
109
+ // If no token is available at all, we skip the initial connect and let
110
+ // the storage listener or retry logic connect when a token appears.
111
+ const token = resolveToken();
112
+ socketRef.current = ioFn(baseURL, {
113
+ transports: ['websocket'],
114
+ autoConnect: !!token, // don't auto-connect when there is no token
115
+ auth: (cb) => {
116
+ const resolved = resolveToken();
117
+ if (!resolved) {
118
+ // No token available -- disconnect gracefully instead of sending
119
+ // an empty string that the server will reject.
120
+ debug.warn('No access token available for socket auth; disconnecting.');
121
+ if (socketRef.current) {
122
+ socketRef.current.disconnect();
96
123
  }
124
+ return;
97
125
  }
126
+ cb({ token: resolved });
127
+ },
128
+ });
129
+ const socket = socketRef.current;
130
+ // Server auto-joins the user to `user:<userId>` room on connection
131
+ const handleConnect = () => {
132
+ debug.log('Socket connected:', socket.id);
133
+ // Successful connection resets the auth retry counter.
134
+ authRetryCount = 0;
135
+ };
136
+ /**
137
+ * Handle socket disconnection. When the disconnect reason indicates an
138
+ * auth failure (server rejected the token), schedule a short retry so
139
+ * that an in-progress token refresh can complete before the next attempt.
140
+ */
141
+ const handleDisconnect = (reason) => {
142
+ debug.log('Socket disconnected:', reason);
143
+ // "io server disconnect" = server forcibly closed the connection (auth failure).
144
+ // "transport error" can also happen when the auth callback aborted.
145
+ if ((reason === 'io server disconnect' || reason === 'transport error') &&
146
+ authRetryCount < MAX_AUTH_RETRIES &&
147
+ !cancelled) {
148
+ authRetryCount++;
149
+ debug.log(`Auth-related disconnect; scheduling retry ${authRetryCount}/${MAX_AUTH_RETRIES} in ${AUTH_RETRY_DELAY_MS}ms`);
150
+ authRetryTimer = setTimeout(() => {
151
+ if (cancelled)
152
+ return;
153
+ const retryToken = resolveToken();
154
+ if (retryToken && socketRef.current) {
155
+ debug.log('Retrying socket connection with refreshed token');
156
+ socketRef.current.connect();
157
+ }
158
+ }, AUTH_RETRY_DELAY_MS);
98
159
  }
99
- else {
100
- refreshSessions();
101
- }
102
- }
103
- else if (data.type === 'device_removed') {
104
- // Track all removed sessions from this device
105
- if (data.sessionIds && onSessionRemovedRef.current) {
106
- for (const sessionId of data.sessionIds) {
107
- onSessionRemovedRef.current(sessionId);
160
+ };
161
+ const refreshSessions = () => {
162
+ invalidateSessionQueries(queryClientRef.current);
163
+ return Promise.resolve();
164
+ };
165
+ const handleSessionUpdate = async (data) => {
166
+ debug.log('Received session_update:', data);
167
+ const currentActiveSessionId = activeSessionIdRef.current;
168
+ const deviceId = currentDeviceIdRef.current;
169
+ // Handle different event types
170
+ if (data.type === 'session_removed') {
171
+ // Track removed session
172
+ if (data.sessionId && onSessionRemovedRef.current) {
173
+ onSessionRemovedRef.current(data.sessionId);
108
174
  }
109
- }
110
- // If the removed deviceId matches the current device, immediately clear state
111
- if (data.deviceId && data.deviceId === deviceId) {
112
- if (onRemoteSignOutRef.current) {
113
- onRemoteSignOutRef.current();
175
+ // If the removed sessionId matches the current activeSessionId, immediately clear state
176
+ if (data.sessionId === currentActiveSessionId) {
177
+ if (onRemoteSignOutRef.current) {
178
+ onRemoteSignOutRef.current();
179
+ }
180
+ else {
181
+ toast.info('You have been signed out remotely.');
182
+ }
183
+ try {
184
+ await clearSessionStateRef.current();
185
+ }
186
+ catch (error) {
187
+ if (process.env.NODE_ENV !== 'production') {
188
+ logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
189
+ }
190
+ }
114
191
  }
115
192
  else {
116
- toast.info('This device has been removed. You have been signed out.');
117
- }
118
- try {
119
- await clearSessionStateRef.current();
120
- }
121
- catch (error) {
122
- if (__DEV__) {
123
- logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
124
- }
193
+ refreshSessions();
125
194
  }
126
195
  }
127
- else {
128
- refreshSessions();
129
- }
130
- }
131
- else if (data.type === 'sessions_removed') {
132
- // Track all removed sessions
133
- if (data.sessionIds && onSessionRemovedRef.current) {
134
- for (const sessionId of data.sessionIds) {
135
- onSessionRemovedRef.current(sessionId);
196
+ else if (data.type === 'device_removed') {
197
+ // Track all removed sessions from this device
198
+ if (data.sessionIds && onSessionRemovedRef.current) {
199
+ for (const sessionId of data.sessionIds) {
200
+ onSessionRemovedRef.current(sessionId);
201
+ }
136
202
  }
137
- }
138
- // If the current activeSessionId is in the removed sessionIds list, immediately clear state
139
- if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
140
- if (onRemoteSignOutRef.current) {
141
- onRemoteSignOutRef.current();
203
+ // If the removed deviceId matches the current device, immediately clear state
204
+ if (data.deviceId && data.deviceId === deviceId) {
205
+ if (onRemoteSignOutRef.current) {
206
+ onRemoteSignOutRef.current();
207
+ }
208
+ else {
209
+ toast.info('This device has been removed. You have been signed out.');
210
+ }
211
+ try {
212
+ await clearSessionStateRef.current();
213
+ }
214
+ catch (error) {
215
+ if (process.env.NODE_ENV !== 'production') {
216
+ logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
217
+ }
218
+ }
142
219
  }
143
220
  else {
144
- toast.info('You have been signed out remotely.');
221
+ refreshSessions();
145
222
  }
146
- try {
147
- await clearSessionStateRef.current();
223
+ }
224
+ else if (data.type === 'sessions_removed') {
225
+ // Track all removed sessions
226
+ if (data.sessionIds && onSessionRemovedRef.current) {
227
+ for (const sessionId of data.sessionIds) {
228
+ onSessionRemovedRef.current(sessionId);
229
+ }
148
230
  }
149
- catch (error) {
150
- if (__DEV__) {
151
- logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
231
+ // If the current activeSessionId is in the removed sessionIds list, immediately clear state
232
+ if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
233
+ if (onRemoteSignOutRef.current) {
234
+ onRemoteSignOutRef.current();
235
+ }
236
+ else {
237
+ toast.info('You have been signed out remotely.');
238
+ }
239
+ try {
240
+ await clearSessionStateRef.current();
241
+ }
242
+ catch (error) {
243
+ if (process.env.NODE_ENV !== 'production') {
244
+ logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
245
+ }
152
246
  }
153
247
  }
248
+ else {
249
+ refreshSessions();
250
+ }
154
251
  }
155
252
  else {
253
+ // For other event types (e.g., session_created), refresh sessions
156
254
  refreshSessions();
157
- }
158
- }
159
- else {
160
- // For other event types (e.g., session_created), refresh sessions
161
- refreshSessions();
162
- // If the current session was logged out (legacy behavior), handle it specially
163
- if (data.sessionId === currentActiveSessionId) {
164
- if (onRemoteSignOutRef.current) {
165
- onRemoteSignOutRef.current();
166
- }
167
- else {
168
- toast.info('You have been signed out remotely.');
169
- }
170
- try {
171
- await clearSessionStateRef.current();
172
- }
173
- catch (error) {
174
- debug.error('Failed to clear session state after session_update:', error);
255
+ // If the current session was logged out (legacy behavior), handle it specially
256
+ if (data.sessionId === currentActiveSessionId) {
257
+ if (onRemoteSignOutRef.current) {
258
+ onRemoteSignOutRef.current();
259
+ }
260
+ else {
261
+ toast.info('You have been signed out remotely.');
262
+ }
263
+ try {
264
+ await clearSessionStateRef.current();
265
+ }
266
+ catch (error) {
267
+ debug.error('Failed to clear session state after session_update:', error);
268
+ }
175
269
  }
176
270
  }
271
+ };
272
+ socket.on('connect', handleConnect);
273
+ socket.on('disconnect', handleDisconnect);
274
+ socket.on('session_update', handleSessionUpdate);
275
+ // Listen for cross-tab token updates via the Storage event.
276
+ // When another tab writes a fresh access token to localStorage,
277
+ // reconnect this tab's socket if it was disconnected.
278
+ const handleStorageEvent = (e) => {
279
+ if (e.key === LS_ACCESS_TOKEN_KEY && e.newValue && socketRef.current?.disconnected) {
280
+ debug.log('Cross-tab token update detected; reconnecting socket');
281
+ authRetryCount = 0; // reset retries since we got a fresh token
282
+ socketRef.current.connect();
283
+ }
284
+ };
285
+ if (typeof window !== 'undefined') {
286
+ window.addEventListener('storage', handleStorageEvent);
287
+ // Store the handler so cleanup can remove it
288
+ socket.__oxyStorageHandler = handleStorageEvent;
177
289
  }
178
- };
179
- socket.on('connect', handleConnect);
180
- socket.on('session_update', handleSessionUpdate);
290
+ });
181
291
  return () => {
182
- socket.off('connect', handleConnect);
183
- socket.off('session_update', handleSessionUpdate);
184
- socket.disconnect();
185
- socketRef.current = null;
292
+ cancelled = true;
293
+ if (authRetryTimer) {
294
+ clearTimeout(authRetryTimer);
295
+ }
296
+ if (socketRef.current) {
297
+ // Remove cross-tab storage listener
298
+ if (typeof window !== 'undefined' && socketRef.current.__oxyStorageHandler) {
299
+ window.removeEventListener('storage', socketRef.current.__oxyStorageHandler);
300
+ }
301
+ socketRef.current.disconnect();
302
+ socketRef.current = null;
303
+ }
186
304
  };
187
305
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
188
306
  }
package/dist/esm/index.js CHANGED
@@ -27,12 +27,15 @@ export { WebOxyProvider, useWebOxy, useAuth } from './WebOxyProvider';
27
27
  // --- Stores ---
28
28
  export { useAuthStore } from './stores/authStore';
29
29
  export { useAssetStore, useAssets as useAssetsStore, useAsset, useUploadProgress, useAssetLoading, useAssetErrors, useAssetsByApp, useAssetsByEntity, useAssetUsageCount, useIsAssetLinked, } from './stores/assetStore';
30
+ export { useAccountStore, useAccounts, useAccountLoading, useAccountError, useAccountLoadingSession, } from './stores/accountStore';
31
+ export { useFollowStore, } from './stores/followStore';
30
32
  // --- Query Hooks ---
31
33
  export { useUserProfile, useUserProfiles, useCurrentUser, useUserById, useUserByUsername, useUsersBySessions, usePrivacySettings, useSessions, useSession, useDeviceSessions, useUserDevices, useSecurityInfo, useSecurityActivity, useRecentSecurityActivity, } from './hooks/queries';
32
34
  // --- Mutation Hooks ---
33
35
  export { useUpdateProfile, useUploadAvatar, useUpdateAccountSettings, useUpdatePrivacySettings, useUploadFile, useSwitchSession, useLogoutSession, useLogoutAll, useUpdateDeviceName, useRemoveDevice, } from './hooks/mutations';
34
36
  export { createProfileMutation, createGenericMutation, } from './hooks/mutations/mutationFactory';
35
37
  // --- Custom Hooks ---
38
+ export { useWebSSO, isWebBrowser } from './hooks/useWebSSO';
36
39
  export { useSessionSocket } from './hooks/useSessionSocket';
37
40
  export { useAssets, setOxyAssetInstance } from './hooks/useAssets';
38
41
  export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownloadUrl';
@@ -161,7 +161,7 @@ export const useAccountStore = create((set, get) => ({
161
161
  }
162
162
  catch (error) {
163
163
  const errorMessage = error instanceof Error ? error.message : 'Failed to load accounts';
164
- if (__DEV__) {
164
+ if (process.env.NODE_ENV !== 'production') {
165
165
  console.error('AccountStore: Failed to load accounts:', error);
166
166
  }
167
167
  set({ error: errorMessage });
@@ -172,7 +172,7 @@ export const useAccountStore = create((set, get) => ({
172
172
  }
173
173
  catch (error) {
174
174
  const errorMessage = error instanceof Error ? error.message : 'Failed to load accounts';
175
- if (__DEV__) {
175
+ if (process.env.NODE_ENV !== 'production') {
176
176
  console.error('AccountStore: Failed to load accounts:', error);
177
177
  }
178
178
  set({ error: errorMessage, loading: false });
@@ -33,7 +33,7 @@ export const fetchSessionsWithFallback = async (oxyServices, sessionId, { fallba
33
33
  return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
34
34
  }
35
35
  catch (error) {
36
- if (__DEV__ && logger) {
36
+ if (process.env.NODE_ENV !== 'production' && logger) {
37
37
  logger('Failed to get device sessions, falling back to user sessions', error);
38
38
  }
39
39
  const userSessions = await oxyServices.getSessionsBySessionId(sessionId);
@@ -77,7 +77,7 @@ const createNativeStorage = async () => {
77
77
  return asyncStorageInstance;
78
78
  }
79
79
  catch (error) {
80
- if (__DEV__) {
80
+ if (process.env.NODE_ENV !== 'production') {
81
81
  console.error('Failed to import AsyncStorage:', error);
82
82
  }
83
83
  throw new Error('AsyncStorage is required in React Native environment');