@oxyhq/auth 1.1.3 → 1.2.0

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.
@@ -49,6 +49,8 @@ function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChange, onEr
49
49
  // Multi-session management is handled by @oxyhq/services (OxyContext) for RN apps.
50
50
  const sessions = [];
51
51
  const isAuthenticated = !!user;
52
+ // Mutex: prevents concurrent sign-in attempts (FedCM + popup + redirect)
53
+ const signingInRef = (0, react_1.useRef)(false);
52
54
  const handleAuthSuccess = (0, react_1.useCallback)(async (session, method = 'credentials') => {
53
55
  await authManager.handleAuthSuccess(session, method);
54
56
  if (session.sessionId) {
@@ -127,6 +129,9 @@ function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChange, onEr
127
129
  onAuthStateChange?.(user);
128
130
  }, [user, onAuthStateChange]);
129
131
  const signIn = (0, react_1.useCallback)(async () => {
132
+ if (signingInRef.current)
133
+ return;
134
+ signingInRef.current = true;
130
135
  setError(null);
131
136
  setIsLoading(true);
132
137
  let selectedMethod = 'popup';
@@ -147,8 +152,14 @@ function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChange, onEr
147
152
  catch (err) {
148
153
  handleAuthError(err);
149
154
  }
155
+ finally {
156
+ signingInRef.current = false;
157
+ }
150
158
  }, [crossDomainAuth, preferredAuthMethod, handleAuthSuccess, handleAuthError]);
151
159
  const signInWithFedCM = (0, react_1.useCallback)(async () => {
160
+ if (signingInRef.current)
161
+ return;
162
+ signingInRef.current = true;
152
163
  setError(null);
153
164
  setIsLoading(true);
154
165
  try {
@@ -158,8 +169,14 @@ function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChange, onEr
158
169
  catch (err) {
159
170
  handleAuthError(err);
160
171
  }
172
+ finally {
173
+ signingInRef.current = false;
174
+ }
161
175
  }, [crossDomainAuth, handleAuthSuccess, handleAuthError]);
162
176
  const signInWithPopup = (0, react_1.useCallback)(async () => {
177
+ if (signingInRef.current)
178
+ return;
179
+ signingInRef.current = true;
163
180
  setError(null);
164
181
  setIsLoading(true);
165
182
  try {
@@ -169,6 +186,9 @@ function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChange, onEr
169
186
  catch (err) {
170
187
  handleAuthError(err);
171
188
  }
189
+ finally {
190
+ signingInRef.current = false;
191
+ }
172
192
  }, [crossDomainAuth, handleAuthSuccess, handleAuthError]);
173
193
  const signInWithRedirect = (0, react_1.useCallback)(() => {
174
194
  setError(null);
@@ -2,12 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useUploadFile = exports.useUpdatePrivacySettings = exports.useUpdateAccountSettings = exports.useUploadAvatar = exports.useUpdateProfile = void 0;
4
4
  const react_query_1 = require("@tanstack/react-query");
5
+ const core_1 = require("@oxyhq/core");
5
6
  const queryKeys_1 = require("../queries/queryKeys");
6
7
  const WebOxyProvider_1 = require("../../WebOxyProvider");
7
8
  const sonner_1 = require("sonner");
8
9
  const avatarUtils_1 = require("../../utils/avatarUtils");
9
10
  const authStore_1 = require("../../stores/authStore");
10
- const authHelpers_1 = require("../../utils/authHelpers");
11
11
  /**
12
12
  * Update user profile with optimistic updates and offline queue support
13
13
  */
@@ -16,7 +16,7 @@ const useUpdateProfile = () => {
16
16
  const queryClient = (0, react_query_1.useQueryClient)();
17
17
  return (0, react_query_1.useMutation)({
18
18
  mutationFn: async (updates) => {
19
- return (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.updateProfile(updates));
19
+ return (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.updateProfile(updates));
20
20
  },
21
21
  // Optimistic update
22
22
  onMutate: async (updates) => {
@@ -78,7 +78,7 @@ const useUploadAvatar = () => {
78
78
  const queryClient = (0, react_query_1.useQueryClient)();
79
79
  return (0, react_query_1.useMutation)({
80
80
  mutationFn: async (file) => {
81
- return (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, async () => {
81
+ return (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, async () => {
82
82
  // Upload file first
83
83
  const uploadResult = await oxyServices.assetUpload(file, 'public');
84
84
  const fileId = uploadResult?.file?.id || uploadResult?.id || uploadResult;
@@ -188,7 +188,7 @@ const useUpdatePrivacySettings = () => {
188
188
  if (!targetUserId) {
189
189
  throw new Error('User ID is required');
190
190
  }
191
- return (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.updatePrivacySettings(settings, targetUserId));
191
+ return (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.updatePrivacySettings(settings, targetUserId));
192
192
  },
193
193
  // Optimistic update
194
194
  onMutate: async ({ settings, userId }) => {
@@ -268,7 +268,7 @@ const useUploadFile = () => {
268
268
  const { oxyServices, activeSessionId } = (0, WebOxyProvider_1.useWebOxy)();
269
269
  return (0, react_query_1.useMutation)({
270
270
  mutationFn: async ({ file, visibility, metadata, onProgress }) => {
271
- return (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.assetUpload(file, visibility, metadata, onProgress));
271
+ return (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.assetUpload(file, visibility, metadata, onProgress));
272
272
  },
273
273
  });
274
274
  };
@@ -2,9 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.usePrivacySettings = exports.useUsersBySessions = exports.useUserByUsername = exports.useUserById = exports.useCurrentUser = exports.useUserProfiles = exports.useUserProfile = void 0;
4
4
  const react_query_1 = require("@tanstack/react-query");
5
+ const core_1 = require("@oxyhq/core");
5
6
  const queryKeys_1 = require("./queryKeys");
6
7
  const WebOxyProvider_1 = require("../../WebOxyProvider");
7
- const authHelpers_1 = require("../../utils/authHelpers");
8
8
  /**
9
9
  * Get user profile by session ID
10
10
  */
@@ -131,7 +131,7 @@ const usePrivacySettings = (userId, options) => {
131
131
  if (!targetUserId) {
132
132
  throw new Error('User ID is required');
133
133
  }
134
- return (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.getPrivacySettings(targetUserId));
134
+ return (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.getPrivacySettings(targetUserId));
135
135
  },
136
136
  enabled: (options?.enabled !== false) && !!targetUserId,
137
137
  staleTime: 2 * 60 * 1000, // 2 minutes
@@ -2,10 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useSecurityInfo = exports.useUserDevices = exports.useDeviceSessions = exports.useSession = exports.useSessions = void 0;
4
4
  const react_query_1 = require("@tanstack/react-query");
5
+ const core_1 = require("@oxyhq/core");
5
6
  const queryKeys_1 = require("./queryKeys");
6
7
  const WebOxyProvider_1 = require("../../WebOxyProvider");
7
8
  const sessionHelpers_1 = require("../../utils/sessionHelpers");
8
- const authHelpers_1 = require("../../utils/authHelpers");
9
9
  /**
10
10
  * Get all active sessions for the current user
11
11
  */
@@ -87,7 +87,7 @@ const useUserDevices = (options) => {
87
87
  return (0, react_query_1.useQuery)({
88
88
  queryKey: queryKeys_1.queryKeys.devices.list(),
89
89
  queryFn: async () => {
90
- return (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.getUserDevices());
90
+ return (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.getUserDevices());
91
91
  },
92
92
  enabled: (options?.enabled !== false) && isAuthenticated,
93
93
  staleTime: 5 * 60 * 1000,
@@ -10,10 +10,9 @@ const sonner_1 = require("sonner");
10
10
  const core_1 = require("@oxyhq/core");
11
11
  const core_2 = require("@oxyhq/core");
12
12
  const debug = (0, core_2.createDebugLogger)('SessionSocket');
13
- function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, onRemoteSignOut, onSessionRemoved }) {
13
+ function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, getAccessToken, onRemoteSignOut, onSessionRemoved }) {
14
14
  const socketRef = (0, react_1.useRef)(null);
15
- const joinedRoomRef = (0, react_1.useRef)(null);
16
- // Store callbacks in refs to avoid re-joining when they change
15
+ // Store callbacks in refs to avoid reconnecting when they change
17
16
  const refreshSessionsRef = (0, react_1.useRef)(refreshSessions);
18
17
  const logoutRef = (0, react_1.useRef)(logout);
19
18
  const clearSessionStateRef = (0, react_1.useRef)(clearSessionState);
@@ -21,6 +20,7 @@ function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSes
21
20
  const onSessionRemovedRef = (0, react_1.useRef)(onSessionRemoved);
22
21
  const activeSessionIdRef = (0, react_1.useRef)(activeSessionId);
23
22
  const currentDeviceIdRef = (0, react_1.useRef)(currentDeviceId);
23
+ const getAccessTokenRef = (0, react_1.useRef)(getAccessToken);
24
24
  // Update refs when callbacks change
25
25
  (0, react_1.useEffect)(() => {
26
26
  refreshSessionsRef.current = refreshSessions;
@@ -30,35 +30,32 @@ function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSes
30
30
  onSessionRemovedRef.current = onSessionRemoved;
31
31
  activeSessionIdRef.current = activeSessionId;
32
32
  currentDeviceIdRef.current = currentDeviceId;
33
- }, [refreshSessions, logout, clearSessionState, onRemoteSignOut, onSessionRemoved, activeSessionId, currentDeviceId]);
33
+ getAccessTokenRef.current = getAccessToken;
34
+ }, [refreshSessions, logout, clearSessionState, onRemoteSignOut, onSessionRemoved, activeSessionId, currentDeviceId, getAccessToken]);
34
35
  (0, react_1.useEffect)(() => {
35
36
  if (!userId || !baseURL) {
36
37
  // Clean up if userId or baseURL becomes invalid
37
- if (socketRef.current && joinedRoomRef.current) {
38
- socketRef.current.emit('leave', { userId: joinedRoomRef.current });
39
- joinedRoomRef.current = null;
38
+ if (socketRef.current) {
39
+ socketRef.current.disconnect();
40
+ socketRef.current = null;
40
41
  }
41
42
  return;
42
43
  }
43
- const roomId = `user:${userId}`;
44
- // Only create socket if it doesn't exist
45
- if (!socketRef.current) {
46
- socketRef.current = (0, socket_io_client_1.default)(baseURL, {
47
- transports: ['websocket'],
48
- });
44
+ // Disconnect previous socket if switching users
45
+ if (socketRef.current) {
46
+ socketRef.current.disconnect();
47
+ socketRef.current = null;
49
48
  }
49
+ // Connect with auth token; use callback so reconnections get a fresh token
50
+ socketRef.current = (0, socket_io_client_1.default)(baseURL, {
51
+ transports: ['websocket'],
52
+ auth: (cb) => {
53
+ const token = getAccessTokenRef.current();
54
+ cb({ token: token ?? '' });
55
+ },
56
+ });
50
57
  const socket = socketRef.current;
51
- // Only join if we haven't already joined this room
52
- if (joinedRoomRef.current !== roomId) {
53
- // Leave previous room if switching users
54
- if (joinedRoomRef.current) {
55
- socket.emit('leave', { userId: joinedRoomRef.current });
56
- }
57
- socket.emit('join', { userId: roomId });
58
- joinedRoomRef.current = roomId;
59
- debug.log('Emitting join for room:', roomId);
60
- }
61
- // Set up event handlers (only once per socket instance)
58
+ // Server auto-joins the user to `user:<userId>` room on connection
62
59
  const handleConnect = () => {
63
60
  debug.log('Socket connected:', socket.id);
64
61
  };
@@ -205,11 +202,8 @@ function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSes
205
202
  return () => {
206
203
  socket.off('connect', handleConnect);
207
204
  socket.off('session_update', handleSessionUpdate);
208
- // Only leave on unmount if we're still in this room
209
- if (joinedRoomRef.current === roomId) {
210
- socket.emit('leave', { userId: roomId });
211
- joinedRoomRef.current = null;
212
- }
205
+ socket.disconnect();
206
+ socketRef.current = null;
213
207
  };
214
208
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
215
209
  }
package/dist/cjs/index.js CHANGED
@@ -24,8 +24,8 @@
24
24
  * ```
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.withAuthErrorHandling = exports.ensureValidToken = exports.useFileFiltering = exports.useFollowerCounts = exports.useFollow = exports.setOxyFileUrlInstance = exports.useFileDownloadUrl = exports.setOxyAssetInstance = exports.useAssets = exports.useSessionSocket = exports.createGenericMutation = exports.createProfileMutation = exports.useRemoveDevice = exports.useUpdateDeviceName = exports.useLogoutAll = exports.useLogoutSession = exports.useSwitchSession = exports.useUploadFile = exports.useUpdatePrivacySettings = exports.useUpdateAccountSettings = exports.useUploadAvatar = exports.useUpdateProfile = exports.useRecentSecurityActivity = exports.useSecurityActivity = exports.useSecurityInfo = exports.useUserDevices = exports.useDeviceSessions = exports.useSession = exports.useSessions = exports.usePrivacySettings = exports.useUsersBySessions = exports.useUserByUsername = exports.useUserById = exports.useCurrentUser = exports.useUserProfiles = exports.useUserProfile = exports.useIsAssetLinked = exports.useAssetUsageCount = exports.useAssetsByEntity = exports.useAssetsByApp = exports.useAssetErrors = exports.useAssetLoading = exports.useUploadProgress = exports.useAsset = exports.useAssetsStore = exports.useAssetStore = exports.useAuthStore = exports.useAuth = exports.useWebOxy = exports.WebOxyProvider = void 0;
28
- exports.createCrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.CrossDomainAuth = exports.OxyServices = exports.extractErrorMessage = exports.isTimeoutOrNetworkError = exports.isInvalidSessionError = exports.handleAuthError = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.isAuthenticationError = exports.authenticatedApiCall = void 0;
27
+ exports.isInvalidSessionError = exports.handleAuthError = exports.useFileFiltering = exports.useFollowerCounts = exports.useFollow = exports.setOxyFileUrlInstance = exports.useFileDownloadUrl = exports.setOxyAssetInstance = exports.useAssets = exports.useSessionSocket = exports.createGenericMutation = exports.createProfileMutation = exports.useRemoveDevice = exports.useUpdateDeviceName = exports.useLogoutAll = exports.useLogoutSession = exports.useSwitchSession = exports.useUploadFile = exports.useUpdatePrivacySettings = exports.useUpdateAccountSettings = exports.useUploadAvatar = exports.useUpdateProfile = exports.useRecentSecurityActivity = exports.useSecurityActivity = exports.useSecurityInfo = exports.useUserDevices = exports.useDeviceSessions = exports.useSession = exports.useSessions = exports.usePrivacySettings = exports.useUsersBySessions = exports.useUserByUsername = exports.useUserById = exports.useCurrentUser = exports.useUserProfiles = exports.useUserProfile = exports.useIsAssetLinked = exports.useAssetUsageCount = exports.useAssetsByEntity = exports.useAssetsByApp = exports.useAssetErrors = exports.useAssetLoading = exports.useUploadProgress = exports.useAsset = exports.useAssetsStore = exports.useAssetStore = exports.useAuthStore = exports.useAuth = exports.useWebOxy = exports.WebOxyProvider = void 0;
28
+ exports.extractErrorMessage = exports.isTimeoutOrNetworkError = void 0;
29
29
  // --- Provider & Hooks ---
30
30
  var WebOxyProvider_1 = require("./WebOxyProvider");
31
31
  Object.defineProperty(exports, "WebOxyProvider", { enumerable: true, get: function () { return WebOxyProvider_1.WebOxyProvider; } });
@@ -90,26 +90,11 @@ Object.defineProperty(exports, "useFollow", { enumerable: true, get: function ()
90
90
  Object.defineProperty(exports, "useFollowerCounts", { enumerable: true, get: function () { return useFollow_1.useFollowerCounts; } });
91
91
  var useFileFiltering_1 = require("./hooks/useFileFiltering");
92
92
  Object.defineProperty(exports, "useFileFiltering", { enumerable: true, get: function () { return useFileFiltering_1.useFileFiltering; } });
93
- // --- Auth Helpers ---
94
- var authHelpers_1 = require("./utils/authHelpers");
95
- Object.defineProperty(exports, "ensureValidToken", { enumerable: true, get: function () { return authHelpers_1.ensureValidToken; } });
96
- Object.defineProperty(exports, "withAuthErrorHandling", { enumerable: true, get: function () { return authHelpers_1.withAuthErrorHandling; } });
97
- Object.defineProperty(exports, "authenticatedApiCall", { enumerable: true, get: function () { return authHelpers_1.authenticatedApiCall; } });
98
- Object.defineProperty(exports, "isAuthenticationError", { enumerable: true, get: function () { return authHelpers_1.isAuthenticationError; } });
99
- Object.defineProperty(exports, "SessionSyncRequiredError", { enumerable: true, get: function () { return authHelpers_1.SessionSyncRequiredError; } });
100
- Object.defineProperty(exports, "AuthenticationFailedError", { enumerable: true, get: function () { return authHelpers_1.AuthenticationFailedError; } });
101
93
  // --- Error Handlers ---
102
94
  var errorHandlers_1 = require("./utils/errorHandlers");
103
95
  Object.defineProperty(exports, "handleAuthError", { enumerable: true, get: function () { return errorHandlers_1.handleAuthError; } });
104
96
  Object.defineProperty(exports, "isInvalidSessionError", { enumerable: true, get: function () { return errorHandlers_1.isInvalidSessionError; } });
105
97
  Object.defineProperty(exports, "isTimeoutOrNetworkError", { enumerable: true, get: function () { return errorHandlers_1.isTimeoutOrNetworkError; } });
106
98
  Object.defineProperty(exports, "extractErrorMessage", { enumerable: true, get: function () { return errorHandlers_1.extractErrorMessage; } });
107
- // Re-export core for convenience
108
- var core_1 = require("@oxyhq/core");
109
- Object.defineProperty(exports, "OxyServices", { enumerable: true, get: function () { return core_1.OxyServices; } });
110
- Object.defineProperty(exports, "CrossDomainAuth", { enumerable: true, get: function () { return core_1.CrossDomainAuth; } });
111
- Object.defineProperty(exports, "AuthManager", { enumerable: true, get: function () { return core_1.AuthManager; } });
112
- Object.defineProperty(exports, "createAuthManager", { enumerable: true, get: function () { return core_1.createAuthManager; } });
113
- Object.defineProperty(exports, "createCrossDomainAuth", { enumerable: true, get: function () { return core_1.createCrossDomainAuth; } });
114
99
  const WebOxyProvider_2 = require("./WebOxyProvider");
115
100
  exports.default = WebOxyProvider_2.WebOxyProvider;
@@ -3,10 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.updateAvatarVisibility = updateAvatarVisibility;
4
4
  exports.refreshAvatarInStore = refreshAvatarInStore;
5
5
  exports.updateProfileWithAvatar = updateProfileWithAvatar;
6
+ const core_1 = require("@oxyhq/core");
6
7
  const accountStore_1 = require("../stores/accountStore");
7
8
  const authStore_1 = require("../stores/authStore");
8
9
  const queryKeys_1 = require("../hooks/queries/queryKeys");
9
- const authHelpers_1 = require("./authHelpers");
10
10
  /**
11
11
  * Updates file visibility to public for avatar use.
12
12
  * Handles errors gracefully, only logging non-404 errors.
@@ -58,7 +58,7 @@ function refreshAvatarInStore(sessionId, avatarFileId, oxyServices) {
58
58
  * @returns Promise that resolves with updated user data
59
59
  */
60
60
  async function updateProfileWithAvatar(updates, oxyServices, activeSessionId, queryClient, syncSession) {
61
- const data = await (0, authHelpers_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.updateProfile(updates), syncSession);
61
+ const data = await (0, core_1.authenticatedApiCall)(oxyServices, activeSessionId, () => oxyServices.updateProfile(updates), syncSession);
62
62
  // Update cache with server response
63
63
  queryClient.setQueryData(queryKeys_1.queryKeys.accounts.current(), data);
64
64
  if (activeSessionId) {
@@ -6,7 +6,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  * Provides FedCM, popup, and redirect authentication methods.
7
7
  * Uses centralized AuthManager for token and session management.
8
8
  */
9
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
9
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
10
10
  import { OxyServices, CrossDomainAuth, createAuthManager, } from '@oxyhq/core';
11
11
  import { QueryClientProvider } from '@tanstack/react-query';
12
12
  import { createQueryClient } from './hooks/queryClient';
@@ -44,6 +44,8 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
44
44
  // Multi-session management is handled by @oxyhq/services (OxyContext) for RN apps.
45
45
  const sessions = [];
46
46
  const isAuthenticated = !!user;
47
+ // Mutex: prevents concurrent sign-in attempts (FedCM + popup + redirect)
48
+ const signingInRef = useRef(false);
47
49
  const handleAuthSuccess = useCallback(async (session, method = 'credentials') => {
48
50
  await authManager.handleAuthSuccess(session, method);
49
51
  if (session.sessionId) {
@@ -122,6 +124,9 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
122
124
  onAuthStateChange?.(user);
123
125
  }, [user, onAuthStateChange]);
124
126
  const signIn = useCallback(async () => {
127
+ if (signingInRef.current)
128
+ return;
129
+ signingInRef.current = true;
125
130
  setError(null);
126
131
  setIsLoading(true);
127
132
  let selectedMethod = 'popup';
@@ -142,8 +147,14 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
142
147
  catch (err) {
143
148
  handleAuthError(err);
144
149
  }
150
+ finally {
151
+ signingInRef.current = false;
152
+ }
145
153
  }, [crossDomainAuth, preferredAuthMethod, handleAuthSuccess, handleAuthError]);
146
154
  const signInWithFedCM = useCallback(async () => {
155
+ if (signingInRef.current)
156
+ return;
157
+ signingInRef.current = true;
147
158
  setError(null);
148
159
  setIsLoading(true);
149
160
  try {
@@ -153,8 +164,14 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
153
164
  catch (err) {
154
165
  handleAuthError(err);
155
166
  }
167
+ finally {
168
+ signingInRef.current = false;
169
+ }
156
170
  }, [crossDomainAuth, handleAuthSuccess, handleAuthError]);
157
171
  const signInWithPopup = useCallback(async () => {
172
+ if (signingInRef.current)
173
+ return;
174
+ signingInRef.current = true;
158
175
  setError(null);
159
176
  setIsLoading(true);
160
177
  try {
@@ -164,6 +181,9 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
164
181
  catch (err) {
165
182
  handleAuthError(err);
166
183
  }
184
+ finally {
185
+ signingInRef.current = false;
186
+ }
167
187
  }, [crossDomainAuth, handleAuthSuccess, handleAuthError]);
168
188
  const signInWithRedirect = useCallback(() => {
169
189
  setError(null);
@@ -1,10 +1,10 @@
1
1
  import { useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxyhq/core';
2
3
  import { queryKeys, invalidateAccountQueries, invalidateUserQueries } from '../queries/queryKeys';
3
4
  import { useWebOxy } from '../../WebOxyProvider';
4
5
  import { toast } from 'sonner';
5
6
  import { refreshAvatarInStore } from '../../utils/avatarUtils';
6
7
  import { useAuthStore } from '../../stores/authStore';
7
- import { authenticatedApiCall } from '../../utils/authHelpers';
8
8
  /**
9
9
  * Update user profile with optimistic updates and offline queue support
10
10
  */
@@ -1,7 +1,7 @@
1
1
  import { useQuery, useQueries } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxyhq/core';
2
3
  import { queryKeys } from './queryKeys';
3
4
  import { useWebOxy } from '../../WebOxyProvider';
4
- import { authenticatedApiCall } from '../../utils/authHelpers';
5
5
  /**
6
6
  * Get user profile by session ID
7
7
  */
@@ -1,8 +1,8 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxyhq/core';
2
3
  import { queryKeys } from './queryKeys';
3
4
  import { useWebOxy } from '../../WebOxyProvider';
4
5
  import { fetchSessionsWithFallback, mapSessionsToClient } from '../../utils/sessionHelpers';
5
- import { authenticatedApiCall } from '../../utils/authHelpers';
6
6
  /**
7
7
  * Get all active sessions for the current user
8
8
  */
@@ -4,10 +4,9 @@ import { toast } from 'sonner';
4
4
  import { logger } from '@oxyhq/core';
5
5
  import { createDebugLogger } from '@oxyhq/core';
6
6
  const debug = createDebugLogger('SessionSocket');
7
- export function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, onRemoteSignOut, onSessionRemoved }) {
7
+ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, getAccessToken, onRemoteSignOut, onSessionRemoved }) {
8
8
  const socketRef = useRef(null);
9
- const joinedRoomRef = useRef(null);
10
- // Store callbacks in refs to avoid re-joining when they change
9
+ // Store callbacks in refs to avoid reconnecting when they change
11
10
  const refreshSessionsRef = useRef(refreshSessions);
12
11
  const logoutRef = useRef(logout);
13
12
  const clearSessionStateRef = useRef(clearSessionState);
@@ -15,6 +14,7 @@ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, ref
15
14
  const onSessionRemovedRef = useRef(onSessionRemoved);
16
15
  const activeSessionIdRef = useRef(activeSessionId);
17
16
  const currentDeviceIdRef = useRef(currentDeviceId);
17
+ const getAccessTokenRef = useRef(getAccessToken);
18
18
  // Update refs when callbacks change
19
19
  useEffect(() => {
20
20
  refreshSessionsRef.current = refreshSessions;
@@ -24,35 +24,32 @@ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, ref
24
24
  onSessionRemovedRef.current = onSessionRemoved;
25
25
  activeSessionIdRef.current = activeSessionId;
26
26
  currentDeviceIdRef.current = currentDeviceId;
27
- }, [refreshSessions, logout, clearSessionState, onRemoteSignOut, onSessionRemoved, activeSessionId, currentDeviceId]);
27
+ getAccessTokenRef.current = getAccessToken;
28
+ }, [refreshSessions, logout, clearSessionState, onRemoteSignOut, onSessionRemoved, activeSessionId, currentDeviceId, getAccessToken]);
28
29
  useEffect(() => {
29
30
  if (!userId || !baseURL) {
30
31
  // Clean up if userId or baseURL becomes invalid
31
- if (socketRef.current && joinedRoomRef.current) {
32
- socketRef.current.emit('leave', { userId: joinedRoomRef.current });
33
- joinedRoomRef.current = null;
32
+ if (socketRef.current) {
33
+ socketRef.current.disconnect();
34
+ socketRef.current = null;
34
35
  }
35
36
  return;
36
37
  }
37
- const roomId = `user:${userId}`;
38
- // Only create socket if it doesn't exist
39
- if (!socketRef.current) {
40
- socketRef.current = io(baseURL, {
41
- transports: ['websocket'],
42
- });
38
+ // Disconnect previous socket if switching users
39
+ if (socketRef.current) {
40
+ socketRef.current.disconnect();
41
+ socketRef.current = null;
43
42
  }
43
+ // Connect with auth token; use callback so reconnections get a fresh token
44
+ socketRef.current = io(baseURL, {
45
+ transports: ['websocket'],
46
+ auth: (cb) => {
47
+ const token = getAccessTokenRef.current();
48
+ cb({ token: token ?? '' });
49
+ },
50
+ });
44
51
  const socket = socketRef.current;
45
- // Only join if we haven't already joined this room
46
- if (joinedRoomRef.current !== roomId) {
47
- // Leave previous room if switching users
48
- if (joinedRoomRef.current) {
49
- socket.emit('leave', { userId: joinedRoomRef.current });
50
- }
51
- socket.emit('join', { userId: roomId });
52
- joinedRoomRef.current = roomId;
53
- debug.log('Emitting join for room:', roomId);
54
- }
55
- // Set up event handlers (only once per socket instance)
52
+ // Server auto-joins the user to `user:<userId>` room on connection
56
53
  const handleConnect = () => {
57
54
  debug.log('Socket connected:', socket.id);
58
55
  };
@@ -199,11 +196,8 @@ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, ref
199
196
  return () => {
200
197
  socket.off('connect', handleConnect);
201
198
  socket.off('session_update', handleSessionUpdate);
202
- // Only leave on unmount if we're still in this room
203
- if (joinedRoomRef.current === roomId) {
204
- socket.emit('leave', { userId: roomId });
205
- joinedRoomRef.current = null;
206
- }
199
+ socket.disconnect();
200
+ socketRef.current = null;
207
201
  };
208
202
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
209
203
  }
package/dist/esm/index.js CHANGED
@@ -38,11 +38,7 @@ export { useAssets, setOxyAssetInstance } from './hooks/useAssets';
38
38
  export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownloadUrl';
39
39
  export { useFollow, useFollowerCounts } from './hooks/useFollow';
40
40
  export { useFileFiltering } from './hooks/useFileFiltering';
41
- // --- Auth Helpers ---
42
- export { ensureValidToken, withAuthErrorHandling, authenticatedApiCall, isAuthenticationError, SessionSyncRequiredError, AuthenticationFailedError, } from './utils/authHelpers';
43
41
  // --- Error Handlers ---
44
42
  export { handleAuthError, isInvalidSessionError, isTimeoutOrNetworkError, extractErrorMessage, } from './utils/errorHandlers';
45
- // Re-export core for convenience
46
- export { OxyServices, CrossDomainAuth, AuthManager, createAuthManager, createCrossDomainAuth, } from '@oxyhq/core';
47
43
  import { WebOxyProvider as _WebOxyProvider } from './WebOxyProvider';
48
44
  export default _WebOxyProvider;
@@ -1,7 +1,7 @@
1
+ import { authenticatedApiCall } from '@oxyhq/core';
1
2
  import { useAccountStore } from '../stores/accountStore';
2
3
  import { useAuthStore } from '../stores/authStore';
3
4
  import { queryKeys, invalidateUserQueries, invalidateAccountQueries } from '../hooks/queries/queryKeys';
4
- import { authenticatedApiCall } from './authHelpers';
5
5
  /**
6
6
  * Updates file visibility to public for avatar use.
7
7
  * Handles errors gracefully, only logging non-404 errors.
@@ -6,8 +6,9 @@ interface UseSessionSocketProps {
6
6
  logout: () => Promise<void>;
7
7
  clearSessionState: () => Promise<void>;
8
8
  baseURL: string;
9
+ getAccessToken: () => string | null;
9
10
  onRemoteSignOut?: () => void;
10
11
  onSessionRemoved?: (sessionId: string) => void;
11
12
  }
12
- export declare function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, onRemoteSignOut, onSessionRemoved }: UseSessionSocketProps): void;
13
+ export declare function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, getAccessToken, onRemoteSignOut, onSessionRemoved }: UseSessionSocketProps): void;
13
14
  export {};
@@ -36,11 +36,7 @@ export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownlo
36
36
  export { useFollow, useFollowerCounts } from './hooks/useFollow';
37
37
  export { useFileFiltering } from './hooks/useFileFiltering';
38
38
  export type { ViewMode, SortBy, SortOrder } from './hooks/useFileFiltering';
39
- export { ensureValidToken, withAuthErrorHandling, authenticatedApiCall, isAuthenticationError, SessionSyncRequiredError, AuthenticationFailedError, } from './utils/authHelpers';
40
- export type { HandleApiErrorOptions } from './utils/authHelpers';
41
39
  export { handleAuthError, isInvalidSessionError, isTimeoutOrNetworkError, extractErrorMessage, } from './utils/errorHandlers';
42
40
  export type { HandleAuthErrorOptions } from './utils/errorHandlers';
43
- export { OxyServices, CrossDomainAuth, AuthManager, createAuthManager, createCrossDomainAuth, } from '@oxyhq/core';
44
- export type { User, LoginResponse, ApiError, SessionLoginResponse, ClientSession, MinimalUserData, OxyConfig, StorageAdapter, AuthStateChangeCallback, AuthMethod, AuthManagerConfig, CrossDomainAuthOptions, } from '@oxyhq/core';
45
41
  import { WebOxyProvider as _WebOxyProvider } from './WebOxyProvider';
46
42
  export default _WebOxyProvider;
@@ -1,5 +1,4 @@
1
- import type { OxyServices } from '@oxyhq/core';
2
- import type { User } from '@oxyhq/core';
1
+ import type { OxyServices, User } from '@oxyhq/core';
3
2
  import { QueryClient } from '@tanstack/react-query';
4
3
  /**
5
4
  * Updates file visibility to public for avatar use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/auth",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "OxyHQ Web Authentication SDK — headless auth with React hooks for Next.js, Vite, and web apps",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -12,6 +12,7 @@ import {
12
12
  useContext,
13
13
  useEffect,
14
14
  useMemo,
15
+ useRef,
15
16
  useState,
16
17
  type ReactNode,
17
18
  } from 'react';
@@ -112,6 +113,9 @@ export function WebOxyProvider({
112
113
 
113
114
  const isAuthenticated = !!user;
114
115
 
116
+ // Mutex: prevents concurrent sign-in attempts (FedCM + popup + redirect)
117
+ const signingInRef = useRef(false);
118
+
115
119
  const handleAuthSuccess = useCallback(async (
116
120
  session: SessionLoginResponse,
117
121
  method: 'fedcm' | 'popup' | 'redirect' | 'credentials' = 'credentials'
@@ -201,6 +205,8 @@ export function WebOxyProvider({
201
205
  }, [user, onAuthStateChange]);
202
206
 
203
207
  const signIn = useCallback(async () => {
208
+ if (signingInRef.current) return;
209
+ signingInRef.current = true;
204
210
  setError(null);
205
211
  setIsLoading(true);
206
212
 
@@ -221,10 +227,14 @@ export function WebOxyProvider({
221
227
  }
222
228
  } catch (err) {
223
229
  handleAuthError(err);
230
+ } finally {
231
+ signingInRef.current = false;
224
232
  }
225
233
  }, [crossDomainAuth, preferredAuthMethod, handleAuthSuccess, handleAuthError]);
226
234
 
227
235
  const signInWithFedCM = useCallback(async () => {
236
+ if (signingInRef.current) return;
237
+ signingInRef.current = true;
228
238
  setError(null);
229
239
  setIsLoading(true);
230
240
  try {
@@ -232,10 +242,14 @@ export function WebOxyProvider({
232
242
  await handleAuthSuccess(session, 'fedcm');
233
243
  } catch (err) {
234
244
  handleAuthError(err);
245
+ } finally {
246
+ signingInRef.current = false;
235
247
  }
236
248
  }, [crossDomainAuth, handleAuthSuccess, handleAuthError]);
237
249
 
238
250
  const signInWithPopup = useCallback(async () => {
251
+ if (signingInRef.current) return;
252
+ signingInRef.current = true;
239
253
  setError(null);
240
254
  setIsLoading(true);
241
255
  try {
@@ -243,6 +257,8 @@ export function WebOxyProvider({
243
257
  await handleAuthSuccess(session, 'popup');
244
258
  } catch (err) {
245
259
  handleAuthError(err);
260
+ } finally {
261
+ signingInRef.current = false;
246
262
  }
247
263
  }, [crossDomainAuth, handleAuthSuccess, handleAuthError]);
248
264
 
@@ -1,11 +1,11 @@
1
1
  import { useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxyhq/core';
2
3
  import type { User } from '@oxyhq/core';
3
4
  import { queryKeys, invalidateAccountQueries, invalidateUserQueries } from '../queries/queryKeys';
4
5
  import { useWebOxy } from '../../WebOxyProvider';
5
6
  import { toast } from 'sonner';
6
7
  import { refreshAvatarInStore } from '../../utils/avatarUtils';
7
8
  import { useAuthStore } from '../../stores/authStore';
8
- import { authenticatedApiCall } from '../../utils/authHelpers';
9
9
 
10
10
  /**
11
11
  * Update user profile with optimistic updates and offline queue support
@@ -1,8 +1,8 @@
1
1
  import { useQuery, useQueries } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxyhq/core';
2
3
  import type { User } from '@oxyhq/core';
3
4
  import { queryKeys } from './queryKeys';
4
5
  import { useWebOxy } from '../../WebOxyProvider';
5
- import { authenticatedApiCall } from '../../utils/authHelpers';
6
6
 
7
7
  /**
8
8
  * Get user profile by session ID
@@ -1,9 +1,9 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxyhq/core';
2
3
  import type { ClientSession } from '@oxyhq/core';
3
4
  import { queryKeys } from './queryKeys';
4
5
  import { useWebOxy } from '../../WebOxyProvider';
5
6
  import { fetchSessionsWithFallback, mapSessionsToClient } from '../../utils/sessionHelpers';
6
- import { authenticatedApiCall } from '../../utils/authHelpers';
7
7
 
8
8
  /**
9
9
  * Get all active sessions for the current user
@@ -14,15 +14,15 @@ interface UseSessionSocketProps {
14
14
  logout: () => Promise<void>;
15
15
  clearSessionState: () => Promise<void>;
16
16
  baseURL: string;
17
+ getAccessToken: () => string | null;
17
18
  onRemoteSignOut?: () => void;
18
19
  onSessionRemoved?: (sessionId: string) => void;
19
20
  }
20
21
 
21
- export function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, onRemoteSignOut, onSessionRemoved }: UseSessionSocketProps) {
22
+ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions, logout, clearSessionState, baseURL, getAccessToken, onRemoteSignOut, onSessionRemoved }: UseSessionSocketProps) {
22
23
  const socketRef = useRef<any>(null);
23
- const joinedRoomRef = useRef<string | null>(null);
24
-
25
- // Store callbacks in refs to avoid re-joining when they change
24
+
25
+ // Store callbacks in refs to avoid reconnecting when they change
26
26
  const refreshSessionsRef = useRef(refreshSessions);
27
27
  const logoutRef = useRef(logout);
28
28
  const clearSessionStateRef = useRef(clearSessionState);
@@ -30,6 +30,7 @@ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, ref
30
30
  const onSessionRemovedRef = useRef(onSessionRemoved);
31
31
  const activeSessionIdRef = useRef(activeSessionId);
32
32
  const currentDeviceIdRef = useRef(currentDeviceId);
33
+ const getAccessTokenRef = useRef(getAccessToken);
33
34
 
34
35
  // Update refs when callbacks change
35
36
  useEffect(() => {
@@ -40,42 +41,36 @@ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, ref
40
41
  onSessionRemovedRef.current = onSessionRemoved;
41
42
  activeSessionIdRef.current = activeSessionId;
42
43
  currentDeviceIdRef.current = currentDeviceId;
43
- }, [refreshSessions, logout, clearSessionState, onRemoteSignOut, onSessionRemoved, activeSessionId, currentDeviceId]);
44
+ getAccessTokenRef.current = getAccessToken;
45
+ }, [refreshSessions, logout, clearSessionState, onRemoteSignOut, onSessionRemoved, activeSessionId, currentDeviceId, getAccessToken]);
44
46
 
45
47
  useEffect(() => {
46
48
  if (!userId || !baseURL) {
47
49
  // Clean up if userId or baseURL becomes invalid
48
- if (socketRef.current && joinedRoomRef.current) {
49
- socketRef.current.emit('leave', { userId: joinedRoomRef.current });
50
- joinedRoomRef.current = null;
50
+ if (socketRef.current) {
51
+ socketRef.current.disconnect();
52
+ socketRef.current = null;
51
53
  }
52
54
  return;
53
55
  }
54
56
 
55
- const roomId = `user:${userId}`;
56
-
57
- // Only create socket if it doesn't exist
58
- if (!socketRef.current) {
59
- socketRef.current = io(baseURL, {
60
- transports: ['websocket'],
61
- });
57
+ // Disconnect previous socket if switching users
58
+ if (socketRef.current) {
59
+ socketRef.current.disconnect();
60
+ socketRef.current = null;
62
61
  }
63
- const socket = socketRef.current;
64
62
 
65
- // Only join if we haven't already joined this room
66
- if (joinedRoomRef.current !== roomId) {
67
- // Leave previous room if switching users
68
- if (joinedRoomRef.current) {
69
- socket.emit('leave', { userId: joinedRoomRef.current });
70
- }
71
-
72
- socket.emit('join', { userId: roomId });
73
- joinedRoomRef.current = roomId;
74
-
75
- debug.log('Emitting join for room:', roomId);
76
- }
63
+ // Connect with auth token; use callback so reconnections get a fresh token
64
+ socketRef.current = io(baseURL, {
65
+ transports: ['websocket'],
66
+ auth: (cb: (data: { token: string }) => void) => {
67
+ const token = getAccessTokenRef.current();
68
+ cb({ token: token ?? '' });
69
+ },
70
+ });
71
+ const socket = socketRef.current;
77
72
 
78
- // Set up event handlers (only once per socket instance)
73
+ // Server auto-joins the user to `user:<userId>` room on connection
79
74
  const handleConnect = () => {
80
75
  debug.log('Socket connected:', socket.id);
81
76
  };
@@ -222,12 +217,8 @@ export function useSessionSocket({ userId, activeSessionId, currentDeviceId, ref
222
217
  return () => {
223
218
  socket.off('connect', handleConnect);
224
219
  socket.off('session_update', handleSessionUpdate);
225
-
226
- // Only leave on unmount if we're still in this room
227
- if (joinedRoomRef.current === roomId) {
228
- socket.emit('leave', { userId: roomId });
229
- joinedRoomRef.current = null;
230
- }
220
+ socket.disconnect();
221
+ socketRef.current = null;
231
222
  };
232
223
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
233
224
  }
package/src/index.ts CHANGED
@@ -96,17 +96,6 @@ export { useFollow, useFollowerCounts } from './hooks/useFollow';
96
96
  export { useFileFiltering } from './hooks/useFileFiltering';
97
97
  export type { ViewMode, SortBy, SortOrder } from './hooks/useFileFiltering';
98
98
 
99
- // --- Auth Helpers ---
100
- export {
101
- ensureValidToken,
102
- withAuthErrorHandling,
103
- authenticatedApiCall,
104
- isAuthenticationError,
105
- SessionSyncRequiredError,
106
- AuthenticationFailedError,
107
- } from './utils/authHelpers';
108
- export type { HandleApiErrorOptions } from './utils/authHelpers';
109
-
110
99
  // --- Error Handlers ---
111
100
  export {
112
101
  handleAuthError,
@@ -116,29 +105,5 @@ export {
116
105
  } from './utils/errorHandlers';
117
106
  export type { HandleAuthErrorOptions } from './utils/errorHandlers';
118
107
 
119
- // Re-export core for convenience
120
- export {
121
- OxyServices,
122
- CrossDomainAuth,
123
- AuthManager,
124
- createAuthManager,
125
- createCrossDomainAuth,
126
- } from '@oxyhq/core';
127
-
128
- export type {
129
- User,
130
- LoginResponse,
131
- ApiError,
132
- SessionLoginResponse,
133
- ClientSession,
134
- MinimalUserData,
135
- OxyConfig,
136
- StorageAdapter,
137
- AuthStateChangeCallback,
138
- AuthMethod,
139
- AuthManagerConfig,
140
- CrossDomainAuthOptions,
141
- } from '@oxyhq/core';
142
-
143
108
  import { WebOxyProvider as _WebOxyProvider } from './WebOxyProvider';
144
109
  export default _WebOxyProvider;
@@ -1,10 +1,9 @@
1
- import type { OxyServices } from '@oxyhq/core';
2
- import type { User } from '@oxyhq/core';
1
+ import { authenticatedApiCall } from '@oxyhq/core';
2
+ import type { OxyServices, User } from '@oxyhq/core';
3
3
  import { useAccountStore } from '../stores/accountStore';
4
4
  import { useAuthStore } from '../stores/authStore';
5
5
  import { QueryClient } from '@tanstack/react-query';
6
6
  import { queryKeys, invalidateUserQueries, invalidateAccountQueries } from '../hooks/queries/queryKeys';
7
- import { authenticatedApiCall } from './authHelpers';
8
7
 
9
8
  /**
10
9
  * Updates file visibility to public for avatar use.
@@ -1,183 +0,0 @@
1
- /**
2
- * Authentication helper utilities to reduce code duplication across hooks and utilities.
3
- * These functions handle common token validation and authentication error patterns.
4
- */
5
-
6
- import type { OxyServices } from '@oxyhq/core';
7
-
8
- /**
9
- * Error thrown when session sync is required
10
- */
11
- export class SessionSyncRequiredError extends Error {
12
- constructor(message = 'Session needs to be synced. Please try again.') {
13
- super(message);
14
- this.name = 'SessionSyncRequiredError';
15
- }
16
- }
17
-
18
- /**
19
- * Error thrown when authentication fails
20
- */
21
- export class AuthenticationFailedError extends Error {
22
- constructor(message = 'Authentication failed. Please sign in again.') {
23
- super(message);
24
- this.name = 'AuthenticationFailedError';
25
- }
26
- }
27
-
28
- /**
29
- * Ensures a valid token exists before making authenticated API calls.
30
- * If no valid token exists and an active session ID is available,
31
- * attempts to refresh the token using the session.
32
- *
33
- * @param oxyServices - The OxyServices instance
34
- * @param activeSessionId - The active session ID (if available)
35
- * @throws {SessionSyncRequiredError} If the session needs to be synced (offline session)
36
- * @throws {Error} If token refresh fails for other reasons
37
- *
38
- * @example
39
- * ```ts
40
- * // In a mutation or query function:
41
- * await ensureValidToken(oxyServices, activeSessionId);
42
- * return await oxyServices.updateProfile(updates);
43
- * ```
44
- */
45
- export async function ensureValidToken(
46
- oxyServices: OxyServices,
47
- activeSessionId: string | null | undefined
48
- ): Promise<void> {
49
- if (oxyServices.hasValidToken() || !activeSessionId) {
50
- return;
51
- }
52
-
53
- try {
54
- await oxyServices.getTokenBySession(activeSessionId);
55
- } catch (tokenError) {
56
- const errorMessage = tokenError instanceof Error ? tokenError.message : String(tokenError);
57
-
58
- if (errorMessage.includes('AUTH_REQUIRED_OFFLINE_SESSION') || errorMessage.includes('offline')) {
59
- throw new SessionSyncRequiredError();
60
- }
61
-
62
- throw tokenError;
63
- }
64
- }
65
-
66
- /**
67
- * Options for handling API authentication errors
68
- */
69
- export interface HandleApiErrorOptions {
70
- /** Optional callback to attempt session sync and retry */
71
- syncSession?: () => Promise<unknown>;
72
- /** The active session ID for retry attempts */
73
- activeSessionId?: string | null;
74
- /** The OxyServices instance for retry attempts */
75
- oxyServices?: OxyServices;
76
- }
77
-
78
- /**
79
- * Checks if an error is an authentication error (401 or auth-related message)
80
- *
81
- * @param error - The error to check
82
- * @returns True if the error is an authentication error
83
- */
84
- export function isAuthenticationError(error: unknown): boolean {
85
- if (!error || typeof error !== 'object') {
86
- return false;
87
- }
88
-
89
- const errorObj = error as { message?: string; status?: number; response?: { status?: number } };
90
- const errorMessage = errorObj.message || '';
91
- const status = errorObj.status || errorObj.response?.status;
92
-
93
- return (
94
- status === 401 ||
95
- errorMessage.includes('Authentication required') ||
96
- errorMessage.includes('Invalid or missing authorization header')
97
- );
98
- }
99
-
100
- /**
101
- * Wraps an API call with authentication error handling.
102
- * If an authentication error occurs, it can optionally attempt to sync the session and retry.
103
- *
104
- * @param apiCall - The API call function to execute
105
- * @param options - Optional error handling configuration
106
- * @returns The result of the API call
107
- * @throws {AuthenticationFailedError} If authentication fails and cannot be recovered
108
- * @throws {Error} If the API call fails for non-auth reasons
109
- *
110
- * @example
111
- * ```ts
112
- * // Simple usage:
113
- * const result = await withAuthErrorHandling(
114
- * () => oxyServices.updateProfile(updates)
115
- * );
116
- *
117
- * // With retry on auth failure:
118
- * const result = await withAuthErrorHandling(
119
- * () => oxyServices.updateProfile(updates),
120
- * { syncSession, activeSessionId, oxyServices }
121
- * );
122
- * ```
123
- */
124
- export async function withAuthErrorHandling<T>(
125
- apiCall: () => Promise<T>,
126
- options?: HandleApiErrorOptions
127
- ): Promise<T> {
128
- try {
129
- return await apiCall();
130
- } catch (error) {
131
- if (!isAuthenticationError(error)) {
132
- throw error;
133
- }
134
-
135
- // If we have sync capabilities, try to recover
136
- if (options?.syncSession && options?.activeSessionId && options?.oxyServices) {
137
- try {
138
- await options.syncSession();
139
- await options.oxyServices.getTokenBySession(options.activeSessionId);
140
- // Retry the API call after refreshing token
141
- return await apiCall();
142
- } catch {
143
- throw new AuthenticationFailedError();
144
- }
145
- }
146
-
147
- throw new AuthenticationFailedError();
148
- }
149
- }
150
-
151
- /**
152
- * Combines token validation and auth error handling for a complete authenticated API call.
153
- * This is the recommended helper for most authenticated API operations.
154
- *
155
- * @param oxyServices - The OxyServices instance
156
- * @param activeSessionId - The active session ID
157
- * @param apiCall - The API call function to execute
158
- * @param syncSession - Optional callback to sync session on auth failure
159
- * @returns The result of the API call
160
- *
161
- * @example
162
- * ```ts
163
- * return await authenticatedApiCall(
164
- * oxyServices,
165
- * activeSessionId,
166
- * () => oxyServices.updateProfile(updates)
167
- * );
168
- * ```
169
- */
170
- export async function authenticatedApiCall<T>(
171
- oxyServices: OxyServices,
172
- activeSessionId: string | null | undefined,
173
- apiCall: () => Promise<T>,
174
- syncSession?: () => Promise<unknown>
175
- ): Promise<T> {
176
- await ensureValidToken(oxyServices, activeSessionId);
177
-
178
- return withAuthErrorHandling(apiCall, {
179
- syncSession,
180
- activeSessionId,
181
- oxyServices,
182
- });
183
- }