@oxyhq/services 12.0.0 → 12.1.1
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/ui/context/OxyContext.js +45 -18
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js +262 -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/module/ui/context/OxyContext.js +46 -19
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/inSessionTokenRefresh.js +257 -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/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +101 -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/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +101 -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/package.json +3 -3
- package/src/ui/context/OxyContext.tsx +45 -21
- package/src/ui/context/inSessionTokenRefresh.ts +283 -0
- package/src/ui/context/silentSessionRestore.ts +67 -0
|
@@ -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.
|
|
3
|
+
"version": "12.1.1",
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
@@ -1393,14 +1418,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1393
1418
|
// step that prevents the re-bounce loop: when it finds a session,
|
|
1394
1419
|
// the terminal `sso-bounce` never fires.
|
|
1395
1420
|
//
|
|
1396
|
-
// The
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1403
|
-
// `isWebBrowser()` gates it off, so native never runs an iframe.
|
|
1421
|
+
// The per-apex iframe mint itself lives in
|
|
1422
|
+
// `mintSessionViaPerApexIframe` (shared verbatim with the in-session
|
|
1423
|
+
// refresh handler so the two paths can never drift): it points the
|
|
1424
|
+
// iframe at `autoDetectAuthWebUrl()` and skips when there is no
|
|
1425
|
+
// per-apex IdP (localhost/IP/single-label/off-browser). Web only; on
|
|
1426
|
+
// native `isWebBrowser()` gates it off, so native never runs an
|
|
1427
|
+
// iframe.
|
|
1404
1428
|
//
|
|
1405
1429
|
// FIX-B: additionally skipped when `stored-session` already won.
|
|
1406
1430
|
// FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
|
|
@@ -1408,15 +1432,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1408
1432
|
id: 'silent-iframe',
|
|
1409
1433
|
enabled: () => !storedSessionRestored && isWebBrowser(),
|
|
1410
1434
|
run: async () => {
|
|
1411
|
-
|
|
1412
|
-
if (!perApexAuthUrl || !commitWebSession) {
|
|
1435
|
+
if (!commitWebSession) {
|
|
1413
1436
|
return { kind: 'skip' };
|
|
1414
1437
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1438
|
+
// Shared with the in-session refresh handler: the ONE
|
|
1439
|
+
// implementation of "mint a first-party per-apex token without a
|
|
1440
|
+
// reload" lives in `silentSessionRestore` so cold boot and refresh
|
|
1441
|
+
// never drift.
|
|
1442
|
+
const session = await mintSessionViaPerApexIframe(oxyServices, SILENT_IFRAME_TIMEOUT);
|
|
1443
|
+
if (!session) {
|
|
1420
1444
|
return { kind: 'skip' };
|
|
1421
1445
|
}
|
|
1422
1446
|
await commitWebSession(session);
|
|
@@ -0,0 +1,283 @@
|
|
|
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 composing the SAME silent-restore primitives cold boot uses
|
|
19
|
+
* ({@link mintSessionViaPerApexIframe}, {@link selectActiveRefreshAccount}) —
|
|
20
|
+
* not a copy. The linked client (`createLinkedClient`) inherits the fix for
|
|
21
|
+
* free: its refresh delegates 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 { 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
|
+
import { mintSessionViaPerApexIframe, selectActiveRefreshAccount } from './silentSessionRestore';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-arm fail-fast budget (ms) for the web first-party `/auth/silent` iframe
|
|
46
|
+
* arm. Slightly more generous than the cold-boot iframe budget (2.5s) because an
|
|
47
|
+
* in-session refresh is NOT in the first-paint critical path — a couple hundred
|
|
48
|
+
* ms of extra headroom for the same-origin handshake is worth a higher success
|
|
49
|
+
* rate. `silentSignIn` still fail-fasts on `iframe.onerror`, so a hard failure
|
|
50
|
+
* returns well before this.
|
|
51
|
+
*/
|
|
52
|
+
const SILENT_IFRAME_REFRESH_TIMEOUT = 4000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Per-arm fail-fast budget (ms) for the same-apex refresh-cookie arm
|
|
56
|
+
* (`refreshAllSessions`). On a cross-apex RP the `Domain=oxy.so` cookie never
|
|
57
|
+
* reaches `api.<apex>`, so this returns `{accounts:[]}` quickly; the bound only
|
|
58
|
+
* matters if that endpoint stalls.
|
|
59
|
+
*/
|
|
60
|
+
const COOKIE_REFRESH_TIMEOUT = 4000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Lead time (ms) before access-token expiry at which the proactive scheduler
|
|
64
|
+
* refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
|
|
65
|
+
* (60s) so the scheduled refresh and the request-time preflight refresh use the
|
|
66
|
+
* same window — the scheduler simply fires it during idle/background instead of
|
|
67
|
+
* waiting for the next request.
|
|
68
|
+
*/
|
|
69
|
+
export const TOKEN_REFRESH_LEAD_MS = 60_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A single refresh arm: attempts one silent-restore mechanism and resolves to
|
|
73
|
+
* the freshly planted access token, or `null` to fall through to the next arm.
|
|
74
|
+
*/
|
|
75
|
+
type RefreshArm = () => Promise<string | null>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the in-session `AuthRefreshHandler` for the owner client.
|
|
79
|
+
*
|
|
80
|
+
* Arms run in an explicit order; the first to plant a token wins. Each arm
|
|
81
|
+
* resolves to the planted token (read back via `getAccessToken()` — the
|
|
82
|
+
* underlying SDK call plants it internally) or `null`. A throw in one arm is
|
|
83
|
+
* logged and treated as `null` so the chain continues.
|
|
84
|
+
*
|
|
85
|
+
* NATIVE (Expo): shared cross-app identity key re-mint
|
|
86
|
+
* (`signInWithSharedIdentity` → challenge→sign→verify plants the tokens).
|
|
87
|
+
* The ONLY silent native arm (mirrors cold boot's `shared-key-signin`); the
|
|
88
|
+
* `/auth/silent` web iframe is NEVER attempted on native. Resolves to `null`
|
|
89
|
+
* when the device holds no shared identity (e.g. a password-only native
|
|
90
|
+
* sign-in) so a genuinely dead session reconciles to logged-out rather than
|
|
91
|
+
* staying a zombie.
|
|
92
|
+
*
|
|
93
|
+
* WEB, in order:
|
|
94
|
+
* 1. Per-apex `/auth/silent` iframe — {@link mintSessionViaPerApexIframe}
|
|
95
|
+
* (shared verbatim with cold boot). The durable cross-apex path; also
|
|
96
|
+
* covers `*.oxy.so` (the per-apex host IS the central host there).
|
|
97
|
+
* 2. FedCM silent re-auth (Chrome) — `silentSignInWithFedCM`.
|
|
98
|
+
* 3. Same-apex refresh cookie — `refreshAllSessions` + the shared
|
|
99
|
+
* {@link selectActiveRefreshAccount}; plant the active account's rotated
|
|
100
|
+
* token. On a cross-apex RP it returns `{accounts:[]}` (clean no-op).
|
|
101
|
+
* Unlike the cold-boot cookie restore this does NOT rebuild multi-session
|
|
102
|
+
* state — an in-session refresh only needs a fresh bearer.
|
|
103
|
+
*
|
|
104
|
+
* NO RECURSION: no arm issues a request through the authed client's
|
|
105
|
+
* `refreshAccessToken` path. The iframe/FedCM transports are postMessage /
|
|
106
|
+
* credential APIs; the follow-up `/session/user` fetch inside `silentSignIn`
|
|
107
|
+
* runs against the just-planted FULL-TTL token (≫ the preflight lead), so it
|
|
108
|
+
* never re-enters the refresh path; `refreshAllSessions` uses a raw `fetch`.
|
|
109
|
+
*/
|
|
110
|
+
export function createInSessionRefreshHandler(oxyServices: OxyServices): AuthRefreshHandler {
|
|
111
|
+
const runArm = async (label: string, reason: AuthRefreshReason, arm: RefreshArm): Promise<string | null> => {
|
|
112
|
+
try {
|
|
113
|
+
return await arm();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (__DEV__) {
|
|
116
|
+
loggerUtil.debug(
|
|
117
|
+
`In-session refresh arm "${label}" failed (falling through)`,
|
|
118
|
+
{ component: 'inSessionTokenRefresh', method: 'authRefreshHandler', reason },
|
|
119
|
+
error,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const webArms: Array<[string, RefreshArm]> = [
|
|
127
|
+
['silent-iframe', async () =>
|
|
128
|
+
(await mintSessionViaPerApexIframe(oxyServices, SILENT_IFRAME_REFRESH_TIMEOUT))
|
|
129
|
+
? oxyServices.getAccessToken()
|
|
130
|
+
: null,
|
|
131
|
+
],
|
|
132
|
+
['fedcm-silent', async () => {
|
|
133
|
+
if (oxyServices.isFedCMSupported?.() !== true) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return (await oxyServices.silentSignInWithFedCM?.()) ? oxyServices.getAccessToken() : null;
|
|
137
|
+
}],
|
|
138
|
+
['refresh-cookie', async () => {
|
|
139
|
+
const snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_REFRESH_TIMEOUT });
|
|
140
|
+
if (snapshot.accounts.length === 0) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const active = selectActiveRefreshAccount(snapshot.accounts, readActiveAuthuser());
|
|
144
|
+
oxyServices.httpService.setTokens(active.accessToken);
|
|
145
|
+
return oxyServices.getAccessToken();
|
|
146
|
+
}],
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
return async (reason: AuthRefreshReason): Promise<string | null> => {
|
|
150
|
+
if (!isWebBrowser()) {
|
|
151
|
+
return runArm('native-shared-key', reason, async () =>
|
|
152
|
+
(await oxyServices.signInWithSharedIdentity?.()) ? oxyServices.getAccessToken() : null,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const [label, arm] of webArms) {
|
|
157
|
+
const token = await runArm(label, reason, arm);
|
|
158
|
+
if (token) {
|
|
159
|
+
return token;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Handle returned by {@link startTokenRefreshScheduler}; call `dispose()` to tear
|
|
168
|
+
* down the timer and the foreground listener.
|
|
169
|
+
*/
|
|
170
|
+
export interface TokenRefreshSchedulerHandle {
|
|
171
|
+
dispose(): void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Start the proactive in-session refresh scheduler against `oxyServices`.
|
|
176
|
+
*
|
|
177
|
+
* Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
|
|
178
|
+
* current access token's `exp`, calling `httpService.refreshAccessToken`
|
|
179
|
+
* ('preflight') — which runs the installed handler and is deduped + cooldown-
|
|
180
|
+
* guarded. After every attempt it reschedules from the (possibly rotated) token.
|
|
181
|
+
* It also reschedules whenever the token changes (`onTokensChanged` — so a
|
|
182
|
+
* sign-out that clears the token cancels the timer) and, on web tab-focus /
|
|
183
|
+
* native app-foreground, refreshes immediately if already inside the lead window
|
|
184
|
+
* (a long-hidden tab throttles timers, so the token can be expired on return).
|
|
185
|
+
*
|
|
186
|
+
* The `exp` is derived directly from the JWT via `getAccessTokenExpiry()`. No-ops
|
|
187
|
+
* cleanly when there is no token, an opaque/no-`exp` token, or the host lacks
|
|
188
|
+
* `getAccessTokenExpiry` (older stubs) — the reactive 401 path stays the only
|
|
189
|
+
* refresh trigger in those cases.
|
|
190
|
+
*/
|
|
191
|
+
export function startTokenRefreshScheduler(oxyServices: OxyServices): TokenRefreshSchedulerHandle {
|
|
192
|
+
let disposed = false;
|
|
193
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
194
|
+
|
|
195
|
+
const clearTimer = (): void => {
|
|
196
|
+
if (timer !== null) {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
timer = null;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const runRefresh = (): void => {
|
|
203
|
+
// `refreshAccessToken` is deduped + cooldown-guarded internally, so this is
|
|
204
|
+
// safe to call from the timer, the foreground listener, and request-time
|
|
205
|
+
// preflight concurrently — they collapse to one network attempt.
|
|
206
|
+
const refresh = oxyServices.httpService.refreshAccessToken?.('preflight');
|
|
207
|
+
if (!refresh) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
void refresh
|
|
211
|
+
.catch(() => null)
|
|
212
|
+
.finally(() => {
|
|
213
|
+
if (!disposed) {
|
|
214
|
+
schedule();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const schedule = (): void => {
|
|
220
|
+
clearTimer();
|
|
221
|
+
if (disposed || !oxyServices.getAccessToken()) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const expSeconds = oxyServices.getAccessTokenExpiry?.() ?? null;
|
|
225
|
+
if (expSeconds === null) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const fireInMs = expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS;
|
|
229
|
+
timer = setTimeout(runRefresh, Math.max(fireInMs, 0));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const onForeground = (): void => {
|
|
233
|
+
if (disposed || !oxyServices.getAccessToken()) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const expSeconds = oxyServices.getAccessTokenExpiry?.() ?? null;
|
|
237
|
+
if (expSeconds === null) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const remainingMs = expSeconds * 1000 - Date.now();
|
|
241
|
+
if (remainingMs <= TOKEN_REFRESH_LEAD_MS) {
|
|
242
|
+
runRefresh();
|
|
243
|
+
} else {
|
|
244
|
+
schedule();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const unsubscribeTokens = oxyServices.onTokensChanged(() => {
|
|
249
|
+
if (!disposed) {
|
|
250
|
+
schedule();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
let removeForeground: (() => void) | null = null;
|
|
255
|
+
if (isWebBrowser() && typeof document !== 'undefined') {
|
|
256
|
+
const handler = (): void => {
|
|
257
|
+
if (document.visibilityState === 'visible') {
|
|
258
|
+
onForeground();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
document.addEventListener('visibilitychange', handler);
|
|
262
|
+
removeForeground = () => document.removeEventListener('visibilitychange', handler);
|
|
263
|
+
} else if (!isWebBrowser() && AppState && typeof AppState.addEventListener === 'function') {
|
|
264
|
+
const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
|
|
265
|
+
if (state === 'active') {
|
|
266
|
+
onForeground();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
removeForeground = () => subscription.remove();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
schedule();
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
dispose(): void {
|
|
276
|
+
disposed = true;
|
|
277
|
+
clearTimer();
|
|
278
|
+
unsubscribeTokens();
|
|
279
|
+
removeForeground?.();
|
|
280
|
+
removeForeground = null;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -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
|
+
}
|