@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.
- package/lib/commonjs/ui/context/OxyContext.js +283 -21
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +283 -21
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSecurityQueries.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSecurityQueries.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useSecurityQueries.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/queries/useSecurityQueries.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/ui/context/OxyContext.tsx +288 -18
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
521
|
-
//
|
|
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;
|