@oxyhq/core 9.2.0 → 9.2.2
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +13 -0
- package/dist/cjs/index.js +6 -4
- package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
- package/dist/cjs/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +45 -0
- package/dist/cjs/session/accountDialogController.js +31 -0
- package/dist/cjs/session/authStateStore.js +196 -16
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +13 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/mixins/OxyServices.accounts.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +45 -0
- package/dist/esm/session/accountDialogController.js +31 -0
- package/dist/esm/session/authStateStore.js +195 -15
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +3 -1
- package/dist/types/models/session.d.ts +6 -0
- package/dist/types/session/SessionClient.d.ts +11 -0
- package/dist/types/session/accountDialogController.d.ts +17 -0
- package/dist/types/session/authStateStore.d.ts +33 -8
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
- package/src/boot/sessionColdBoot.ts +13 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +9 -0
- package/src/mixins/OxyServices.auth.ts +2 -0
- package/src/mixins/OxyServices.utility.ts +10 -9
- package/src/models/interfaces.ts +3 -1
- package/src/models/session.ts +6 -0
- package/src/session/SessionClient.ts +44 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/accountDialogController.test.ts +93 -1
- package/src/session/__tests__/authStateStore.test.ts +170 -0
- package/src/session/accountDialogController.ts +54 -1
- package/src/session/authStateStore.ts +219 -15
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* ESM-safe (no `require()`).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { logger } from '../utils/loggerUtils';
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* The persisted session credential set for a single origin.
|
|
22
24
|
*
|
|
@@ -81,12 +83,34 @@ export interface NativeKeyValueStorage {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
|
-
* Versioned storage key.
|
|
85
|
-
*
|
|
86
|
-
* `
|
|
86
|
+
* Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
|
|
87
|
+
* (`sessionId`, `userId`, `deviceId`, `deviceSecret`) — never the large JWT
|
|
88
|
+
* `accessToken`. Keeping this blob small (<2KB) matters on Android
|
|
89
|
+
* `expo-secure-store`, whose backing store can silently fail to persist an
|
|
90
|
+
* oversize value; bundling the token here previously took the mint credential
|
|
91
|
+
* down with it on every write, losing the session on cold restart.
|
|
92
|
+
*
|
|
93
|
+
* The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
|
|
94
|
+
* stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
|
|
95
|
+
* in `KeyManager`, so it never collides.
|
|
96
|
+
*
|
|
97
|
+
* BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
|
|
98
|
+
* `expiresAt`) into this single key. `load()` still reads those token fields
|
|
99
|
+
* from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
|
|
100
|
+
* so upgrading users are not signed out; the next `save()` splits them apart.
|
|
87
101
|
*/
|
|
88
102
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
89
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
106
|
+
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
107
|
+
* failure (quota / oversize keychain value) is swallowed because the session is
|
|
108
|
+
* fully re-mintable from the durable `deviceSecret`. Kept separate from
|
|
109
|
+
* {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
|
|
110
|
+
* corrupt the durable credential write.
|
|
111
|
+
*/
|
|
112
|
+
export const AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
|
|
113
|
+
|
|
90
114
|
/**
|
|
91
115
|
* Parse + shape-validate a stored blob. Returns `null` for anything that is
|
|
92
116
|
* not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
|
|
@@ -142,6 +166,104 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
142
166
|
return state;
|
|
143
167
|
}
|
|
144
168
|
|
|
169
|
+
/** The parsed warm-token blob: the short-lived optimization fields only. */
|
|
170
|
+
interface WarmToken {
|
|
171
|
+
accessToken?: string;
|
|
172
|
+
expiresAt?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse the best-effort warm-token blob. Returns `null` for anything not a
|
|
177
|
+
* well-formed object so a corrupt warm entry simply forgoes the warm-boot
|
|
178
|
+
* optimization (the durable credential re-mints a fresh token).
|
|
179
|
+
*/
|
|
180
|
+
function parseWarmToken(raw: string | null): WarmToken | null {
|
|
181
|
+
if (!raw) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
let parsed: unknown;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(raw);
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const candidate = parsed as Record<string, unknown>;
|
|
194
|
+
const warm: WarmToken = {};
|
|
195
|
+
if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
|
|
196
|
+
warm.accessToken = candidate.accessToken;
|
|
197
|
+
}
|
|
198
|
+
if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
|
|
199
|
+
warm.expiresAt = candidate.expiresAt;
|
|
200
|
+
}
|
|
201
|
+
return warm;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Serialize ONLY the small, re-mint-critical fields for the durable key. */
|
|
205
|
+
function serializeDurable(state: PersistedAuthState): string {
|
|
206
|
+
const durable: Record<string, string> = {
|
|
207
|
+
sessionId: state.sessionId,
|
|
208
|
+
userId: state.userId,
|
|
209
|
+
};
|
|
210
|
+
if (state.deviceId) {
|
|
211
|
+
durable.deviceId = state.deviceId;
|
|
212
|
+
}
|
|
213
|
+
if (state.deviceSecret) {
|
|
214
|
+
durable.deviceSecret = state.deviceSecret;
|
|
215
|
+
}
|
|
216
|
+
return JSON.stringify(durable);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Serialize the warm-token blob, or `null` when there is no token to persist
|
|
221
|
+
* (so the caller clears the warm key rather than writing an empty object).
|
|
222
|
+
*/
|
|
223
|
+
function serializeWarmToken(state: PersistedAuthState): string | null {
|
|
224
|
+
const warm: WarmToken = {};
|
|
225
|
+
if (state.accessToken) {
|
|
226
|
+
warm.accessToken = state.accessToken;
|
|
227
|
+
}
|
|
228
|
+
if (state.expiresAt) {
|
|
229
|
+
warm.expiresAt = state.expiresAt;
|
|
230
|
+
}
|
|
231
|
+
if (!warm.accessToken && !warm.expiresAt) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return JSON.stringify(warm);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compose the unchanged {@link PersistedAuthState} return shape from the two
|
|
239
|
+
* on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
|
|
240
|
+
* present; when the warm key is ABSENT the token fields fall back to whatever
|
|
241
|
+
* the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
|
|
242
|
+
*/
|
|
243
|
+
function composeState(durableRaw: string | null, warmRaw: string | null): PersistedAuthState | null {
|
|
244
|
+
const state = deserialize(durableRaw);
|
|
245
|
+
if (!state) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (warmRaw !== null) {
|
|
249
|
+
// New split layout: the warm key is authoritative for the token fields.
|
|
250
|
+
// Drop anything the durable blob may have carried, then overlay the warm
|
|
251
|
+
// values (a warm key with no token → the session simply has no warm token).
|
|
252
|
+
delete state.accessToken;
|
|
253
|
+
delete state.expiresAt;
|
|
254
|
+
const warm = parseWarmToken(warmRaw);
|
|
255
|
+
if (warm?.accessToken) {
|
|
256
|
+
state.accessToken = warm.accessToken;
|
|
257
|
+
}
|
|
258
|
+
if (warm?.expiresAt) {
|
|
259
|
+
state.expiresAt = warm.expiresAt;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
|
|
263
|
+
// any `accessToken` / `expiresAt` from the old combined blob.
|
|
264
|
+
return state;
|
|
265
|
+
}
|
|
266
|
+
|
|
145
267
|
/**
|
|
146
268
|
* A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
|
|
147
269
|
* tests/SSR and as the degraded fallback of the web store when `localStorage`
|
|
@@ -180,8 +302,9 @@ function safeGetLocalStorage(): Storage | null {
|
|
|
180
302
|
}
|
|
181
303
|
|
|
182
304
|
/**
|
|
183
|
-
* A `localStorage`-backed {@link AuthStateStore}
|
|
184
|
-
* {@link AUTH_STATE_STORAGE_KEY}
|
|
305
|
+
* A `localStorage`-backed {@link AuthStateStore} split across the durable
|
|
306
|
+
* {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
|
|
307
|
+
* {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
|
|
185
308
|
*
|
|
186
309
|
* Resilience:
|
|
187
310
|
* - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
|
|
@@ -208,18 +331,50 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
208
331
|
return sessionMirror;
|
|
209
332
|
}
|
|
210
333
|
try {
|
|
211
|
-
return
|
|
334
|
+
return composeState(
|
|
335
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY),
|
|
336
|
+
storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
337
|
+
);
|
|
212
338
|
} catch {
|
|
213
339
|
return null;
|
|
214
340
|
}
|
|
215
341
|
},
|
|
216
342
|
save: async (state) => {
|
|
217
343
|
sessionMirror = state; // mirror FIRST — authoritative even if persist fails
|
|
344
|
+
// Durable credential FIRST, then VERIFY it landed. The in-memory mirror
|
|
345
|
+
// keeps the session live for this page, but a failed DURABLE write means
|
|
346
|
+
// the mint credential will NOT survive a reload — surface it, never swallow.
|
|
347
|
+
let durablePersisted = false;
|
|
218
348
|
try {
|
|
219
|
-
|
|
349
|
+
const durableJson = serializeDurable(state);
|
|
350
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
|
|
351
|
+
durablePersisted = storage.getItem(AUTH_STATE_STORAGE_KEY) === durableJson;
|
|
352
|
+
if (!durablePersisted) {
|
|
353
|
+
logger.error(
|
|
354
|
+
'[authStateStore] durable credential read-back mismatch after save — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload',
|
|
355
|
+
undefined,
|
|
356
|
+
{ component: 'authStateStore' },
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
logger.error(
|
|
361
|
+
'[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload',
|
|
362
|
+
error,
|
|
363
|
+
{ component: 'authStateStore' },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
367
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
368
|
+
// corrupt the durable write above.
|
|
369
|
+
try {
|
|
370
|
+
const warmJson = serializeWarmToken(state);
|
|
371
|
+
if (warmJson) {
|
|
372
|
+
storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
373
|
+
} else {
|
|
374
|
+
storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
375
|
+
}
|
|
220
376
|
} catch {
|
|
221
|
-
// Quota / private-mode / disabled storage — non-fatal
|
|
222
|
-
// stays live via the in-memory mirror; only reload durability is lost.
|
|
377
|
+
// Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
|
|
223
378
|
}
|
|
224
379
|
},
|
|
225
380
|
clear: async () => {
|
|
@@ -229,6 +384,11 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
229
384
|
} catch {
|
|
230
385
|
// Non-fatal — see save().
|
|
231
386
|
}
|
|
387
|
+
try {
|
|
388
|
+
storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
389
|
+
} catch {
|
|
390
|
+
// Non-fatal — see save().
|
|
391
|
+
}
|
|
232
392
|
},
|
|
233
393
|
};
|
|
234
394
|
}
|
|
@@ -237,9 +397,12 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
237
397
|
* A native {@link AuthStateStore} over an injected async key/value store.
|
|
238
398
|
*
|
|
239
399
|
* `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
|
|
240
|
-
* the SecureStore-backed adapter and passes it here.
|
|
241
|
-
*
|
|
242
|
-
*
|
|
400
|
+
* the SecureStore-backed adapter and passes it here. Persistence is split across
|
|
401
|
+
* the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
|
|
402
|
+
* best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
|
|
403
|
+
* durable write is read-back-verified and its failure surfaced (not swallowed),
|
|
404
|
+
* while the warm-token write and all reads degrade gracefully exactly like the
|
|
405
|
+
* web store.
|
|
243
406
|
*/
|
|
244
407
|
export function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore {
|
|
245
408
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
@@ -251,17 +414,53 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
251
414
|
return sessionMirror;
|
|
252
415
|
}
|
|
253
416
|
try {
|
|
254
|
-
|
|
417
|
+
const [durableRaw, warmRaw] = await Promise.all([
|
|
418
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY),
|
|
419
|
+
storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
420
|
+
]);
|
|
421
|
+
return composeState(durableRaw, warmRaw);
|
|
255
422
|
} catch {
|
|
256
423
|
return null;
|
|
257
424
|
}
|
|
258
425
|
},
|
|
259
426
|
save: async (state) => {
|
|
260
427
|
sessionMirror = state;
|
|
428
|
+
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
429
|
+
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
430
|
+
// only reliable proof. The mirror keeps the session live for this app run,
|
|
431
|
+
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
432
|
+
// cold restart — surface it, never swallow.
|
|
433
|
+
let durablePersisted = false;
|
|
434
|
+
try {
|
|
435
|
+
const durableJson = serializeDurable(state);
|
|
436
|
+
await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
|
|
437
|
+
durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
|
|
438
|
+
if (!durablePersisted) {
|
|
439
|
+
logger.error(
|
|
440
|
+
'[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart',
|
|
441
|
+
undefined,
|
|
442
|
+
{ component: 'authStateStore' },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
logger.error(
|
|
447
|
+
'[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart',
|
|
448
|
+
error,
|
|
449
|
+
{ component: 'authStateStore' },
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
453
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
454
|
+
// corrupt the durable write above.
|
|
261
455
|
try {
|
|
262
|
-
|
|
456
|
+
const warmJson = serializeWarmToken(state);
|
|
457
|
+
if (warmJson) {
|
|
458
|
+
await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
459
|
+
} else {
|
|
460
|
+
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
461
|
+
}
|
|
263
462
|
} catch {
|
|
264
|
-
//
|
|
463
|
+
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
265
464
|
}
|
|
266
465
|
},
|
|
267
466
|
clear: async () => {
|
|
@@ -271,6 +470,11 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
271
470
|
} catch {
|
|
272
471
|
// Non-fatal.
|
|
273
472
|
}
|
|
473
|
+
try {
|
|
474
|
+
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
475
|
+
} catch {
|
|
476
|
+
// Non-fatal.
|
|
477
|
+
}
|
|
274
478
|
},
|
|
275
479
|
};
|
|
276
480
|
}
|