@oxyhq/services 6.10.7 → 6.10.8

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.
@@ -97,6 +97,77 @@ export interface OxyContextState {
97
97
 
98
98
  const OxyContext = createContext<OxyContextState | null>(null);
99
99
 
100
+ /**
101
+ * Minimal, decode-only shape of the session access-token JWT claims the
102
+ * `POST /auth/refresh` endpoint returns. We only read `sessionId` (and `userId`
103
+ * as a fallback). The token is already signed and verified server-side; the
104
+ * client decodes the payload purely to recover the session id — it does NOT and
105
+ * MUST NOT verify the signature.
106
+ */
107
+ interface RefreshAccessTokenClaims {
108
+ sessionId?: string;
109
+ userId?: string;
110
+ id?: string;
111
+ }
112
+
113
+ /**
114
+ * Decode the payload of a JWT WITHOUT verifying its signature.
115
+ *
116
+ * The server (`POST /auth/refresh`) has already minted and signed this access
117
+ * token; we only need to recover the `sessionId` claim from it on cold boot.
118
+ * Returns `null` for any malformed input rather than throwing, so a bad token
119
+ * simply falls through to the unauthenticated path.
120
+ *
121
+ * Implemented with manual base64url decoding (no `jwt-decode` dependency added
122
+ * to `@oxyhq/services`). Works on web (where this cold-boot path runs) via
123
+ * `atob`; if `atob` is unavailable (non-browser runtime) it is treated as
124
+ * undecodable and returns `null`.
125
+ */
126
+ function decodeAccessTokenClaims(token: string): RefreshAccessTokenClaims | null {
127
+ if (!token || typeof token !== 'string') {
128
+ return null;
129
+ }
130
+ const segments = token.split('.');
131
+ if (segments.length !== 3) {
132
+ return null;
133
+ }
134
+ const payloadSegment = segments[1];
135
+ if (!payloadSegment) {
136
+ return null;
137
+ }
138
+ try {
139
+ const base64 = payloadSegment.replace(/-/g, '+').replace(/_/g, '/');
140
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
141
+ if (typeof atob !== 'function') {
142
+ return null;
143
+ }
144
+ const json = decodeURIComponent(
145
+ atob(padded)
146
+ .split('')
147
+ .map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`)
148
+ .join(''),
149
+ );
150
+ const parsed: unknown = JSON.parse(json);
151
+ if (parsed === null || typeof parsed !== 'object') {
152
+ return null;
153
+ }
154
+ const claims = parsed as Record<string, unknown>;
155
+ return {
156
+ sessionId: typeof claims.sessionId === 'string' ? claims.sessionId : undefined,
157
+ userId: typeof claims.userId === 'string' ? claims.userId : undefined,
158
+ id: typeof claims.id === 'string' ? claims.id : undefined,
159
+ };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /** Server response shape of `POST /auth/refresh`. */
166
+ interface RefreshCookieResponse {
167
+ accessToken?: string;
168
+ sessionId?: string;
169
+ }
170
+
100
171
  export interface OxyContextProviderProps {
101
172
  children: ReactNode;
102
173
  oxyServices?: OxyServices;
@@ -235,16 +306,58 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
235
306
 
236
307
  const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
237
308
 
238
- // Simple storage initialization - no complex hook needed
309
+ // Storage initialization.
310
+ //
311
+ // `storage` (state) drives render-time gating (`isStorageReady`) and the
312
+ // hooks below. But it is `null` for a brief window after mount while
313
+ // `createPlatformStorage()` resolves (a microtask on web; a dynamic
314
+ // `import()` on native). Any persistence path that fires during that window
315
+ // — e.g. an interactive FedCM sign-in the instant the screen mounts — would
316
+ // read `storage === null` and SILENTLY skip writing the session, leaving the
317
+ // user signed-in in-memory but with nothing to restore on reload.
318
+ //
319
+ // To make persistence robust regardless of timing we ALSO expose the storage
320
+ // as an awaitable promise (`getReadyStorage`). Persistence code awaits the
321
+ // ready instance instead of branching on the possibly-null state, so a write
322
+ // is never silently dropped just because it raced storage init.
239
323
  const storageRef = useRef<StorageInterface | null>(null);
240
324
  const [storage, setStorage] = useState<StorageInterface | null>(null);
241
325
 
326
+ // A single, stable deferred that resolves with the initialized storage. Built
327
+ // lazily via a ref initializer so the resolver is captured exactly once and
328
+ // the promise identity is stable across renders.
329
+ const buildStorageDeferred = () => {
330
+ let resolve: (storage: StorageInterface) => void = () => undefined;
331
+ const promise = new Promise<StorageInterface>((res) => {
332
+ resolve = res;
333
+ });
334
+ return { promise, resolve };
335
+ };
336
+ const storageReadyRef = useRef<ReturnType<typeof buildStorageDeferred> | null>(null);
337
+ if (storageReadyRef.current === null) {
338
+ storageReadyRef.current = buildStorageDeferred();
339
+ }
340
+ const storageReady = storageReadyRef.current;
341
+
342
+ // Resolve the storage instance that is guaranteed to be ready. Returns the
343
+ // already-initialized instance synchronously when available, otherwise awaits
344
+ // the init promise. Never resolves to `null`.
345
+ const getReadyStorage = useCallback((): Promise<StorageInterface> => {
346
+ if (storageRef.current) {
347
+ return Promise.resolve(storageRef.current);
348
+ }
349
+ return storageReady.promise;
350
+ }, [storageReady]);
351
+
242
352
  useEffect(() => {
243
353
  let mounted = true;
244
354
  createPlatformStorage()
245
355
  .then((storageInstance) => {
356
+ // Resolve the ready-promise even if the component unmounted: in-flight
357
+ // persistence awaiting it must still complete against a real store.
358
+ storageRef.current = storageInstance;
359
+ storageReady.resolve(storageInstance);
246
360
  if (mounted) {
247
- storageRef.current = storageInstance;
248
361
  setStorage(storageInstance);
249
362
  }
250
363
  })
@@ -262,7 +375,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
262
375
  return () => {
263
376
  mounted = false;
264
377
  };
265
- }, [logger, onError]);
378
+ }, [logger, onError, storageReady]);
266
379
 
267
380
 
268
381
  // Offline queuing is now handled by TanStack Query mutations
@@ -375,6 +488,148 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
375
488
  const clearSessionStateRef = useRef(clearSessionState);
376
489
  clearSessionStateRef.current = clearSessionState;
377
490
 
491
+ // Durable, navigation-safe session persistence.
492
+ //
493
+ // Writes the active-session id and appends the session id to the durable
494
+ // `session_ids` list, awaiting the READY storage instance (never the possibly
495
+ // -null `storage` state) so a write is never dropped because it raced storage
496
+ // init. Callers MUST invoke this BEFORE any work that can trigger a route
497
+ // navigation (`onAuthStateChange`) — navigation can interrupt a still-pending
498
+ // async write, which is exactly what once left `session_ids` empty after a
499
+ // successful sign-in. Shared by the FedCM/popup path and the cold-boot
500
+ // refresh-cookie restore so both land the same durable record.
501
+ const persistSessionDurably = useCallback(async (sessionId: string): Promise<void> => {
502
+ const readyStorage = await getReadyStorage();
503
+ await readyStorage.setItem(storageKeys.activeSessionId, sessionId);
504
+ const existingIds = await readyStorage.getItem(storageKeys.sessionIds);
505
+ let sessionIds: string[] = [];
506
+ try { sessionIds = existingIds ? JSON.parse(existingIds) : []; } catch { /* corrupted storage */ }
507
+ if (!sessionIds.includes(sessionId)) {
508
+ sessionIds.push(sessionId);
509
+ await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
510
+ }
511
+ }, [getReadyStorage, storageKeys.activeSessionId, storageKeys.sessionIds]);
512
+
513
+ // Refs so the cold-boot restore can plant session state without widening its
514
+ // dependency array (mirrors the existing ref pattern above).
515
+ const setActiveSessionIdRef = useRef(setActiveSessionId);
516
+ setActiveSessionIdRef.current = setActiveSessionId;
517
+ const loginSuccessRef = useRef(loginSuccess);
518
+ loginSuccessRef.current = loginSuccess;
519
+ const onAuthStateChangeRef = useRef(onAuthStateChange);
520
+ onAuthStateChangeRef.current = onAuthStateChange;
521
+
522
+ // Cold-boot session restore via the secure refresh cookie (web only).
523
+ //
524
+ // On a hard reload the in-app, bearer-protected token fetch
525
+ // (`getTokenBySession` → `/session/token/:id`) 401s because there is no token
526
+ // in memory yet, which previously cleared the session and bounced the user to
527
+ // sign-in. Instead we call `POST {apiBaseUrl}/auth/refresh` with
528
+ // `credentials: 'include'` and NO Authorization header: the browser
529
+ // automatically attaches the first-party httpOnly `oxy_rt` cookie (set at
530
+ // login/signup/fedcm-exchange), the server validates + rotates it and returns
531
+ // a fresh session access token. JS never sees the refresh cookie (httpOnly) —
532
+ // that is the security property.
533
+ //
534
+ // Returns `true` when the session was restored (caller short-circuits the
535
+ // bearer path); `false` on 401 / no durable cookie / any failure (caller
536
+ // proceeds unauthenticated through the existing flow — nothing is cleared).
537
+ const restoreViaRefreshCookie = useCallback(async (): Promise<boolean> => {
538
+ if (!isWebBrowser()) {
539
+ return false;
540
+ }
541
+
542
+ const apiBaseUrl = oxyServices.getBaseURL();
543
+ if (!apiBaseUrl) {
544
+ return false;
545
+ }
546
+
547
+ let response: Response;
548
+ try {
549
+ // Direct credentialed, no-auth POST. We deliberately bypass the SDK's
550
+ // HttpService here: it would attach the (absent) bearer and does not send
551
+ // cookies. The refresh cookie is scoped to `Path=/auth/refresh` and sent
552
+ // automatically same-site.
553
+ response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/auth/refresh`, {
554
+ method: 'POST',
555
+ credentials: 'include',
556
+ headers: { Accept: 'application/json' },
557
+ });
558
+ } catch (fetchError) {
559
+ // Offline / network error — fall through to the cached/stored-session flow.
560
+ if (__DEV__) {
561
+ loggerUtil.debug('Refresh-cookie restore network error (expected when offline)', { component: 'OxyContext', method: 'restoreViaRefreshCookie' }, fetchError as unknown);
562
+ }
563
+ return false;
564
+ }
565
+
566
+ // 401 (no/expired/reused cookie) and any non-2xx → no durable session.
567
+ if (!response.ok) {
568
+ return false;
569
+ }
570
+
571
+ let payload: RefreshCookieResponse;
572
+ try {
573
+ payload = (await response.json()) as RefreshCookieResponse;
574
+ } catch {
575
+ return false;
576
+ }
577
+
578
+ const accessToken = payload.accessToken;
579
+ if (!accessToken) {
580
+ return false;
581
+ }
582
+
583
+ // Recover the session id from the (server-signed) access-token claims, or
584
+ // from the response body if the server included it. Decode-only; the server
585
+ // already verified the signature.
586
+ const claims = decodeAccessTokenClaims(accessToken);
587
+ const sessionId = payload.sessionId ?? claims?.sessionId;
588
+ if (!sessionId) {
589
+ // A token with no resolvable session id cannot drive multi-session state.
590
+ return false;
591
+ }
592
+
593
+ // Plant the fresh access token. The refresh token stays in the httpOnly
594
+ // cookie and is never touched by JS.
595
+ oxyServices.httpService.setTokens(accessToken);
596
+
597
+ // Fetch the full user with the freshly planted token.
598
+ let fullUser: User;
599
+ try {
600
+ fullUser = await oxyServices.getCurrentUser();
601
+ } catch (userError) {
602
+ // Token planted but profile fetch failed (e.g. transient network). Do not
603
+ // claim a restored session; fall through so the stored-session flow can
604
+ // retry. Leave the planted token in place — it is valid and harmless.
605
+ if (__DEV__) {
606
+ loggerUtil.debug('Refresh-cookie restore: getCurrentUser failed', { component: 'OxyContext', method: 'restoreViaRefreshCookie' }, userError as unknown);
607
+ }
608
+ return false;
609
+ }
610
+
611
+ const userId = fullUser.id?.toString() ?? claims?.userId ?? '';
612
+ const now = new Date();
613
+ const clientSession: ClientSession = {
614
+ sessionId,
615
+ deviceId: '',
616
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
617
+ lastActive: now.toISOString(),
618
+ userId,
619
+ isCurrent: true,
620
+ };
621
+
622
+ // Restore the active session into the multi-session store (merge: keep any
623
+ // other sessions intact) and durably persist BEFORE notifying listeners.
624
+ updateSessionsRef.current([clientSession], { merge: true });
625
+ setActiveSessionIdRef.current(sessionId);
626
+ await persistSessionDurably(sessionId);
627
+
628
+ loginSuccessRef.current(fullUser);
629
+ onAuthStateChangeRef.current?.(fullUser);
630
+ return true;
631
+ }, [oxyServices, persistSessionDurably]);
632
+
378
633
  const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
379
634
  if (!storage) {
380
635
  return;
@@ -383,6 +638,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
383
638
  setTokenReady(false);
384
639
 
385
640
  try {
641
+ // Web cold-boot fast path: restore the active session from the secure
642
+ // httpOnly refresh cookie before the bearer-protected stored-session
643
+ // validation (which 401s on a hard reload). On success we are signed in
644
+ // from the cookie alone — no FedCM needed. On failure we fall through to
645
+ // the existing stored-session flow below; nothing is cleared.
646
+ if (await restoreViaRefreshCookie()) {
647
+ return;
648
+ }
649
+
386
650
  const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
387
651
  const storedSessionIds: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
388
652
  const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
@@ -473,6 +737,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
473
737
  storage,
474
738
  storageKeys.activeSessionId,
475
739
  storageKeys.sessionIds,
740
+ restoreViaRefreshCookie,
476
741
  ]);
477
742
 
478
743
  useEffect(() => {
@@ -517,8 +782,25 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
517
782
  updateSessions([clientSession], { merge: true });
518
783
  setActiveSessionId(session.sessionId);
519
784
 
520
- // Fetch the full user profile now that we have a valid access token.
521
- // The session only carries MinimalUserData; the store and callbacks expect a full User.
785
+ // Persist to storage BEFORE fetching the profile and BEFORE notifying the
786
+ // auth-state-change callback. The token is planted and the in-memory store
787
+ // is updated above; durably committing the session id here — ahead of
788
+ // `getCurrentUser()` / `loginSuccess` / `onAuthStateChange` — is critical
789
+ // because `onAuthStateChange` triggers a route navigation that can
790
+ // interrupt/supersede a still-pending async write. Persisting first
791
+ // guarantees the durable record lands; that is exactly what was missing
792
+ // when `oxy_session_session_ids` came back empty after a successful FedCM
793
+ // sign-in (the user appeared logged in until reload, then had no session to
794
+ // restore). `persistSessionDurably` awaits the READY storage instance rather
795
+ // than reading the possibly-null `storage` state, so a sign-in fired the
796
+ // instant the screen mounts (before storage-init populates state) is not
797
+ // silently dropped.
798
+ await persistSessionDurably(session.sessionId);
799
+
800
+ // Fetch the full user profile now that we have a valid access token and the
801
+ // session is durably persisted. The session only carries MinimalUserData;
802
+ // the store and callbacks expect a full User. The navigation kicked off by
803
+ // `onAuthStateChange` now happens only after the durable write is committed.
522
804
  let fullUser: User;
523
805
  try {
524
806
  fullUser = await oxyServices.getCurrentUser();
@@ -529,19 +811,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
529
811
  }
530
812
  loginSuccess(fullUser);
531
813
  onAuthStateChange?.(fullUser);
532
-
533
- // Persist to storage
534
- if (storage) {
535
- await storage.setItem(storageKeys.activeSessionId, session.sessionId);
536
- const existingIds = await storage.getItem(storageKeys.sessionIds);
537
- let sessionIds: string[] = [];
538
- try { sessionIds = existingIds ? JSON.parse(existingIds) : []; } catch { /* corrupted storage */ }
539
- if (!sessionIds.includes(session.sessionId)) {
540
- sessionIds.push(session.sessionId);
541
- await storage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
542
- }
543
- }
544
- }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, storage, storageKeys]);
814
+ }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
545
815
 
546
816
  // Enable web SSO only after local storage check completes and no user found
547
817
  const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;