@oxyhq/services 12.0.0 → 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 (26) hide show
  1. package/lib/commonjs/ui/context/OxyContext.js +38 -10
  2. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  3. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +286 -0
  4. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -0
  5. package/lib/commonjs/ui/context/silentSessionRestore.js +65 -0
  6. package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -0
  7. package/lib/module/ui/context/OxyContext.js +39 -11
  8. package/lib/module/ui/context/OxyContext.js.map +1 -1
  9. package/lib/module/ui/context/inSessionTokenRefresh.js +281 -0
  10. package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -0
  11. package/lib/module/ui/context/silentSessionRestore.js +61 -0
  12. package/lib/module/ui/context/silentSessionRestore.js.map +1 -0
  13. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  14. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +105 -0
  15. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -0
  16. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +42 -0
  17. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -0
  18. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  19. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +105 -0
  20. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -0
  21. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +42 -0
  22. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -0
  23. package/package.json +3 -3
  24. package/src/ui/context/OxyContext.tsx +38 -13
  25. package/src/ui/context/inSessionTokenRefresh.ts +298 -0
  26. package/src/ui/context/silentSessionRestore.ts +67 -0
@@ -0,0 +1,105 @@
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 { OxyServices, AuthRefreshHandler } from '@oxyhq/core';
34
+ /**
35
+ * Lead time (ms) before access-token expiry at which the proactive scheduler
36
+ * refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
37
+ * (60s) so the scheduled refresh and the request-time preflight refresh use the
38
+ * same window — the scheduler simply fires it during idle/background instead of
39
+ * waiting for the next request.
40
+ */
41
+ export declare const TOKEN_REFRESH_LEAD_MS = 60000;
42
+ /**
43
+ * Build the in-session `AuthRefreshHandler` for the owner client.
44
+ *
45
+ * Arms (first to yield a fresh token wins; each is bounded and falls through on
46
+ * failure). Every arm plants the fresh token internally, so on success we read
47
+ * it back via `getAccessToken()`:
48
+ *
49
+ * NATIVE (Expo): shared cross-app identity key re-mint
50
+ * (`signInWithSharedIdentity` → challenge→sign→verify plants the tokens).
51
+ * The ONLY silent native arm (mirrors cold boot's `shared-key-signin`); the
52
+ * `/auth/silent` web iframe is NEVER attempted on native. Returns `null`
53
+ * when the device holds no shared identity (e.g. a password-only native
54
+ * sign-in) so a genuinely dead session reconciles to logged-out rather than
55
+ * staying a zombie.
56
+ *
57
+ * WEB, in order:
58
+ * 1. First-party `/auth/silent` iframe at the per-apex IdP
59
+ * (`silentSignIn` with `authWebUrlOverride = autoDetectAuthWebUrl()`).
60
+ * The durable cross-apex path: the iframe reads the first-party
61
+ * `fedcm_session` cookie on `auth.<apex>` and mints a fresh Oxy token. No
62
+ * top-level navigation → works in a backgrounded tab. On a `*.oxy.so`
63
+ * app the per-apex host IS the central host, so this also covers
64
+ * same-apex.
65
+ * 2. FedCM silent re-auth (Chrome) — `silentSignInWithFedCM`.
66
+ * 3. Same-apex refresh cookie — `refreshAllSessions`. On `*.oxy.so` the
67
+ * httpOnly `oxy_rt_${n}` cookies ride along; we plant the active
68
+ * account's rotated token. On a cross-apex RP it returns `{accounts:[]}`
69
+ * and is a clean no-op. Unlike the cold-boot cookie restore this does NOT
70
+ * rebuild multi-session state — an in-session refresh only needs a fresh
71
+ * bearer.
72
+ *
73
+ * NO RECURSION: none of these arms issue requests through the authed client's
74
+ * `refreshAccessToken` path. The iframe/FedCM transports are postMessage /
75
+ * credential APIs; the follow-up `/session/user` fetch inside `silentSignIn`
76
+ * runs against the just-planted FULL-TTL token (≫ the preflight lead), so it
77
+ * never re-enters the refresh path; `refreshAllSessions` uses a raw `fetch` with
78
+ * `credentials:'include'`.
79
+ */
80
+ export declare function createInSessionRefreshHandler(oxyServices: OxyServices): AuthRefreshHandler;
81
+ /**
82
+ * Handle returned by {@link startTokenRefreshScheduler}; call `dispose()` to tear
83
+ * down the timer and the foreground listener.
84
+ */
85
+ export interface TokenRefreshSchedulerHandle {
86
+ dispose(): void;
87
+ }
88
+ /**
89
+ * Start the proactive in-session refresh scheduler against `oxyServices`.
90
+ *
91
+ * Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
92
+ * current access token's `exp`, calling `httpService.refreshAccessToken`
93
+ * ('preflight') — which runs the installed handler and is deduped + cooldown-
94
+ * guarded. After every attempt it reschedules from the (possibly rotated) token.
95
+ * It also reschedules whenever the token changes (`onTokensChanged`) and, on
96
+ * web tab-focus / native app-foreground, refreshes immediately if already inside
97
+ * the lead window (a long-hidden tab throttles timers, so the token can be
98
+ * expired on return).
99
+ *
100
+ * No-ops cleanly when there is no token, an opaque/no-`exp` token, or the host
101
+ * lacks `getAccessTokenExpiry` (older stubs) — in those cases the reactive 401
102
+ * path remains the only refresh trigger.
103
+ */
104
+ export declare function startTokenRefreshScheduler(oxyServices: OxyServices): TokenRefreshSchedulerHandle;
105
+ //# sourceMappingURL=inSessionTokenRefresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inSessionTokenRefresh.d.ts","sourceRoot":"","sources":["../../../../../src/ui/context/inSessionTokenRefresh.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,KAAK,EACV,WAAW,EACX,kBAAkB,EAEnB,MAAM,aAAa,CAAC;AAwBrB;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAY5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,6BAA6B,CAAC,WAAW,EAAE,WAAW,GAAG,kBAAkB,CA8D1F;AAED;;;GAGG;AACH,MAAM,WAAW,2BAA2B;IAC1C,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,WAAW,GAAG,2BAA2B,CA4FhG"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Silent, no-reload session-restore PRIMITIVES — the single shared
3
+ * implementation used by BOTH cold boot (`OxyContext.restoreSessionsFromStorage`)
4
+ * and in-session token refresh (`createInSessionRefreshHandler`). Neither caller
5
+ * re-implements these; they compose them. Keeping one home avoids the two paths
6
+ * drifting on "how do we mint a first-party token without a page reload".
7
+ *
8
+ * Each function is platform-agnostic at the type level and returns plain data;
9
+ * the callers own the side effects that differ between them (cold boot COMMITS
10
+ * the recovered session into provider state; refresh only reads the freshly
11
+ * planted bearer).
12
+ */
13
+ import type { OxyServices, SessionLoginResponse } from '@oxyhq/core';
14
+ /**
15
+ * Mint a fresh first-party session via the PER-APEX `/auth/silent` iframe — the
16
+ * durable cross-domain restore path that works WITHOUT a top-level navigation
17
+ * (so it succeeds under Safari ITP / Firefox TCP and in a backgrounded tab).
18
+ *
19
+ * The instance is configured with the CENTRAL auth URL, so we explicitly point
20
+ * the iframe at the per-apex host (`auth.<rp-apex>`) via `autoDetectAuthWebUrl()`
21
+ * + `silentSignIn`'s `authWebUrlOverride`. On a `*.oxy.so` app the per-apex host
22
+ * IS the central host, so this also covers same-apex. When auto-detection bails
23
+ * (localhost / IP / single-label / off-browser) there is no per-apex IdP and we
24
+ * return `null`. `silentSignIn` plants the access token internally on success.
25
+ *
26
+ * @returns the recovered session (token already planted) when complete, else
27
+ * `null` (no per-apex IdP, no session, or an incomplete iframe response).
28
+ */
29
+ export declare function mintSessionViaPerApexIframe(oxyServices: OxyServices, timeoutMs: number): Promise<SessionLoginResponse | null>;
30
+ /**
31
+ * Pick the active account from a `refreshAllSessions` snapshot: the persisted
32
+ * `authuser` slot when it still matches a returned account, otherwise the lowest
33
+ * `authuser` (the server sorts ascending, so `[0]`). Callers guarantee a
34
+ * non-empty list, so the result is always defined.
35
+ *
36
+ * Shared by cold-boot cookie restore and the in-session refresh cookie arm so
37
+ * the active-slot selection can never diverge between them.
38
+ */
39
+ export declare function selectActiveRefreshAccount<T extends {
40
+ authuser: number;
41
+ }>(accounts: T[], persistedAuthuser: number | null): T;
42
+ //# sourceMappingURL=silentSessionRestore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"silentSessionRestore.d.ts","sourceRoot":"","sources":["../../../../../src/ui/context/silentSessionRestore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAGrE;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,2BAA2B,CAC/C,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAatC;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,CAAC,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,EACvE,QAAQ,EAAE,CAAC,EAAE,EACb,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAC/B,CAAC,CAMH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -106,7 +106,7 @@
106
106
  }
107
107
  },
108
108
  "dependencies": {
109
- "@oxyhq/contracts": "0.4.0"
109
+ "@oxyhq/contracts": "0.5.0"
110
110
  },
111
111
  "devDependencies": {
112
112
  "nativewind": "5.0.0-preview.3",
@@ -152,7 +152,7 @@
152
152
  "peerDependencies": {
153
153
  "@expo/vector-icons": "^15.0.3",
154
154
  "@oxyhq/bloom": ">=0.16.0",
155
- "@oxyhq/core": "^3.15.0",
155
+ "@oxyhq/core": "^3.17.0",
156
156
  "@react-native-async-storage/async-storage": "^2.0.0",
157
157
  "@react-native-community/netinfo": "^11.4.1",
158
158
  "@tanstack/query-async-storage-persister": "^5.100",
@@ -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,
@@ -54,6 +53,8 @@ import { useAccountStore } from '../stores/accountStore';
54
53
  import { logger as loggerUtil } from '@oxyhq/core';
55
54
  import { useWebSSO, isWebBrowser } from '../hooks/useWebSSO';
56
55
  import { buildSilentGuardKey } from '../../utils/silentGuardKey';
56
+ import { createInSessionRefreshHandler, startTokenRefreshScheduler } from './inSessionTokenRefresh';
57
+ import { mintSessionViaPerApexIframe, selectActiveRefreshAccount } from './silentSessionRestore';
57
58
 
58
59
  export interface OxyContextState {
59
60
  user: User | null;
@@ -823,6 +824,34 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
823
824
  return oxyServices.onTokensChanged(handleTokenChange);
824
825
  }, [logger, oxyServices]);
825
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
+
826
855
  // Durable, navigation-safe session persistence.
827
856
  //
828
857
  // Writes the active-session id and appends the session id to the durable
@@ -932,11 +961,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
932
961
  // Pick the active account: persisted authuser if it still matches a returned
933
962
  // account, otherwise the lowest authuser (deterministic). The server has
934
963
  // already sorted ascending so [0] is the lowest.
935
- const persistedAuthuser = readActiveAuthuser();
936
- const matched = persistedAuthuser !== null
937
- ? snapshot.accounts.find((a) => a.authuser === persistedAuthuser)
938
- : undefined;
939
- const activeAccount = matched ?? snapshot.accounts[0];
964
+ const activeAccount = selectActiveRefreshAccount(snapshot.accounts, readActiveAuthuser());
940
965
 
941
966
  // Plant the active access token. Sibling accounts' access tokens stay in
942
967
  // the snapshot (the chooser can drive a per-account refresh via
@@ -1408,15 +1433,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1408
1433
  id: 'silent-iframe',
1409
1434
  enabled: () => !storedSessionRestored && isWebBrowser(),
1410
1435
  run: async () => {
1411
- const perApexAuthUrl = autoDetectAuthWebUrl();
1412
- if (!perApexAuthUrl || !commitWebSession) {
1436
+ if (!commitWebSession) {
1413
1437
  return { kind: 'skip' };
1414
1438
  }
1415
- const session = await oxyServices.silentSignIn?.({
1416
- authWebUrlOverride: perApexAuthUrl,
1417
- timeout: SILENT_IFRAME_TIMEOUT,
1418
- });
1419
- 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) {
1420
1445
  return { kind: 'skip' };
1421
1446
  }
1422
1447
  await commitWebSession(session);
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Silent, no-reload session-restore PRIMITIVES — the single shared
3
+ * implementation used by BOTH cold boot (`OxyContext.restoreSessionsFromStorage`)
4
+ * and in-session token refresh (`createInSessionRefreshHandler`). Neither caller
5
+ * re-implements these; they compose them. Keeping one home avoids the two paths
6
+ * drifting on "how do we mint a first-party token without a page reload".
7
+ *
8
+ * Each function is platform-agnostic at the type level and returns plain data;
9
+ * the callers own the side effects that differ between them (cold boot COMMITS
10
+ * the recovered session into provider state; refresh only reads the freshly
11
+ * planted bearer).
12
+ */
13
+ import type { OxyServices, SessionLoginResponse } from '@oxyhq/core';
14
+ import { autoDetectAuthWebUrl } from '@oxyhq/core';
15
+
16
+ /**
17
+ * Mint a fresh first-party session via the PER-APEX `/auth/silent` iframe — the
18
+ * durable cross-domain restore path that works WITHOUT a top-level navigation
19
+ * (so it succeeds under Safari ITP / Firefox TCP and in a backgrounded tab).
20
+ *
21
+ * The instance is configured with the CENTRAL auth URL, so we explicitly point
22
+ * the iframe at the per-apex host (`auth.<rp-apex>`) via `autoDetectAuthWebUrl()`
23
+ * + `silentSignIn`'s `authWebUrlOverride`. On a `*.oxy.so` app the per-apex host
24
+ * IS the central host, so this also covers same-apex. When auto-detection bails
25
+ * (localhost / IP / single-label / off-browser) there is no per-apex IdP and we
26
+ * return `null`. `silentSignIn` plants the access token internally on success.
27
+ *
28
+ * @returns the recovered session (token already planted) when complete, else
29
+ * `null` (no per-apex IdP, no session, or an incomplete iframe response).
30
+ */
31
+ export async function mintSessionViaPerApexIframe(
32
+ oxyServices: OxyServices,
33
+ timeoutMs: number,
34
+ ): Promise<SessionLoginResponse | null> {
35
+ const perApexAuthUrl = autoDetectAuthWebUrl();
36
+ if (!perApexAuthUrl) {
37
+ return null;
38
+ }
39
+ const session = await oxyServices.silentSignIn?.({
40
+ authWebUrlOverride: perApexAuthUrl,
41
+ timeout: timeoutMs,
42
+ });
43
+ if (!session?.user || !session.sessionId) {
44
+ return null;
45
+ }
46
+ return session;
47
+ }
48
+
49
+ /**
50
+ * Pick the active account from a `refreshAllSessions` snapshot: the persisted
51
+ * `authuser` slot when it still matches a returned account, otherwise the lowest
52
+ * `authuser` (the server sorts ascending, so `[0]`). Callers guarantee a
53
+ * non-empty list, so the result is always defined.
54
+ *
55
+ * Shared by cold-boot cookie restore and the in-session refresh cookie arm so
56
+ * the active-slot selection can never diverge between them.
57
+ */
58
+ export function selectActiveRefreshAccount<T extends { authuser: number }>(
59
+ accounts: T[],
60
+ persistedAuthuser: number | null,
61
+ ): T {
62
+ const matched =
63
+ persistedAuthuser !== null
64
+ ? accounts.find((account) => account.authuser === persistedAuthuser)
65
+ : undefined;
66
+ return matched ?? accounts[0];
67
+ }