@oxyhq/services 8.5.0 → 8.6.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/components/OxyProvider.js +2 -0
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/SignInModal.js +4 -3
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +206 -39
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +3 -3
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/appName.js +62 -0
- package/lib/commonjs/ui/utils/appName.js.map +1 -0
- package/lib/module/ui/components/OxyProvider.js +2 -0
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/SignInModal.js +4 -3
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +206 -39
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/screens/OxyAuthScreen.js +3 -3
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/ui/utils/appName.js +59 -0
- package/lib/module/ui/utils/appName.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +12 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/appName.d.ts +22 -0
- package/lib/typescript/commonjs/ui/utils/appName.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +12 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/appName.d.ts +22 -0
- package/lib/typescript/module/ui/utils/appName.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/components/SignInModal.tsx +3 -3
- package/src/ui/context/OxyContext.tsx +215 -32
- package/src/ui/screens/OxyAuthScreen.tsx +3 -3
- package/src/ui/types/navigation.ts +8 -0
- package/src/ui/utils/__tests__/appName.test.ts +52 -0
- package/src/ui/utils/appName.ts +62 -0
|
@@ -42,6 +42,7 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
|
|
|
42
42
|
import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
|
|
43
43
|
import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
|
|
44
44
|
import { readActiveAuthuser, writeActiveAuthuser } from '../utils/activeAuthuser';
|
|
45
|
+
import { resolveAppDisplayName } from '../utils/appName';
|
|
45
46
|
import type { RouteName } from '../navigation/routes';
|
|
46
47
|
import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
|
|
47
48
|
import { useQueryClient } from '@tanstack/react-query';
|
|
@@ -114,6 +115,13 @@ export interface OxyContextState {
|
|
|
114
115
|
clearSessionState: () => Promise<void>;
|
|
115
116
|
clearAllAccountData: () => Promise<void>;
|
|
116
117
|
storageKeyPrefix: string;
|
|
118
|
+
/**
|
|
119
|
+
* Resolved human-readable app display name surfaced on the central Oxy
|
|
120
|
+
* sign-in / consent experience (e.g. "Mention wants to access your Oxy
|
|
121
|
+
* account"). Always non-empty — derived from the `appName` prop, then
|
|
122
|
+
* `storageKeyPrefix`, then `document.title` (web), then the platform.
|
|
123
|
+
*/
|
|
124
|
+
appName: string;
|
|
117
125
|
oxyServices: OxyServices;
|
|
118
126
|
useFollow?: UseFollowHook;
|
|
119
127
|
showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
|
|
@@ -140,6 +148,11 @@ export interface OxyContextProviderProps {
|
|
|
140
148
|
authWebUrl?: string;
|
|
141
149
|
authRedirectUri?: string;
|
|
142
150
|
storageKeyPrefix?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Human-readable name of the consuming app shown on the central Oxy
|
|
153
|
+
* sign-in / consent experience. See {@link OxyContextState.appName}.
|
|
154
|
+
*/
|
|
155
|
+
appName?: string;
|
|
143
156
|
onAuthStateChange?: (user: User | null) => void;
|
|
144
157
|
onError?: (error: ApiError) => void;
|
|
145
158
|
}
|
|
@@ -178,6 +191,60 @@ function silentColdBootKey(oxyServices: OxyServices): string {
|
|
|
178
191
|
return `${origin}|${baseURL}`;
|
|
179
192
|
}
|
|
180
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Per-step fail-fast budget for the cold-boot silent iframe (`silentSignIn`
|
|
196
|
+
* against the per-apex `/auth/silent` host).
|
|
197
|
+
*
|
|
198
|
+
* This step ONLY succeeds when a durable per-apex `fedcm_session` cookie exists
|
|
199
|
+
* (established by a prior `/sso` bounce). On the common reload of a logged-out
|
|
200
|
+
* tab — or a tab that restores via the now-earlier stored-session step — the
|
|
201
|
+
* iframe never posts a message, so the full wait would be dead latency in front
|
|
202
|
+
* of the terminal `/sso` bounce. `silentSignIn` already fails fast on a load
|
|
203
|
+
* error via `iframe.onerror`; this caps the no-message case. 2.5s is well above
|
|
204
|
+
* a same-origin iframe handshake yet a fraction of the legacy 5s default.
|
|
205
|
+
*/
|
|
206
|
+
const SILENT_IFRAME_TIMEOUT = 2500;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Per-step fail-fast budget for the cold-boot refresh-cookie restore
|
|
210
|
+
* (`refreshAllSessions`).
|
|
211
|
+
*
|
|
212
|
+
* On a cross-domain RP the `Domain=oxy.so` refresh cookie never reaches
|
|
213
|
+
* `api.<apex>`, so this request returns no accounts (or stalls behind a slow
|
|
214
|
+
* endpoint) with no useful answer. As one cold-boot step it must not block the
|
|
215
|
+
* fall-through to the terminal `/sso` bounce. 3s bounds the wait while leaving
|
|
216
|
+
* ample headroom for a genuine first-party `*.oxy.so` rotation round-trip.
|
|
217
|
+
*/
|
|
218
|
+
const COOKIE_RESTORE_TIMEOUT = 3000;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* HARD overall deadline (ms) for the entire cold-boot step loop —
|
|
222
|
+
* defense-in-depth so a single non-settling step can NEVER hang auth resolution
|
|
223
|
+
* forever (the production regression: a `navigator.credentials.get()` that
|
|
224
|
+
* ignored its abort signal left the `fedcm-silent` step's promise unsettled, so
|
|
225
|
+
* `runColdBoot` never advanced to the terminal `/sso` bounce and auth hung
|
|
226
|
+
* indefinitely).
|
|
227
|
+
*
|
|
228
|
+
* Every step ALREADY bounds its own network work (the stored-session bearer
|
|
229
|
+
* validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT`, the refresh
|
|
230
|
+
* cookie at `COOKIE_RESTORE_TIMEOUT`, FedCM silent at `FEDCM_SILENT_TIMEOUT`
|
|
231
|
+
* plus its hard settle). On a healthy load the FIRST recovering step wins in a
|
|
232
|
+
* single round-trip (1–3s) and the chain short-circuits long before this fires.
|
|
233
|
+
* This budget only trips when one of those per-step bounds regresses.
|
|
234
|
+
*
|
|
235
|
+
* 20s is the chosen value: comfortably ABOVE the worst-case bounded
|
|
236
|
+
* stored-session path under transient slowness (the 8s parallel validation
|
|
237
|
+
* window plus a `switchSession` round-trip) so a genuinely slow-but-healthy
|
|
238
|
+
* reload is never cut off, yet well BELOW the ~28–30s the previous
|
|
239
|
+
* probe-first ordering took — and, critically, finite, so the user can never
|
|
240
|
+
* sit on an indefinite spinner. When the deadline trips, `runColdBoot` keeps
|
|
241
|
+
* iterating to the terminal `sso-bounce` step (whose navigation side effect
|
|
242
|
+
* runs synchronously), so a genuine no-local-session first visit STILL reaches
|
|
243
|
+
* the cross-domain `/sso` fallback. Native runs only the stored-session step,
|
|
244
|
+
* which is bounded well under this, so the deadline never alters native flow.
|
|
245
|
+
*/
|
|
246
|
+
const COLD_BOOT_OVERALL_DEADLINE = 20000;
|
|
247
|
+
|
|
181
248
|
/**
|
|
182
249
|
* Whether `idpOrigin` is a same-site, first-party host of the current page —
|
|
183
250
|
* i.e. it shares the page's registrable apex (last two labels), so a "no
|
|
@@ -247,6 +314,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
247
314
|
authWebUrl,
|
|
248
315
|
authRedirectUri,
|
|
249
316
|
storageKeyPrefix = 'oxy_session',
|
|
317
|
+
appName: appNameProp,
|
|
250
318
|
onAuthStateChange,
|
|
251
319
|
onError,
|
|
252
320
|
}) => {
|
|
@@ -300,10 +368,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
300
368
|
|
|
301
369
|
const [tokenReady, setTokenReady] = useState(true);
|
|
302
370
|
// Whether the FIRST cold-boot auth restore has concluded. Starts `false`
|
|
303
|
-
// (auth undetermined) and flips to `true` exactly once
|
|
304
|
-
//
|
|
305
|
-
//
|
|
371
|
+
// (auth undetermined) and flips to `true` exactly once — monotonic, never
|
|
372
|
+
// reverts. It now flips the MOMENT a session commits (the common reload case
|
|
373
|
+
// unblocks immediately, without waiting for the rest of the cold-boot chain),
|
|
374
|
+
// with the restore `finally` as the no-session/error backstop. The ref makes
|
|
375
|
+
// the flip idempotent across both sites so the setters fire at most once. See
|
|
376
|
+
// `isAuthResolved` on the context type for the consumer contract.
|
|
306
377
|
const [authResolved, setAuthResolved] = useState(false);
|
|
378
|
+
const authResolvedRef = useRef(false);
|
|
307
379
|
const [initialized, setInitialized] = useState(false);
|
|
308
380
|
const setAuthState = useAuthStore.setState;
|
|
309
381
|
|
|
@@ -352,6 +424,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
352
424
|
|
|
353
425
|
const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
|
|
354
426
|
|
|
427
|
+
// Human-readable app display name for the central sign-in / consent UI.
|
|
428
|
+
// Derived once from the consumer config; never "web" unless the app supplies
|
|
429
|
+
// no name, no custom prefix, and no document title.
|
|
430
|
+
const appName = useMemo(
|
|
431
|
+
() => resolveAppDisplayName(appNameProp, storageKeyPrefix),
|
|
432
|
+
[appNameProp, storageKeyPrefix],
|
|
433
|
+
);
|
|
434
|
+
|
|
355
435
|
// Storage initialization.
|
|
356
436
|
//
|
|
357
437
|
// `storage` (state) drives render-time gating (`isStorageReady`) and the
|
|
@@ -565,6 +645,26 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
565
645
|
const onAuthStateChangeRef = useRef(onAuthStateChange);
|
|
566
646
|
onAuthStateChangeRef.current = onAuthStateChange;
|
|
567
647
|
|
|
648
|
+
// Flip the auth-resolution gate (`authResolved` + `tokenReady`) the MOMENT a
|
|
649
|
+
// session commits, instead of waiting for the whole cold-boot chain to finish.
|
|
650
|
+
// Idempotent and monotonic via `authResolvedRef`: the first call wins and the
|
|
651
|
+
// setters fire at most once, so the restore `finally` backstop becomes a no-op
|
|
652
|
+
// once a commit site has already marked resolution. Called from EVERY place a
|
|
653
|
+
// user is actually committed (the FedCM/iframe/redirect/SSO path
|
|
654
|
+
// `handleWebSSOSession`, the cookie-restore path, and the stored-session path)
|
|
655
|
+
// so the common reload case unblocks the loading gate without sitting behind
|
|
656
|
+
// the remaining (now-skipped) cold-boot steps.
|
|
657
|
+
const markAuthResolved = useCallback(() => {
|
|
658
|
+
if (authResolvedRef.current) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
authResolvedRef.current = true;
|
|
662
|
+
setTokenReady(true);
|
|
663
|
+
setAuthResolved(true);
|
|
664
|
+
}, []);
|
|
665
|
+
const markAuthResolvedRef = useRef(markAuthResolved);
|
|
666
|
+
markAuthResolvedRef.current = markAuthResolved;
|
|
667
|
+
|
|
568
668
|
// `handleWebSSOSession` is declared further down (it depends on values that
|
|
569
669
|
// are only available there). The FedCM/iframe cold-boot steps need to commit
|
|
570
670
|
// a recovered session through it, so we route the call through a ref that is
|
|
@@ -598,7 +698,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
598
698
|
|
|
599
699
|
let snapshot;
|
|
600
700
|
try {
|
|
601
|
-
|
|
701
|
+
// Bound the refresh so a cross-domain/stalled call cannot hang the cold
|
|
702
|
+
// boot in front of the terminal `/sso` bounce (see COOKIE_RESTORE_TIMEOUT).
|
|
703
|
+
snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_RESTORE_TIMEOUT });
|
|
602
704
|
} catch (fetchError) {
|
|
603
705
|
// Offline / network error — fall through to the cached/stored-session flow.
|
|
604
706
|
if (__DEV__) {
|
|
@@ -662,6 +764,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
662
764
|
await persistSessionDurably(activeAccount.sessionId);
|
|
663
765
|
|
|
664
766
|
loginSuccessRef.current(fullUser);
|
|
767
|
+
// A session is now committed — unblock the auth-resolution gate immediately
|
|
768
|
+
// rather than waiting for `runColdBoot` to return (idempotent).
|
|
769
|
+
markAuthResolvedRef.current();
|
|
665
770
|
onAuthStateChangeRef.current?.(fullUser);
|
|
666
771
|
return true;
|
|
667
772
|
}, [oxyServices, persistSessionDurably]);
|
|
@@ -739,6 +844,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
739
844
|
if (storedActiveSessionId) {
|
|
740
845
|
try {
|
|
741
846
|
await switchSessionRef.current(storedActiveSessionId);
|
|
847
|
+
// The stored session is committed (this is native's ONLY restore path
|
|
848
|
+
// and the common web reload winner). Unblock the auth-resolution gate
|
|
849
|
+
// immediately so the loading screen clears without waiting for the
|
|
850
|
+
// remaining cold-boot steps to be evaluated/short-circuited (idempotent).
|
|
851
|
+
markAuthResolvedRef.current();
|
|
742
852
|
return true;
|
|
743
853
|
} catch (switchError) {
|
|
744
854
|
// Silently handle expected errors (invalid sessions, timeouts, network issues)
|
|
@@ -841,11 +951,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
841
951
|
// web-only step is gated by `isWebBrowser()`, so on native ONLY
|
|
842
952
|
// `stored-session` runs.
|
|
843
953
|
//
|
|
844
|
-
// Order (web): redirect callback → SSO return → FedCM silent
|
|
845
|
-
// silent iframe (per-apex, the durable reload path) → cookie
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
954
|
+
// Order (web): redirect callback → SSO return → stored session → FedCM silent
|
|
955
|
+
// (central) → silent iframe (per-apex, the durable reload path) → cookie
|
|
956
|
+
// restore → SSO bounce (terminal).
|
|
957
|
+
//
|
|
958
|
+
// LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect probes
|
|
959
|
+
// (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a normal reload the
|
|
960
|
+
// local bearer validates in one round-trip and wins, so `runColdBoot`
|
|
961
|
+
// short-circuits and never sits through those probes' timeouts (the prior
|
|
962
|
+
// serial sum was a ~20-30s stall). `redirect` and `sso-return` MUST stay
|
|
963
|
+
// first — they consume the URL fragment before anything can strip it. On a
|
|
964
|
+
// first visit with no local session, `stored-session` skips and the
|
|
965
|
+
// cross-domain fallback chain (fedcm → iframe → cookie → sso-bounce) runs
|
|
966
|
+
// exactly as before; the per-apex silent iframe still restores a durable
|
|
967
|
+
// cross-domain session on reload WITHOUT a top-level bounce, so when it wins
|
|
968
|
+
// `sso-bounce` never fires (no flash, no loop).
|
|
849
969
|
// Order (native): stored session only (every web-only step is disabled
|
|
850
970
|
// off-browser).
|
|
851
971
|
const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
|
|
@@ -859,6 +979,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
859
979
|
const silentKey = silentColdBootKey(oxyServices);
|
|
860
980
|
const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
|
|
861
981
|
|
|
982
|
+
// FIX-B precondition flag: set true the instant the (now-earlier)
|
|
983
|
+
// `stored-session` step recovers a local bearer session. The slow web-only
|
|
984
|
+
// probes (`fedcm-silent`, `silent-iframe`) AND `enabled` on `!storedSessionRestored`
|
|
985
|
+
// so they are explicitly skipped once a local session won. `runColdBoot`
|
|
986
|
+
// already short-circuits on the first `{kind:'session'}`, so on a winning
|
|
987
|
+
// reload those `enabled` bodies are never even reached — this flag makes the
|
|
988
|
+
// intent explicit and is redundant-safe. On a first-visit-no-local-session,
|
|
989
|
+
// `stored-session` skips, this stays false, and the probes run as before.
|
|
990
|
+
let storedSessionRestored = false;
|
|
991
|
+
|
|
862
992
|
try {
|
|
863
993
|
const outcome = await runColdBoot<true>({
|
|
864
994
|
steps: [
|
|
@@ -895,14 +1025,47 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
895
1025
|
},
|
|
896
1026
|
},
|
|
897
1027
|
{
|
|
898
|
-
// 2)
|
|
1028
|
+
// 2) Stored-session bearer restore. NO `enabled` gate — runs on ALL
|
|
1029
|
+
// platforms. This is native's ONLY restore path (every web-only step
|
|
1030
|
+
// is disabled off-browser, so native reaches exactly this) AND the
|
|
1031
|
+
// common WEB reload winner.
|
|
1032
|
+
//
|
|
1033
|
+
// ORDERING (FIX A): this step now runs BEFORE the slow web-only
|
|
1034
|
+
// probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
|
|
1035
|
+
// normal reload the local bearer validates in one round-trip and
|
|
1036
|
+
// wins; `runColdBoot` then short-circuits and never even evaluates
|
|
1037
|
+
// the slow no-redirect probes that would otherwise time out (the
|
|
1038
|
+
// ~20-30s serial stall). The `redirect` and `sso-return` steps stay
|
|
1039
|
+
// AHEAD of this one — they must consume the URL fragment before any
|
|
1040
|
+
// later step (or anything else) strips it. On a first visit with no
|
|
1041
|
+
// local session this step skips and the cross-domain fallback chain
|
|
1042
|
+
// (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
|
|
1043
|
+
id: 'stored-session',
|
|
1044
|
+
run: async () => {
|
|
1045
|
+
const restored = await restoreStoredSession();
|
|
1046
|
+
if (restored) {
|
|
1047
|
+
// FIX-B: record the win so the slow probes below explicitly skip
|
|
1048
|
+
// (belt-and-suspenders; `runColdBoot` already short-circuits).
|
|
1049
|
+
storedSessionRestored = true;
|
|
1050
|
+
return { kind: 'session', session: true };
|
|
1051
|
+
}
|
|
1052
|
+
return { kind: 'skip' };
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
// 3) FedCM silent reauthn (Chrome) against the CENTRAL IdP
|
|
899
1057
|
// (auth.oxy.so). `silentSignInWithFedCM` plants the access token
|
|
900
1058
|
// internally; we commit the returned session via
|
|
901
1059
|
// `handleWebSSOSession`. Guarded so it fires at most once per page
|
|
902
1060
|
// load across remounts. This is an enhancement layered above the
|
|
903
1061
|
// opaque-code bounce: when it succeeds the bounce never fires.
|
|
1062
|
+
//
|
|
1063
|
+
// FIX-B: additionally skipped when the earlier `stored-session` step
|
|
1064
|
+
// already recovered a local session — the probe cannot improve on a
|
|
1065
|
+
// valid local bearer, and skipping it avoids the silent round-trip.
|
|
904
1066
|
id: 'fedcm-silent',
|
|
905
|
-
enabled: () =>
|
|
1067
|
+
enabled: () =>
|
|
1068
|
+
!storedSessionRestored && fedcmSupported && !servicesSilentAttempted.has(silentKey),
|
|
906
1069
|
run: async () => {
|
|
907
1070
|
servicesSilentAttempted.add(silentKey);
|
|
908
1071
|
const session = await oxyServices.silentSignInWithFedCM?.();
|
|
@@ -914,7 +1077,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
914
1077
|
},
|
|
915
1078
|
},
|
|
916
1079
|
{
|
|
917
|
-
//
|
|
1080
|
+
// 4) First-party silent iframe at the PER-APEX IdP — the DURABLE
|
|
918
1081
|
// cross-domain reload-restore path. The durable session lives as a
|
|
919
1082
|
// first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
|
|
920
1083
|
// `auth.mention.earth`), established during the `/sso` bounce's
|
|
@@ -933,8 +1096,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
933
1096
|
// equivalent. When auto-detection bails (localhost/IP/single-label)
|
|
934
1097
|
// there is no per-apex IdP and the step skips. Web only; on native
|
|
935
1098
|
// `isWebBrowser()` gates it off, so native never runs an iframe.
|
|
1099
|
+
//
|
|
1100
|
+
// FIX-B: additionally skipped when `stored-session` already won.
|
|
1101
|
+
// FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
|
|
1102
|
+
// fail-fast in core) so a no-message iframe cannot stall cold boot.
|
|
936
1103
|
id: 'silent-iframe',
|
|
937
|
-
enabled: () => isWebBrowser(),
|
|
1104
|
+
enabled: () => !storedSessionRestored && isWebBrowser(),
|
|
938
1105
|
run: async () => {
|
|
939
1106
|
const perApexAuthUrl = autoDetectAuthWebUrl();
|
|
940
1107
|
if (!perApexAuthUrl || !commitWebSession) {
|
|
@@ -942,6 +1109,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
942
1109
|
}
|
|
943
1110
|
const session = await oxyServices.silentSignIn?.({
|
|
944
1111
|
authWebUrlOverride: perApexAuthUrl,
|
|
1112
|
+
timeout: SILENT_IFRAME_TIMEOUT,
|
|
945
1113
|
});
|
|
946
1114
|
if (!session?.user || !session?.sessionId) {
|
|
947
1115
|
return { kind: 'skip' };
|
|
@@ -951,12 +1119,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
951
1119
|
},
|
|
952
1120
|
},
|
|
953
1121
|
{
|
|
954
|
-
//
|
|
1122
|
+
// 5) Refresh-cookie restore (first-party only). On `*.oxy.so` the
|
|
955
1123
|
// httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
|
|
956
1124
|
// device-local slot. On a cross-domain RP (mention.earth, …) the
|
|
957
1125
|
// cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
|
|
958
1126
|
// `refreshAllSessions` returns `{accounts:[]}` and this skips. That
|
|
959
1127
|
// is correct; cross-domain restore is handled by the SSO bounce.
|
|
1128
|
+
// FIX-D: `restoreViaRefreshCookie` bounds the request with
|
|
1129
|
+
// `COOKIE_RESTORE_TIMEOUT` so a cross-domain stall cannot hang here.
|
|
960
1130
|
id: 'cookie-restore',
|
|
961
1131
|
enabled: () => isWebBrowser(),
|
|
962
1132
|
run: async () => {
|
|
@@ -964,16 +1134,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
964
1134
|
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
965
1135
|
},
|
|
966
1136
|
},
|
|
967
|
-
{
|
|
968
|
-
// 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
|
|
969
|
-
// platforms. This is native's ONLY restore path (every web-only step
|
|
970
|
-
// is disabled off-browser, so native reaches exactly this).
|
|
971
|
-
id: 'stored-session',
|
|
972
|
-
run: async () => {
|
|
973
|
-
const restored = await restoreStoredSession();
|
|
974
|
-
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
975
|
-
},
|
|
976
|
-
},
|
|
977
1137
|
{
|
|
978
1138
|
// 6) SSO bounce (TERMINAL, web only, at most once). No local session
|
|
979
1139
|
// was found by any step above. Top-level navigate to the central
|
|
@@ -1034,6 +1194,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1034
1194
|
);
|
|
1035
1195
|
}
|
|
1036
1196
|
},
|
|
1197
|
+
// Defense-in-depth: a single step whose promise never settles (the
|
|
1198
|
+
// production FedCM-silent hang) can no longer block the chain forever.
|
|
1199
|
+
// On expiry the runner keeps iterating to the terminal `sso-bounce`
|
|
1200
|
+
// step so a genuine no-local-session visit still reaches the
|
|
1201
|
+
// cross-domain `/sso` fallback; the `finally` backstop flips
|
|
1202
|
+
// `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
|
|
1203
|
+
overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
|
|
1204
|
+
onStepDeadline: (id) => {
|
|
1205
|
+
if (__DEV__) {
|
|
1206
|
+
loggerUtil.debug(
|
|
1207
|
+
`Cold-boot step "${id}" exceeded the overall deadline (abandoned, falling through)`,
|
|
1208
|
+
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1037
1212
|
});
|
|
1038
1213
|
|
|
1039
1214
|
if (__DEV__ && outcome.kind === 'session') {
|
|
@@ -1048,14 +1223,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1048
1223
|
}
|
|
1049
1224
|
await clearSessionStateRef.current();
|
|
1050
1225
|
} finally {
|
|
1051
|
-
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
// (
|
|
1057
|
-
// `
|
|
1058
|
-
|
|
1226
|
+
// Backstop: mark auth resolved on EVERY exit path — success, no-session,
|
|
1227
|
+
// AND error→catch→finally — and on native (which only runs the
|
|
1228
|
+
// `stored-session` step), so the gate can never hang `false`. Idempotent
|
|
1229
|
+
// via `markAuthResolved`'s ref: when a commit site already flipped it
|
|
1230
|
+
// mid-chain (the common reload case), this is a no-op. When no session was
|
|
1231
|
+
// recovered (the unauthenticated/error path), this is where `tokenReady` +
|
|
1232
|
+
// `authResolved` finally flip. Monotonic — never reverts on later restores.
|
|
1233
|
+
markAuthResolved();
|
|
1059
1234
|
}
|
|
1060
1235
|
}, [
|
|
1061
1236
|
oxyServices,
|
|
@@ -1063,6 +1238,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1063
1238
|
restoreViaRefreshCookie,
|
|
1064
1239
|
restoreStoredSession,
|
|
1065
1240
|
runSsoReturn,
|
|
1241
|
+
markAuthResolved,
|
|
1066
1242
|
]);
|
|
1067
1243
|
|
|
1068
1244
|
useEffect(() => {
|
|
@@ -1216,6 +1392,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1216
1392
|
fullUser = session.user as unknown as User;
|
|
1217
1393
|
}
|
|
1218
1394
|
loginSuccess(fullUser);
|
|
1395
|
+
// A session is now committed (FedCM silent / per-apex iframe / redirect /
|
|
1396
|
+
// SSO-return / popup all funnel through here) — unblock the auth-resolution
|
|
1397
|
+
// gate immediately, ahead of the cold-boot chain returning (idempotent).
|
|
1398
|
+
markAuthResolvedRef.current();
|
|
1219
1399
|
onAuthStateChange?.(fullUser);
|
|
1220
1400
|
}, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
|
|
1221
1401
|
|
|
@@ -1486,6 +1666,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1486
1666
|
clearSessionState,
|
|
1487
1667
|
clearAllAccountData,
|
|
1488
1668
|
storageKeyPrefix,
|
|
1669
|
+
appName,
|
|
1489
1670
|
oxyServices,
|
|
1490
1671
|
useFollow: useFollowHook,
|
|
1491
1672
|
showBottomSheet: showBottomSheetForContext,
|
|
@@ -1514,6 +1695,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1514
1695
|
logoutAllDeviceSessions,
|
|
1515
1696
|
oxyServices,
|
|
1516
1697
|
storageKeyPrefix,
|
|
1698
|
+
appName,
|
|
1517
1699
|
refreshSessionsWithUser,
|
|
1518
1700
|
sessions,
|
|
1519
1701
|
setLanguage,
|
|
@@ -1591,6 +1773,7 @@ const LOADING_STATE: OxyContextState = {
|
|
|
1591
1773
|
clearSessionState: () => rejectMissingProvider<void>(),
|
|
1592
1774
|
clearAllAccountData: () => rejectMissingProvider<void>(),
|
|
1593
1775
|
storageKeyPrefix: 'oxy_session',
|
|
1776
|
+
appName: resolveAppDisplayName(undefined, undefined),
|
|
1594
1777
|
oxyServices: LOADING_STATE_OXY_SERVICES,
|
|
1595
1778
|
openAvatarPicker: () => {},
|
|
1596
1779
|
actingAs: null,
|
|
@@ -119,7 +119,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
119
119
|
theme,
|
|
120
120
|
}) => {
|
|
121
121
|
const bloomTheme = useTheme();
|
|
122
|
-
const { oxyServices, signIn, switchSession,
|
|
122
|
+
const { oxyServices, signIn, switchSession, appName } = useOxy();
|
|
123
123
|
|
|
124
124
|
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
|
125
125
|
const [isLoading, setIsLoading] = useState(true);
|
|
@@ -273,7 +273,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
273
273
|
await oxyServices.makeRequest('POST', '/auth/session/create', {
|
|
274
274
|
sessionToken,
|
|
275
275
|
expiresAt,
|
|
276
|
-
appId:
|
|
276
|
+
appId: appName,
|
|
277
277
|
}, { cache: false });
|
|
278
278
|
|
|
279
279
|
setAuthSession({ sessionToken, expiresAt });
|
|
@@ -286,7 +286,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
286
286
|
} finally {
|
|
287
287
|
setIsLoading(false);
|
|
288
288
|
}
|
|
289
|
-
}, [oxyServices, connectSocket]);
|
|
289
|
+
}, [oxyServices, connectSocket, appName]);
|
|
290
290
|
|
|
291
291
|
// Generate a random session token
|
|
292
292
|
const generateSessionToken = (): string => {
|
|
@@ -52,6 +52,14 @@ export interface OxyProviderProps {
|
|
|
52
52
|
children?: ReactNode;
|
|
53
53
|
onAuthStateChange?: (user: unknown) => void;
|
|
54
54
|
storageKeyPrefix?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Human-readable name of the consuming app (e.g. "Mention", "Homiio").
|
|
57
|
+
* Surfaced on the central Oxy sign-in / consent experience as
|
|
58
|
+
* "{appName} wants to access your Oxy account". When omitted, the SDK
|
|
59
|
+
* derives a name from `storageKeyPrefix`, then `document.title` (web),
|
|
60
|
+
* falling back to the platform. Set this to guarantee correct branding.
|
|
61
|
+
*/
|
|
62
|
+
appName?: string;
|
|
55
63
|
baseURL?: string;
|
|
56
64
|
authWebUrl?: string;
|
|
57
65
|
authRedirectUri?: string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { resolveAppDisplayName } from '../appName';
|
|
2
|
+
|
|
3
|
+
// The shared react-native mock pins `Platform.OS` to 'web', which is exactly
|
|
4
|
+
// the platform on which the historical "web wants to access your Oxy account"
|
|
5
|
+
// regression occurred. These tests assert the resolution order that prevents it.
|
|
6
|
+
|
|
7
|
+
describe('resolveAppDisplayName', () => {
|
|
8
|
+
const originalTitle = typeof document !== 'undefined' ? document.title : '';
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (typeof document !== 'undefined') {
|
|
12
|
+
document.title = originalTitle;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('prefers an explicit appName, trimmed', () => {
|
|
17
|
+
expect(resolveAppDisplayName(' Mention ', 'oxy_session')).toBe('Mention');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('explicit appName wins over a custom storageKeyPrefix', () => {
|
|
21
|
+
expect(resolveAppDisplayName('Mention', 'homiio')).toBe('Mention');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('capitalizes a custom storageKeyPrefix when no appName is given', () => {
|
|
25
|
+
expect(resolveAppDisplayName(undefined, 'mention')).toBe('Mention');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('ignores the default storageKeyPrefix (never surfaces "Oxy_session")', () => {
|
|
29
|
+
if (typeof document !== 'undefined') {
|
|
30
|
+
document.title = '';
|
|
31
|
+
}
|
|
32
|
+
expect(resolveAppDisplayName(undefined, 'oxy_session')).toBe('web');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to document.title on web when no name or custom prefix is set', () => {
|
|
36
|
+
if (typeof document !== 'undefined') {
|
|
37
|
+
document.title = 'Homiio';
|
|
38
|
+
}
|
|
39
|
+
expect(resolveAppDisplayName(undefined, 'oxy_session')).toBe('Homiio');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to the platform only when nothing else is available', () => {
|
|
43
|
+
if (typeof document !== 'undefined') {
|
|
44
|
+
document.title = '';
|
|
45
|
+
}
|
|
46
|
+
expect(resolveAppDisplayName(undefined, undefined)).toBe('web');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('treats a whitespace-only appName as absent', () => {
|
|
50
|
+
expect(resolveAppDisplayName(' ', 'mention')).toBe('Mention');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `storageKeyPrefix` default applied by `OxyContextProvider`. When the
|
|
5
|
+
* consumer never overrides it, the prefix carries no app-identity signal and
|
|
6
|
+
* must NOT be used to derive a display name (it would surface "Oxy_session").
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_STORAGE_KEY_PREFIX = 'oxy_session';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Capitalize the first character of a non-empty string. Used to turn a lower
|
|
12
|
+
* case `storageKeyPrefix` (e.g. `"mention"`) into a presentable label
|
|
13
|
+
* (`"Mention"`). Pure; leaves the remainder untouched so multi-word or already
|
|
14
|
+
* capitalized values are preserved.
|
|
15
|
+
*/
|
|
16
|
+
function capitalizeFirst(value: string): string {
|
|
17
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a human-readable application display name for the consent / sign-in
|
|
22
|
+
* UI shown by the central Oxy auth experience (e.g. "Mention wants to access
|
|
23
|
+
* your Oxy account"). This is sent as the `appId` field on
|
|
24
|
+
* `POST /auth/session/create` and rendered verbatim by the auth consent page.
|
|
25
|
+
*
|
|
26
|
+
* Resolution order (first non-empty wins):
|
|
27
|
+
* 1. An explicit `appName` declared by the consumer on `OxyProvider`.
|
|
28
|
+
* 2. The capitalized `storageKeyPrefix` — but only when the consumer actually
|
|
29
|
+
* overrode the default. Apps already pass a brand-shaped prefix
|
|
30
|
+
* (`"mention"`, `"homiio"`, …) so this gives most apps a correct name with
|
|
31
|
+
* zero extra config.
|
|
32
|
+
* 3. On web only, a meaningful `document.title` (trimmed). This rescues
|
|
33
|
+
* zero-config web apps that set a page title but no prefix.
|
|
34
|
+
* 4. `Platform.OS` as the terminal fallback. On web this yields the historical
|
|
35
|
+
* `"web"` value — now reached ONLY when an app supplies neither an explicit
|
|
36
|
+
* name, a custom prefix, nor a document title.
|
|
37
|
+
*
|
|
38
|
+
* The result is never empty.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveAppDisplayName(
|
|
41
|
+
appName: string | undefined,
|
|
42
|
+
storageKeyPrefix: string | undefined,
|
|
43
|
+
): string {
|
|
44
|
+
const explicit = appName?.trim();
|
|
45
|
+
if (explicit) {
|
|
46
|
+
return explicit;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const prefix = storageKeyPrefix?.trim();
|
|
50
|
+
if (prefix && prefix !== DEFAULT_STORAGE_KEY_PREFIX) {
|
|
51
|
+
return capitalizeFirst(prefix);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|
55
|
+
const title = document.title?.trim();
|
|
56
|
+
if (title) {
|
|
57
|
+
return title;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Platform.OS;
|
|
62
|
+
}
|