@oxyhq/services 11.1.1 → 12.1.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.
Files changed (73) hide show
  1. package/lib/commonjs/index.js +2 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyProvider.js +0 -2
  4. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +117 -20
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +24 -3
  8. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  9. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +286 -0
  10. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -0
  11. package/lib/commonjs/ui/context/silentSessionRestore.js +65 -0
  12. package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -0
  13. package/lib/commonjs/ui/index.js.map +1 -1
  14. package/lib/commonjs/ui/utils/storageHelpers.js +2 -1
  15. package/lib/commonjs/ui/utils/storageHelpers.js.map +1 -1
  16. package/lib/module/index.js +2 -0
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/ui/components/OxyProvider.js +0 -2
  19. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  20. package/lib/module/ui/context/OxyContext.js +118 -21
  21. package/lib/module/ui/context/OxyContext.js.map +1 -1
  22. package/lib/module/ui/context/hooks/useAuthOperations.js +24 -3
  23. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  24. package/lib/module/ui/context/inSessionTokenRefresh.js +281 -0
  25. package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -0
  26. package/lib/module/ui/context/silentSessionRestore.js +61 -0
  27. package/lib/module/ui/context/silentSessionRestore.js.map +1 -0
  28. package/lib/module/ui/index.js +2 -0
  29. package/lib/module/ui/index.js.map +1 -1
  30. package/lib/module/ui/utils/storageHelpers.js +2 -1
  31. package/lib/module/ui/utils/storageHelpers.js.map +1 -1
  32. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +0 -10
  35. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +9 -1
  37. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +105 -0
  39. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +42 -0
  41. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/types/navigation.d.ts +0 -17
  44. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/utils/storageHelpers.d.ts +11 -0
  46. package/lib/typescript/commonjs/ui/utils/storageHelpers.d.ts.map +1 -1
  47. package/lib/typescript/module/index.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/context/OxyContext.d.ts +0 -10
  50. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +9 -1
  52. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  53. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +105 -0
  54. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -0
  55. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +42 -0
  56. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -0
  57. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/types/navigation.d.ts +0 -17
  59. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/utils/storageHelpers.d.ts +11 -0
  61. package/lib/typescript/module/ui/utils/storageHelpers.d.ts.map +1 -1
  62. package/lib/typescript/types/react-native-classname.d.ts +39 -0
  63. package/package.json +3 -3
  64. package/src/index.ts +2 -0
  65. package/src/types/react-native-classname.d.ts +39 -0
  66. package/src/ui/components/OxyProvider.tsx +0 -2
  67. package/src/ui/context/OxyContext.tsx +119 -32
  68. package/src/ui/context/hooks/useAuthOperations.ts +35 -2
  69. package/src/ui/context/inSessionTokenRefresh.ts +298 -0
  70. package/src/ui/context/silentSessionRestore.ts +67 -0
  71. package/src/ui/index.ts +2 -0
  72. package/src/ui/types/navigation.ts +0 -17
  73. package/src/ui/utils/storageHelpers.ts +12 -0
@@ -18,7 +18,6 @@ import type { ClientSession } from '@oxyhq/core';
18
18
  import {
19
19
  runColdBoot,
20
20
  resolveCentralAuthUrl,
21
- autoDetectAuthWebUrl,
22
21
  registrableApex,
23
22
  SSO_CALLBACK_PATH,
24
23
  ssoStateKey,
@@ -28,6 +27,7 @@ import {
28
27
  ssoAttemptedKey,
29
28
  isCentralIdPOrigin,
30
29
  guardActive,
30
+ allowSsoBounce,
31
31
  ssoNavigate,
32
32
  buildSsoBounceUrl,
33
33
  consumeSsoReturn,
@@ -53,6 +53,8 @@ import { useAccountStore } from '../stores/accountStore';
53
53
  import { logger as loggerUtil } from '@oxyhq/core';
54
54
  import { useWebSSO, isWebBrowser } from '../hooks/useWebSSO';
55
55
  import { buildSilentGuardKey } from '../../utils/silentGuardKey';
56
+ import { createInSessionRefreshHandler, startTokenRefreshScheduler } from './inSessionTokenRefresh';
57
+ import { mintSessionViaPerApexIframe, selectActiveRefreshAccount } from './silentSessionRestore';
56
58
 
57
59
  export interface OxyContextState {
58
60
  user: User | null;
@@ -196,16 +198,6 @@ export interface OxyContextProviderProps {
196
198
  * for the cross-app device sign-in flow. See {@link OxyContextState.clientId}.
197
199
  */
198
200
  clientId?: string;
199
- /**
200
- * When `true`, skips ONLY the terminal `sso-bounce` cold-boot step — the
201
- * force-redirect to `auth.<apex>/sso?prompt=none` that fires for a visitor
202
- * with no recoverable local session. Every other cold-boot step still runs
203
- * (callback consume, FedCM silent, `/auth/silent` iframe, stored-session,
204
- * cookie-restore), so a returning signed-in user is still silently
205
- * restored; only the bounce for a truly anonymous visitor is suppressed.
206
- * Default `false`.
207
- */
208
- disableAutoSso?: boolean;
209
201
  onAuthStateChange?: (user: User | null) => void;
210
202
  onError?: (error: ApiError) => void;
211
203
  }
@@ -462,7 +454,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
462
454
  authRedirectUri,
463
455
  storageKeyPrefix = 'oxy_session',
464
456
  clientId: clientIdProp,
465
- disableAutoSso = false,
466
457
  onAuthStateChange,
467
458
  onError,
468
459
  }) => {
@@ -631,6 +622,25 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
631
622
  return storageReady.promise;
632
623
  }, [storageReady]);
633
624
 
625
+ // Clear the durable "had a signed-in Oxy session before" hint.
626
+ //
627
+ // The hint (`storageKeys.priorSession`) is WRITTEN whenever a session is
628
+ // established/restored — every commit funnels through `persistSessionDurably`,
629
+ // and the stored-session reload winner sets it directly to backfill
630
+ // pre-existing installs. It lives in the SAME `storageKeyPrefix`-scoped
631
+ // durable store as the session ids, so it SURVIVES a session expiring; it is
632
+ // cleared ONLY here, on EXPLICIT full sign-out (wired into `clearAllAccountData`
633
+ // and the `useAuthOperations` logout paths — never the passive token-expiry
634
+ // path). At cold boot the hint is read into `hadPriorSession` and feeds
635
+ // `allowSsoBounce`: a RETURNING visitor (hint present) whose local session has
636
+ // lapsed still gets ONE terminal `/sso` establish bounce so a central-only
637
+ // cross-domain session recovers, while a truly first-time anonymous visitor is
638
+ // never force-redirected.
639
+ const clearPriorSessionHint = useCallback(async (): Promise<void> => {
640
+ const readyStorage = await getReadyStorage();
641
+ await readyStorage.removeItem(storageKeys.priorSession);
642
+ }, [getReadyStorage, storageKeys.priorSession]);
643
+
634
644
  useEffect(() => {
635
645
  let mounted = true;
636
646
  createPlatformStorage()
@@ -717,6 +727,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
717
727
  updateSessions,
718
728
  saveActiveSessionId,
719
729
  clearSessionState,
730
+ clearPriorSessionHint,
720
731
  switchSession,
721
732
  applyLanguagePreference,
722
733
  onAuthStateChange,
@@ -745,12 +756,18 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
745
756
  // Clear session state (sessions, activeSessionId, storage)
746
757
  await clearSessionState();
747
758
 
759
+ // Explicit FULL sign-out: drop the durable returning-user hint so the next
760
+ // cold boot treats this device as a first-time anonymous visitor (no forced
761
+ // `/sso` bounce). NOT done on the passive token-expiry path, so an expired
762
+ // session still recovers via a returning-user bounce.
763
+ await clearPriorSessionHint();
764
+
748
765
  // Reset account store
749
766
  useAccountStore.getState().reset();
750
767
 
751
768
  // Clear HTTP service cache
752
769
  oxyServices.clearCache();
753
- }, [queryClient, storage, clearSessionState, logger, oxyServices]);
770
+ }, [queryClient, storage, clearSessionState, clearPriorSessionHint, logger, oxyServices]);
754
771
 
755
772
  const { getDeviceSessions, logoutAllDeviceSessions, updateDeviceName } = useDeviceManagement({
756
773
  oxyServices,
@@ -807,6 +824,34 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
807
824
  return oxyServices.onTokensChanged(handleTokenChange);
808
825
  }, [logger, oxyServices]);
809
826
 
827
+ // In-session access-token refresh (SDK-owned; every Expo RP inherits it).
828
+ //
829
+ // The services path never installed an `authRefreshHandler`, so the owner
830
+ // `HttpService.refreshAccessToken` short-circuited to null and a 15-minute
831
+ // access token expired with the app open while `isAuthenticated` stayed true —
832
+ // a zombie logged-in state whose cross-apex feed calls 401-looped. Here we (1)
833
+ // install a handler that re-mints a fresh token WITHOUT a reload using the SAME
834
+ // durable silent-restore arms cold boot uses, and (2) start a proactive
835
+ // scheduler that refreshes ~60s before expiry (and on tab-focus / app-
836
+ // foreground) so the common case never even hits the reactive 401 path. The
837
+ // linked client (`createLinkedClient`) delegates its refresh back to this owner
838
+ // handler, so it is fixed for free.
839
+ //
840
+ // Runs once per `oxyServices` instance (stable, ref-constructed), and BEFORE
841
+ // any cold-boot request can 401: cold boot is gated on async storage init, so
842
+ // this synchronous mount effect installs the handler first. On cleanup the
843
+ // handler is detached and the scheduler torn down (timer + foreground listener)
844
+ // so nothing leaks across a provider remount. `setAuthRefreshHandler` is
845
+ // optional-chained to tolerate partial test stubs.
846
+ useEffect(() => {
847
+ oxyServices.httpService.setAuthRefreshHandler?.(createInSessionRefreshHandler(oxyServices));
848
+ const scheduler = startTokenRefreshScheduler(oxyServices);
849
+ return () => {
850
+ scheduler.dispose();
851
+ oxyServices.httpService.setAuthRefreshHandler?.(null);
852
+ };
853
+ }, [oxyServices]);
854
+
810
855
  // Durable, navigation-safe session persistence.
811
856
  //
812
857
  // Writes the active-session id and appends the session id to the durable
@@ -831,7 +876,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
831
876
  sessionIds.push(sessionId);
832
877
  await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
833
878
  }
834
- }, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds]);
879
+ // A session is now durably committed — set the returning-user hint so a
880
+ // future cold boot whose local session has lapsed still gets ONE `/sso`
881
+ // establish bounce (see `markPriorSessionHint`). Every web commit path
882
+ // (FedCM / silent iframe / SSO return / password / cookie restore) funnels
883
+ // through here, so this is the single chokepoint for the hint.
884
+ await readyStorage.setItem(storageKeys.priorSession, '1');
885
+ }, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds, storageKeys.priorSession]);
835
886
 
836
887
  // Refs so the cold-boot restore can plant session state without widening its
837
888
  // dependency array (mirrors the existing ref pattern above).
@@ -910,11 +961,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
910
961
  // Pick the active account: persisted authuser if it still matches a returned
911
962
  // account, otherwise the lowest authuser (deterministic). The server has
912
963
  // already sorted ascending so [0] is the lowest.
913
- const persistedAuthuser = readActiveAuthuser();
914
- const matched = persistedAuthuser !== null
915
- ? snapshot.accounts.find((a) => a.authuser === persistedAuthuser)
916
- : undefined;
917
- const activeAccount = matched ?? snapshot.accounts[0];
964
+ const activeAccount = selectActiveRefreshAccount(snapshot.accounts, readActiveAuthuser());
918
965
 
919
966
  // Plant the active access token. Sibling accounts' access tokens stay in
920
967
  // the snapshot (the chooser can drive a per-account refresh via
@@ -1073,6 +1120,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1073
1120
  // immediately so the loading screen clears without waiting for the
1074
1121
  // remaining cold-boot steps to be evaluated/short-circuited (idempotent).
1075
1122
  markAuthResolvedRef.current();
1123
+ // Backfill the returning-user hint. A reload winner already had the hint
1124
+ // set at original sign-in, but pre-existing installs (signed in before
1125
+ // this hint shipped) get it set here so their NEXT lapse-and-return still
1126
+ // earns one `/sso` establish bounce. `storage` is non-null (guarded at
1127
+ // the top of this callback); best-effort, never blocks restore.
1128
+ await storage.setItem(storageKeys.priorSession, '1');
1076
1129
  return true;
1077
1130
  } catch (switchError) {
1078
1131
  // Silently handle expected errors (invalid sessions, timeouts, network issues)
@@ -1102,6 +1155,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1102
1155
  storage,
1103
1156
  storageKeys.activeSessionId,
1104
1157
  storageKeys.sessionIds,
1158
+ storageKeys.priorSession,
1105
1159
  ]);
1106
1160
 
1107
1161
  // Shared in-flight `runSsoReturn` promise — see the CONCURRENCY note on
@@ -1213,6 +1267,27 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1213
1267
  // `stored-session` skips, this stays false, and the probes run as before.
1214
1268
  let storedSessionRestored = false;
1215
1269
 
1270
+ // FIX-B smart-gate input: has this device/app EVER had a signed-in Oxy
1271
+ // session (the durable `priorSession` hint, set on every commit, cleared
1272
+ // only on explicit full sign-out)? Read ONCE here — synchronously usable by
1273
+ // the terminal `sso-bounce` `enabled` gate below — so a RETURNING visitor
1274
+ // whose local session has lapsed still earns one `/sso` establish bounce,
1275
+ // while a truly first-time anonymous visitor is never force-redirected.
1276
+ // `storage` is non-null (guarded above); a read failure is treated as "no
1277
+ // prior session" (fail safe toward anonymous-browse).
1278
+ let hadPriorSession = false;
1279
+ try {
1280
+ hadPriorSession = (await storage.getItem(storageKeys.priorSession)) === '1';
1281
+ } catch (priorSessionReadError) {
1282
+ if (__DEV__) {
1283
+ loggerUtil.debug(
1284
+ 'Failed to read prior-session hint (treating as first-time visitor)',
1285
+ { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
1286
+ priorSessionReadError as unknown,
1287
+ );
1288
+ }
1289
+ }
1290
+
1216
1291
  try {
1217
1292
  const outcome = await runColdBoot<true>({
1218
1293
  steps: [
@@ -1358,15 +1433,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1358
1433
  id: 'silent-iframe',
1359
1434
  enabled: () => !storedSessionRestored && isWebBrowser(),
1360
1435
  run: async () => {
1361
- const perApexAuthUrl = autoDetectAuthWebUrl();
1362
- if (!perApexAuthUrl || !commitWebSession) {
1436
+ if (!commitWebSession) {
1363
1437
  return { kind: 'skip' };
1364
1438
  }
1365
- const session = await oxyServices.silentSignIn?.({
1366
- authWebUrlOverride: perApexAuthUrl,
1367
- timeout: SILENT_IFRAME_TIMEOUT,
1368
- });
1369
- if (!session?.user || !session?.sessionId) {
1439
+ // Shared with the in-session refresh handler: the ONE
1440
+ // implementation of "mint a first-party per-apex token without a
1441
+ // reload" lives in `silentSessionRestore` so cold boot and refresh
1442
+ // never drift.
1443
+ const session = await mintSessionViaPerApexIframe(oxyServices, SILENT_IFRAME_TIMEOUT);
1444
+ if (!session) {
1370
1445
  return { kind: 'skip' };
1371
1446
  }
1372
1447
  await commitWebSession(session);
@@ -1400,11 +1475,23 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1400
1475
  // still active (loop + self-heal protection).
1401
1476
  id: 'sso-bounce',
1402
1477
  enabled: () => {
1403
- // Opt-out: when the consumer disabled auto-SSO, never bounce a
1404
- // truly-anonymous visitor to the central IdP. All other restore
1405
- // steps already ran above, so a signed-in user is still
1406
- // recovered; only this terminal force-bounce is suppressed.
1407
- if (disableAutoSso) {
1478
+ // Smart gate (SDK-owned, shared with `WebOxyProvider` via core's
1479
+ // `allowSsoBounce`). The terminal `/sso` establish-bounce is the
1480
+ // ONLY cold-boot step that can recover a session living SOLELY at
1481
+ // the central IdP (a cross-apex RP whose local session expired),
1482
+ // and it is what plants the per-apex `fedcm_session` cookie the
1483
+ // earlier silent-iframe step relies on. So it is allowed iff a
1484
+ // prior-signed-in hint exists OR a local session was recovered this
1485
+ // boot (`storedSessionRestored`, always false here — an earlier step
1486
+ // would have won — but passed for spec fidelity): a RETURNING user
1487
+ // still gets ONE bounce, while a truly first-time anonymous visitor
1488
+ // does NOT (anonymous browse). The per-tab loop guards below
1489
+ // (`ssoNoSessionKey`, `ssoAttemptedKey`, `guardActive`) still cap an
1490
+ // allowed bounce at one per cold boot.
1491
+ if (!allowSsoBounce({
1492
+ hasPriorSession: hadPriorSession,
1493
+ hasLocalSession: storedSessionRestored,
1494
+ })) {
1408
1495
  return false;
1409
1496
  }
1410
1497
  if (!isWebBrowser() || window.top !== window.self) {
@@ -1497,11 +1584,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1497
1584
  }, [
1498
1585
  oxyServices,
1499
1586
  storage,
1587
+ storageKeys.priorSession,
1500
1588
  restoreViaRefreshCookie,
1501
1589
  restoreStoredSession,
1502
1590
  runSsoReturn,
1503
1591
  markAuthResolved,
1504
- disableAutoSso,
1505
1592
  ]);
1506
1593
 
1507
1594
  useEffect(() => {
@@ -20,6 +20,14 @@ export interface UseAuthOperationsOptions {
20
20
  updateSessions: (sessions: ClientSession[], options?: { merge?: boolean }) => void;
21
21
  saveActiveSessionId: (sessionId: string) => Promise<void>;
22
22
  clearSessionState: () => Promise<void>;
23
+ /**
24
+ * Clear the durable returning-user hint (`storageKeys.priorSession`). Called
25
+ * ONLY on EXPLICIT full sign-out — alongside `clearSsoBounceState()` — so the
26
+ * next cold boot treats this device as a first-time anonymous visitor (no
27
+ * forced `/sso` bounce). NEVER called on the passive token-expiry path, so an
28
+ * expired session still recovers via a returning-user bounce. Best-effort.
29
+ */
30
+ clearPriorSessionHint: () => Promise<void>;
23
31
  switchSession: (sessionId: string) => Promise<User>;
24
32
  applyLanguagePreference: (user: User) => Promise<void>;
25
33
  onAuthStateChange?: (user: User | null) => void;
@@ -44,6 +52,24 @@ const LOGIN_ERROR_CODE = 'LOGIN_ERROR';
44
52
  const LOGOUT_ERROR_CODE = 'LOGOUT_ERROR';
45
53
  const LOGOUT_ALL_ERROR_CODE = 'LOGOUT_ALL_ERROR';
46
54
 
55
+ /**
56
+ * Fire-and-forget the durable returning-user hint clear on explicit sign-out.
57
+ *
58
+ * Mirrors the synchronous, non-blocking nature of the sibling
59
+ * `clearSsoBounceState()`: sign-out must NEVER block on (or fail because of) a
60
+ * best-effort storage write. The clear is invoked synchronously (so unit tests
61
+ * can assert it ran) but its async settle is detached; any rejection is logged,
62
+ * never thrown.
63
+ */
64
+ function clearPriorSessionHintSafe(
65
+ clearPriorSessionHint: () => Promise<void>,
66
+ logger?: (message: string, error?: unknown) => void,
67
+ ): void {
68
+ clearPriorSessionHint().catch((hintError) => {
69
+ logger?.('Failed to clear prior-session hint on sign-out', hintError);
70
+ });
71
+ }
72
+
47
73
  /**
48
74
  * Authentication operations using public key cryptography.
49
75
  * Accepts public key as parameter - identity management is handled by the app layer.
@@ -56,6 +82,7 @@ export const useAuthOperations = ({
56
82
  updateSessions,
57
83
  saveActiveSessionId,
58
84
  clearSessionState,
85
+ clearPriorSessionHint,
59
86
  switchSession,
60
87
  applyLanguagePreference,
61
88
  onAuthStateChange,
@@ -227,6 +254,7 @@ export const useAuthOperations = ({
227
254
  // Genuine FULL sign-out (no sessions remain): clear the per-origin
228
255
  // SSO bounce state so a fresh deliberate sign-in can re-probe.
229
256
  clearSsoBounceState();
257
+ clearPriorSessionHintSafe(clearPriorSessionHint, logger);
230
258
  await clearSessionState();
231
259
  return;
232
260
  }
@@ -237,6 +265,7 @@ export const useAuthOperations = ({
237
265
  if (isInvalid && targetSessionId === activeSessionId) {
238
266
  // The active session is invalid → full sign-out; clear SSO state too.
239
267
  clearSsoBounceState();
268
+ clearPriorSessionHintSafe(clearPriorSessionHint, logger);
240
269
  await clearSessionState();
241
270
  return;
242
271
  }
@@ -254,6 +283,7 @@ export const useAuthOperations = ({
254
283
  [
255
284
  activeSessionId,
256
285
  clearSessionState,
286
+ clearPriorSessionHint,
257
287
  logger,
258
288
  onError,
259
289
  oxyServices,
@@ -286,8 +316,11 @@ export const useAuthOperations = ({
286
316
  clearActiveAuthuser();
287
317
  }
288
318
  // logoutAll is ALWAYS a full sign-out: clear the per-origin SSO bounce
289
- // state (web-guarded internally) so a fresh sign-in can re-probe.
319
+ // state (web-guarded internally) so a fresh sign-in can re-probe, and drop
320
+ // the durable returning-user hint so the next cold boot is treated as a
321
+ // first-time anonymous visitor (no forced `/sso` bounce after sign-out).
290
322
  clearSsoBounceState();
323
+ clearPriorSessionHintSafe(clearPriorSessionHint, logger);
291
324
  await clearSessionState();
292
325
  } catch (error) {
293
326
  handleAuthError(error, {
@@ -299,7 +332,7 @@ export const useAuthOperations = ({
299
332
  });
300
333
  throw error instanceof Error ? error : new Error('Logout all failed');
301
334
  }
302
- }, [activeSessionId, clearSessionState, logger, onError, oxyServices, setAuthState]);
335
+ }, [activeSessionId, clearSessionState, clearPriorSessionHint, logger, onError, oxyServices, setAuthState]);
303
336
 
304
337
  return {
305
338
  signIn,
@@ -0,0 +1,298 @@
1
+ /**
2
+ * In-session access-token refresh for the React Native / Expo SDK.
3
+ *
4
+ * THE GAP THIS CLOSES: the services `OxyContext` owns the session but never
5
+ * installed an `authRefreshHandler` on the owner `HttpService`, so
6
+ * `HttpService.refreshAccessToken` short-circuited to `null` — there was NO
7
+ * in-session token refresh on the RN path at all. A 15-minute access token
8
+ * expired with the tab/app open and nothing re-minted one; cross-apex RP feeds
9
+ * (mention.earth, …) then 401'd in a loop while `isAuthenticated` stayed `true`
10
+ * (a zombie logged-in state), because the `Domain=oxy.so` refresh cookie can't
11
+ * reach `api.<apex>`. (`AuthManager`, which installs a refresh handler on the
12
+ * web path, is only ever constructed by `WebOxyProvider` in `@oxyhq/auth`.)
13
+ *
14
+ * THE FIX, two cooperating pieces both wired from `OxyContext`:
15
+ *
16
+ * 1. {@link createInSessionRefreshHandler} — an `AuthRefreshHandler` installed
17
+ * on the owner client. It re-mints a fresh access token WITHOUT a page
18
+ * reload by reusing the SAME durable per-apex silent-restore arms cold boot
19
+ * uses (in order; first that yields a token wins). The linked client
20
+ * (`createLinkedClient`) inherits the fix for free — its refresh delegates
21
+ * back to the owner's `refreshAccessToken`.
22
+ *
23
+ * 2. {@link startTokenRefreshScheduler} — a proactive scheduler that refreshes
24
+ * ~{@link TOKEN_REFRESH_LEAD_MS} before expiry (and on web tab-focus / native
25
+ * app-foreground), so the common case never even reaches the reactive
26
+ * 401-then-recover flash.
27
+ *
28
+ * Concurrency/cooldown/dedup are owned by `HttpService.refreshAccessToken`
29
+ * (single in-flight `tokenRefreshPromise` + cooldown) — this module does not
30
+ * reimplement them, so the timer / foreground / per-request triggers collapse to
31
+ * one network attempt (no refresh storm).
32
+ */
33
+ import type {
34
+ OxyServices,
35
+ AuthRefreshHandler,
36
+ AuthRefreshReason,
37
+ } from '@oxyhq/core';
38
+ import { autoDetectAuthWebUrl, logger as loggerUtil } from '@oxyhq/core';
39
+ import { AppState, type AppStateStatus } from 'react-native';
40
+ import { isWebBrowser } from '../hooks/useWebSSO';
41
+ import { readActiveAuthuser } from '../utils/activeAuthuser';
42
+
43
+ /**
44
+ * Per-arm fail-fast budget (ms) for the web first-party `/auth/silent` iframe
45
+ * arm. Slightly more generous than the cold-boot iframe budget (2.5s) because an
46
+ * in-session refresh is NOT in the first-paint critical path — a couple hundred
47
+ * ms of extra headroom for the same-origin handshake is worth a higher success
48
+ * rate. `silentSignIn` still fail-fasts on `iframe.onerror`, so a hard failure
49
+ * returns well before this.
50
+ */
51
+ const SILENT_IFRAME_REFRESH_TIMEOUT = 4000;
52
+
53
+ /**
54
+ * Per-arm fail-fast budget (ms) for the same-apex refresh-cookie arm
55
+ * (`refreshAllSessions`). On a cross-apex RP the `Domain=oxy.so` cookie never
56
+ * reaches `api.<apex>`, so this returns `{accounts:[]}` quickly; the bound only
57
+ * matters if that endpoint stalls.
58
+ */
59
+ const COOKIE_REFRESH_TIMEOUT = 4000;
60
+
61
+ /**
62
+ * Lead time (ms) before access-token expiry at which the proactive scheduler
63
+ * refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
64
+ * (60s) so the scheduled refresh and the request-time preflight refresh use the
65
+ * same window — the scheduler simply fires it during idle/background instead of
66
+ * waiting for the next request.
67
+ */
68
+ export const TOKEN_REFRESH_LEAD_MS = 60_000;
69
+
70
+ function debugRefresh(message: string, reason: AuthRefreshReason, error?: unknown): void {
71
+ if (__DEV__) {
72
+ loggerUtil.debug(
73
+ message,
74
+ { component: 'inSessionTokenRefresh', method: 'authRefreshHandler', reason },
75
+ error,
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Build the in-session `AuthRefreshHandler` for the owner client.
82
+ *
83
+ * Arms (first to yield a fresh token wins; each is bounded and falls through on
84
+ * failure). Every arm plants the fresh token internally, so on success we read
85
+ * it back via `getAccessToken()`:
86
+ *
87
+ * NATIVE (Expo): shared cross-app identity key re-mint
88
+ * (`signInWithSharedIdentity` → challenge→sign→verify plants the tokens).
89
+ * The ONLY silent native arm (mirrors cold boot's `shared-key-signin`); the
90
+ * `/auth/silent` web iframe is NEVER attempted on native. Returns `null`
91
+ * when the device holds no shared identity (e.g. a password-only native
92
+ * sign-in) so a genuinely dead session reconciles to logged-out rather than
93
+ * staying a zombie.
94
+ *
95
+ * WEB, in order:
96
+ * 1. First-party `/auth/silent` iframe at the per-apex IdP
97
+ * (`silentSignIn` with `authWebUrlOverride = autoDetectAuthWebUrl()`).
98
+ * The durable cross-apex path: the iframe reads the first-party
99
+ * `fedcm_session` cookie on `auth.<apex>` and mints a fresh Oxy token. No
100
+ * top-level navigation → works in a backgrounded tab. On a `*.oxy.so`
101
+ * app the per-apex host IS the central host, so this also covers
102
+ * same-apex.
103
+ * 2. FedCM silent re-auth (Chrome) — `silentSignInWithFedCM`.
104
+ * 3. Same-apex refresh cookie — `refreshAllSessions`. On `*.oxy.so` the
105
+ * httpOnly `oxy_rt_${n}` cookies ride along; we plant the active
106
+ * account's rotated token. On a cross-apex RP it returns `{accounts:[]}`
107
+ * and is a clean no-op. Unlike the cold-boot cookie restore this does NOT
108
+ * rebuild multi-session state — an in-session refresh only needs a fresh
109
+ * bearer.
110
+ *
111
+ * NO RECURSION: none of these arms issue requests through the authed client's
112
+ * `refreshAccessToken` path. The iframe/FedCM transports are postMessage /
113
+ * credential APIs; the follow-up `/session/user` fetch inside `silentSignIn`
114
+ * runs against the just-planted FULL-TTL token (≫ the preflight lead), so it
115
+ * never re-enters the refresh path; `refreshAllSessions` uses a raw `fetch` with
116
+ * `credentials:'include'`.
117
+ */
118
+ export function createInSessionRefreshHandler(oxyServices: OxyServices): AuthRefreshHandler {
119
+ return async (reason: AuthRefreshReason): Promise<string | null> => {
120
+ if (!isWebBrowser()) {
121
+ try {
122
+ const session = await oxyServices.signInWithSharedIdentity?.();
123
+ if (session) {
124
+ // `verifyChallenge` inside `signInWithSharedIdentity` already planted
125
+ // the fresh tokens; read the planted access token back out.
126
+ return oxyServices.getAccessToken();
127
+ }
128
+ } catch (error) {
129
+ debugRefresh('Native shared-key in-session refresh failed', reason, error);
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // WEB arm 1 — first-party silent iframe at the per-apex IdP.
135
+ try {
136
+ const perApexAuthUrl = autoDetectAuthWebUrl();
137
+ if (perApexAuthUrl) {
138
+ const session = await oxyServices.silentSignIn?.({
139
+ authWebUrlOverride: perApexAuthUrl,
140
+ timeout: SILENT_IFRAME_REFRESH_TIMEOUT,
141
+ });
142
+ if (session) {
143
+ return oxyServices.getAccessToken();
144
+ }
145
+ }
146
+ } catch (error) {
147
+ debugRefresh('Silent-iframe in-session refresh failed', reason, error);
148
+ }
149
+
150
+ // WEB arm 2 — FedCM silent re-auth (Chrome).
151
+ try {
152
+ if (oxyServices.isFedCMSupported?.() === true) {
153
+ const session = await oxyServices.silentSignInWithFedCM?.();
154
+ if (session) {
155
+ return oxyServices.getAccessToken();
156
+ }
157
+ }
158
+ } catch (error) {
159
+ debugRefresh('FedCM-silent in-session refresh failed', reason, error);
160
+ }
161
+
162
+ // WEB arm 3 — same-apex refresh cookie.
163
+ try {
164
+ const snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_REFRESH_TIMEOUT });
165
+ if (snapshot.accounts.length > 0) {
166
+ const persistedAuthuser = readActiveAuthuser();
167
+ const active =
168
+ (persistedAuthuser !== null
169
+ ? snapshot.accounts.find((account) => account.authuser === persistedAuthuser)
170
+ : undefined) ?? snapshot.accounts[0];
171
+ oxyServices.httpService.setTokens(active.accessToken);
172
+ return oxyServices.getAccessToken();
173
+ }
174
+ } catch (error) {
175
+ debugRefresh('Refresh-cookie in-session refresh failed', reason, error);
176
+ }
177
+
178
+ return null;
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Handle returned by {@link startTokenRefreshScheduler}; call `dispose()` to tear
184
+ * down the timer and the foreground listener.
185
+ */
186
+ export interface TokenRefreshSchedulerHandle {
187
+ dispose(): void;
188
+ }
189
+
190
+ /**
191
+ * Start the proactive in-session refresh scheduler against `oxyServices`.
192
+ *
193
+ * Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
194
+ * current access token's `exp`, calling `httpService.refreshAccessToken`
195
+ * ('preflight') — which runs the installed handler and is deduped + cooldown-
196
+ * guarded. After every attempt it reschedules from the (possibly rotated) token.
197
+ * It also reschedules whenever the token changes (`onTokensChanged`) and, on
198
+ * web tab-focus / native app-foreground, refreshes immediately if already inside
199
+ * the lead window (a long-hidden tab throttles timers, so the token can be
200
+ * expired on return).
201
+ *
202
+ * No-ops cleanly when there is no token, an opaque/no-`exp` token, or the host
203
+ * lacks `getAccessTokenExpiry` (older stubs) — in those cases the reactive 401
204
+ * path remains the only refresh trigger.
205
+ */
206
+ export function startTokenRefreshScheduler(oxyServices: OxyServices): TokenRefreshSchedulerHandle {
207
+ let disposed = false;
208
+ let timer: ReturnType<typeof setTimeout> | null = null;
209
+
210
+ const clearTimer = (): void => {
211
+ if (timer !== null) {
212
+ clearTimeout(timer);
213
+ timer = null;
214
+ }
215
+ };
216
+
217
+ const runRefresh = (): void => {
218
+ // `refreshAccessToken` is deduped + cooldown-guarded internally, so this is
219
+ // safe to call from the timer, the foreground listener, and request-time
220
+ // preflight concurrently — they collapse to one network attempt.
221
+ const refresh = oxyServices.httpService.refreshAccessToken?.('preflight');
222
+ if (!refresh) {
223
+ return;
224
+ }
225
+ void refresh
226
+ .catch(() => null)
227
+ .finally(() => {
228
+ if (!disposed) {
229
+ schedule();
230
+ }
231
+ });
232
+ };
233
+
234
+ const schedule = (): void => {
235
+ clearTimer();
236
+ if (disposed || !oxyServices.getAccessToken()) {
237
+ return;
238
+ }
239
+ const expSeconds = oxyServices.getAccessTokenExpiry?.() ?? null;
240
+ if (expSeconds === null) {
241
+ return;
242
+ }
243
+ const fireInMs = expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS;
244
+ timer = setTimeout(runRefresh, Math.max(fireInMs, 0));
245
+ };
246
+
247
+ const onForeground = (): void => {
248
+ if (disposed || !oxyServices.getAccessToken()) {
249
+ return;
250
+ }
251
+ const expSeconds = oxyServices.getAccessTokenExpiry?.() ?? null;
252
+ if (expSeconds === null) {
253
+ return;
254
+ }
255
+ const remainingMs = expSeconds * 1000 - Date.now();
256
+ if (remainingMs <= TOKEN_REFRESH_LEAD_MS) {
257
+ runRefresh();
258
+ } else {
259
+ schedule();
260
+ }
261
+ };
262
+
263
+ const unsubscribeTokens = oxyServices.onTokensChanged(() => {
264
+ if (!disposed) {
265
+ schedule();
266
+ }
267
+ });
268
+
269
+ let removeForeground: (() => void) | null = null;
270
+ if (isWebBrowser() && typeof document !== 'undefined') {
271
+ const handler = (): void => {
272
+ if (document.visibilityState === 'visible') {
273
+ onForeground();
274
+ }
275
+ };
276
+ document.addEventListener('visibilitychange', handler);
277
+ removeForeground = () => document.removeEventListener('visibilitychange', handler);
278
+ } else if (!isWebBrowser() && AppState && typeof AppState.addEventListener === 'function') {
279
+ const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
280
+ if (state === 'active') {
281
+ onForeground();
282
+ }
283
+ });
284
+ removeForeground = () => subscription.remove();
285
+ }
286
+
287
+ schedule();
288
+
289
+ return {
290
+ dispose(): void {
291
+ disposed = true;
292
+ clearTimer();
293
+ unsubscribeTokens();
294
+ removeForeground?.();
295
+ removeForeground = null;
296
+ },
297
+ };
298
+ }