@oxyhq/services 10.2.2 → 10.2.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.
@@ -60,6 +60,9 @@ export interface OxyContextState {
60
60
  isAuthenticated: boolean;
61
61
  isLoading: boolean;
62
62
  isTokenReady: boolean;
63
+ hasAccessToken: boolean;
64
+ canUsePrivateApi: boolean;
65
+ isPrivateApiPending: boolean;
63
66
  /**
64
67
  * Whether the initial auth determination has concluded.
65
68
  *
@@ -251,6 +254,35 @@ const COOKIE_RESTORE_TIMEOUT = 3000;
251
254
  */
252
255
  const COLD_BOOT_OVERALL_DEADLINE = 20000;
253
256
 
257
+ function getHttpStatus(error: unknown): number | undefined {
258
+ if (!error || typeof error !== 'object') {
259
+ return undefined;
260
+ }
261
+
262
+ if ('status' in error) {
263
+ const status = (error as { status?: unknown }).status;
264
+ if (typeof status === 'number') {
265
+ return status;
266
+ }
267
+ }
268
+
269
+ if ('response' in error) {
270
+ const response = (error as { response?: unknown }).response;
271
+ if (response && typeof response === 'object' && 'status' in response) {
272
+ const status = (response as { status?: unknown }).status;
273
+ if (typeof status === 'number') {
274
+ return status;
275
+ }
276
+ }
277
+ }
278
+
279
+ return undefined;
280
+ }
281
+
282
+ function isUnauthorizedStatus(error: unknown): boolean {
283
+ return getHttpStatus(error) === 401;
284
+ }
285
+
254
286
  /**
255
287
  * Whether `idpOrigin` is a same-site, first-party host of the current page —
256
288
  * i.e. it shares the page's registrable apex (last two labels), so a "no
@@ -273,7 +305,10 @@ function isSameSiteIdP(idpOrigin: string): boolean {
273
305
  let idpHostname: string;
274
306
  try {
275
307
  idpHostname = new URL(idpOrigin).hostname;
276
- } catch {
308
+ } catch (parseError) {
309
+ if (__DEV__) {
310
+ loggerUtil.debug('Invalid IdP origin while checking same-site session status', { component: 'OxyContext' }, parseError as unknown);
311
+ }
277
312
  return false;
278
313
  }
279
314
  const pageHostname = window.location.hostname;
@@ -384,6 +419,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
384
419
  );
385
420
 
386
421
  const [tokenReady, setTokenReady] = useState(true);
422
+ const [hasAccessToken, setHasAccessToken] = useState(() => Boolean(oxyServices.getAccessToken()));
387
423
  // Whether the FIRST cold-boot auth restore has concluded. Starts `false`
388
424
  // (auth undetermined) and flips to `true` exactly once — monotonic, never
389
425
  // reverts. It now flips the MOMENT a session commits (the common reload case
@@ -393,6 +429,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
393
429
  // `isAuthResolved` on the context type for the consumer contract.
394
430
  const [authResolved, setAuthResolved] = useState(false);
395
431
  const authResolvedRef = useRef(false);
432
+ const userRef = useRef<User | null>(user);
433
+ const isAuthenticatedRef = useRef(isAuthenticated);
434
+ userRef.current = user;
435
+ isAuthenticatedRef.current = isAuthenticated;
396
436
  const [initialized, setInitialized] = useState(false);
397
437
  const [ssoCallbackIntercepting, setSsoCallbackIntercepting] = useState(false);
398
438
  const setAuthState = useAuthStore.setState;
@@ -633,6 +673,43 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
633
673
  updateSessionsRef.current = updateSessions;
634
674
  const clearSessionStateRef = useRef(clearSessionState);
635
675
  clearSessionStateRef.current = clearSessionState;
676
+ const clearingInvalidTokenRef = useRef(false);
677
+
678
+ useEffect(() => {
679
+ const handleTokenChange = (accessToken: string | null) => {
680
+ setHasAccessToken(Boolean(accessToken));
681
+ if (accessToken) {
682
+ setTokenReady(true);
683
+ return;
684
+ }
685
+
686
+ if (userRef.current || isAuthenticatedRef.current) {
687
+ setTokenReady(false);
688
+ if (clearingInvalidTokenRef.current) {
689
+ return;
690
+ }
691
+ clearingInvalidTokenRef.current = true;
692
+ clearSessionStateRef.current()
693
+ .catch((clearError) => {
694
+ logger('Failed to clear invalidated auth session', clearError);
695
+ })
696
+ .finally(() => {
697
+ clearingInvalidTokenRef.current = false;
698
+ if (authResolvedRef.current) {
699
+ setTokenReady(true);
700
+ }
701
+ });
702
+ return;
703
+ }
704
+
705
+ if (authResolvedRef.current) {
706
+ setTokenReady(true);
707
+ }
708
+ };
709
+
710
+ handleTokenChange(oxyServices.getAccessToken());
711
+ return oxyServices.onTokensChanged(handleTokenChange);
712
+ }, [logger, oxyServices]);
636
713
 
637
714
  // Durable, navigation-safe session persistence.
638
715
  //
@@ -649,12 +726,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
649
726
  await readyStorage.setItem(storageKeys.activeSessionId, sessionId);
650
727
  const existingIds = await readyStorage.getItem(storageKeys.sessionIds);
651
728
  let sessionIds: string[] = [];
652
- try { sessionIds = existingIds ? JSON.parse(existingIds) : []; } catch { /* corrupted storage */ }
729
+ try {
730
+ sessionIds = existingIds ? JSON.parse(existingIds) : [];
731
+ } catch (parseError) {
732
+ logger('Failed to parse persisted session ids; replacing corrupted storage value', parseError);
733
+ }
653
734
  if (!sessionIds.includes(sessionId)) {
654
735
  sessionIds.push(sessionId);
655
736
  await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
656
737
  }
657
- }, [getReadyStorage, storageKeys.activeSessionId, storageKeys.sessionIds]);
738
+ }, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds]);
658
739
 
659
740
  // Refs so the cold-boot restore can plant session state without widening its
660
741
  // dependency array (mirrors the existing ref pattern above).
@@ -1397,7 +1478,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1397
1478
  let fullUser: User;
1398
1479
  try {
1399
1480
  fullUser = await oxyServices.getCurrentUser();
1400
- } catch {
1481
+ } catch (profileError) {
1482
+ if (__DEV__) {
1483
+ loggerUtil.debug('Failed to fetch full user after web session; using session user fallback', { component: 'OxyContext', method: 'handleWebSSOSession' }, profileError as unknown);
1484
+ }
1401
1485
  // If the profile fetch fails, fall back to the minimal data from the session
1402
1486
  // so the user is still logged in (the store accepts User, but the shapes overlap at runtime).
1403
1487
  fullUser = session.user as unknown as User;
@@ -1615,16 +1699,25 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1615
1699
 
1616
1700
  // Load managed accounts when authenticated
1617
1701
  const refreshManagedAccounts = useCallback(async (): Promise<void> => {
1618
- if (!isAuthenticated) return;
1702
+ if (!isAuthenticated || !tokenReady || !oxyServices.getAccessToken()) {
1703
+ setManagedAccounts([]);
1704
+ return;
1705
+ }
1706
+
1619
1707
  try {
1620
1708
  const accounts = await oxyServices.getManagedAccounts();
1621
1709
  setManagedAccounts(accounts);
1622
1710
  } catch (err) {
1711
+ if (isUnauthorizedStatus(err)) {
1712
+ setManagedAccounts([]);
1713
+ await clearSessionStateRef.current();
1714
+ return;
1715
+ }
1623
1716
  if (__DEV__) {
1624
1717
  loggerUtil.debug('Failed to load managed accounts', { component: 'OxyContext' }, err as unknown);
1625
1718
  }
1626
1719
  }
1627
- }, [isAuthenticated, oxyServices]);
1720
+ }, [isAuthenticated, oxyServices, tokenReady]);
1628
1721
 
1629
1722
  useEffect(() => {
1630
1723
  if (isAuthenticated && initialized && tokenReady) {
@@ -1638,9 +1731,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1638
1731
  // Persist to storage
1639
1732
  if (storage) {
1640
1733
  if (userId) {
1641
- storage.setItem(`${storageKeyPrefix}_acting_as`, userId).catch(() => {});
1734
+ storage.setItem(`${storageKeyPrefix}_acting_as`, userId).catch((persistError) => {
1735
+ loggerUtil.debug('Failed to persist acting-as account', { component: 'OxyContext' }, persistError as unknown);
1736
+ });
1642
1737
  } else {
1643
- storage.removeItem(`${storageKeyPrefix}_acting_as`).catch(() => {});
1738
+ storage.removeItem(`${storageKeyPrefix}_acting_as`).catch((persistError) => {
1739
+ loggerUtil.debug('Failed to clear acting-as account', { component: 'OxyContext' }, persistError as unknown);
1740
+ });
1644
1741
  }
1645
1742
  }
1646
1743
  }, [oxyServices, storage, storageKeyPrefix]);
@@ -1651,6 +1748,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1651
1748
  return account;
1652
1749
  }, [oxyServices, refreshManagedAccounts]);
1653
1750
 
1751
+ const canUsePrivateApi = authResolved && isAuthenticated && tokenReady && hasAccessToken;
1752
+ const isPrivateApiPending = !authResolved || (isAuthenticated && (!tokenReady || !hasAccessToken));
1753
+
1654
1754
  const contextValue: OxyContextState = useMemo(() => ({
1655
1755
  user,
1656
1756
  sessions,
@@ -1658,6 +1758,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1658
1758
  isAuthenticated,
1659
1759
  isLoading,
1660
1760
  isTokenReady: tokenReady,
1761
+ hasAccessToken,
1762
+ canUsePrivateApi,
1763
+ isPrivateApiPending,
1661
1764
  isAuthResolved: authResolved,
1662
1765
  isStorageReady: storage !== null,
1663
1766
  error,
@@ -1701,6 +1804,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1701
1804
  currentNativeLanguageName,
1702
1805
  error,
1703
1806
  getDeviceSessions,
1807
+ hasAccessToken,
1808
+ canUsePrivateApi,
1809
+ isPrivateApiPending,
1704
1810
  getPublicKey,
1705
1811
  hasIdentity,
1706
1812
  isAuthenticated,
@@ -1717,6 +1823,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1717
1823
  storage,
1718
1824
  switchSessionForContext,
1719
1825
  tokenReady,
1826
+ hasAccessToken,
1827
+ canUsePrivateApi,
1828
+ isPrivateApiPending,
1720
1829
  authResolved,
1721
1830
  updateDeviceName,
1722
1831
  clearAllAccountData,
@@ -1765,6 +1874,9 @@ const LOADING_STATE: OxyContextState = {
1765
1874
  isAuthenticated: false,
1766
1875
  isLoading: true,
1767
1876
  isTokenReady: false,
1877
+ hasAccessToken: false,
1878
+ canUsePrivateApi: false,
1879
+ isPrivateApiPending: true,
1768
1880
  isAuthResolved: false,
1769
1881
  isStorageReady: false,
1770
1882
  error: null,
@@ -41,6 +41,22 @@ export interface AuthState {
41
41
  /** Whether the auth token is ready for API calls */
42
42
  isReady: boolean;
43
43
 
44
+ /** Whether the current OxyServices instance currently holds an access token */
45
+ hasAccessToken: boolean;
46
+
47
+ /**
48
+ * True only when auth cold-boot is resolved, the user is authenticated, and a
49
+ * bearer token is available for private backend requests.
50
+ */
51
+ canUsePrivateApi: boolean;
52
+
53
+ /**
54
+ * True while the SDK is still resolving auth or an authenticated session is
55
+ * waiting for its bearer token. Use this to hold private API screens in a
56
+ * loading state instead of firing unauthenticated requests.
57
+ */
58
+ isPrivateApiPending: boolean;
59
+
44
60
  /**
45
61
  * Whether the initial auth determination has concluded.
46
62
  *
@@ -107,6 +123,9 @@ export function useAuth(): UseAuthReturn {
107
123
  isAuthenticated,
108
124
  isLoading,
109
125
  isTokenReady,
126
+ hasAccessToken,
127
+ canUsePrivateApi,
128
+ isPrivateApiPending,
110
129
  isAuthResolved,
111
130
  error,
112
131
  signIn: oxySignIn,
@@ -196,6 +215,9 @@ export function useAuth(): UseAuthReturn {
196
215
  isAuthenticated,
197
216
  isLoading: isLoading || !isAuthResolved,
198
217
  isReady: isTokenReady,
218
+ hasAccessToken,
219
+ canUsePrivateApi,
220
+ isPrivateApiPending,
199
221
  isAuthResolved,
200
222
  error,
201
223