@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.
- package/lib/commonjs/index.js +2 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +0 -2
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +117 -20
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +24 -3
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js +286 -0
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -0
- package/lib/commonjs/ui/context/silentSessionRestore.js +65 -0
- package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -0
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/utils/storageHelpers.js +2 -1
- package/lib/commonjs/ui/utils/storageHelpers.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +0 -2
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +118 -21
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +24 -3
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/inSessionTokenRefresh.js +281 -0
- package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -0
- package/lib/module/ui/context/silentSessionRestore.js +61 -0
- package/lib/module/ui/context/silentSessionRestore.js.map +1 -0
- package/lib/module/ui/index.js +2 -0
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/utils/storageHelpers.js +2 -1
- package/lib/module/ui/utils/storageHelpers.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +0 -10
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +9 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +105 -0
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +42 -0
- package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +0 -17
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/storageHelpers.d.ts +11 -0
- package/lib/typescript/commonjs/ui/utils/storageHelpers.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +0 -10
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +9 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +105 -0
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +42 -0
- package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -0
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +0 -17
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/storageHelpers.d.ts +11 -0
- package/lib/typescript/module/ui/utils/storageHelpers.d.ts.map +1 -1
- package/lib/typescript/types/react-native-classname.d.ts +39 -0
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/types/react-native-classname.d.ts +39 -0
- package/src/ui/components/OxyProvider.tsx +0 -2
- package/src/ui/context/OxyContext.tsx +119 -32
- package/src/ui/context/hooks/useAuthOperations.ts +35 -2
- package/src/ui/context/inSessionTokenRefresh.ts +298 -0
- package/src/ui/context/silentSessionRestore.ts +67 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/types/navigation.ts +0 -17
- 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
|
-
|
|
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
|
|
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
|
-
|
|
1362
|
-
if (!perApexAuthUrl || !commitWebSession) {
|
|
1436
|
+
if (!commitWebSession) {
|
|
1363
1437
|
return { kind: 'skip' };
|
|
1364
1438
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
//
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
|
|
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
|
+
}
|