@oxyhq/core 9.2.1 → 9.2.3
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/HttpService.js +27 -0
- package/dist/cjs/boot/sessionColdBoot.js +83 -53
- package/dist/cjs/crypto/keyManager.js +45 -12
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +54 -4
- package/dist/cjs/session/accountDialogController.js +30 -0
- package/dist/cjs/session/authStateStore.js +204 -16
- package/dist/cjs/session/refresh.js +110 -37
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +27 -0
- package/dist/esm/boot/sessionColdBoot.js +83 -53
- package/dist/esm/crypto/keyManager.js +46 -13
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +54 -4
- package/dist/esm/session/accountDialogController.js +30 -0
- package/dist/esm/session/authStateStore.js +203 -15
- package/dist/esm/session/refresh.js +109 -37
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/boot/sessionColdBoot.d.ts +7 -3
- package/dist/types/index.d.ts +3 -3
- package/dist/types/session/SessionClient.d.ts +37 -4
- package/dist/types/session/accountDialogController.d.ts +17 -0
- package/dist/types/session/authStateStore.d.ts +48 -9
- package/dist/types/session/refresh.d.ts +67 -31
- package/package.json +2 -2
- package/src/HttpService.ts +31 -0
- package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
- package/src/boot/sessionColdBoot.ts +93 -74
- package/src/crypto/keyManager.ts +42 -15
- package/src/index.ts +3 -2
- package/src/mixins/OxyServices.utility.ts +10 -9
- package/src/session/SessionClient.ts +79 -7
- package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
- package/src/session/__tests__/accountDialogController.test.ts +85 -0
- package/src/session/__tests__/authStateStore.test.ts +232 -4
- package/src/session/__tests__/refresh.test.ts +141 -2
- package/src/session/accountDialogController.ts +45 -0
- package/src/session/authStateStore.ts +242 -16
- package/src/session/refresh.ts +146 -40
|
@@ -16,12 +16,34 @@
|
|
|
16
16
|
*
|
|
17
17
|
* ESM-safe (no `require()`).
|
|
18
18
|
*/
|
|
19
|
+
import { logger } from '../utils/loggerUtils.js';
|
|
19
20
|
/**
|
|
20
|
-
* Versioned storage key.
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
21
|
+
* Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
|
|
22
|
+
* (`sessionId`, `userId`, `deviceId`, `deviceSecret`) — never the large JWT
|
|
23
|
+
* `accessToken`. Keeping this blob small (<2KB) matters on Android
|
|
24
|
+
* `expo-secure-store`, whose backing store can silently fail to persist an
|
|
25
|
+
* oversize value; bundling the token here previously took the mint credential
|
|
26
|
+
* down with it on every write, losing the session on cold restart.
|
|
27
|
+
*
|
|
28
|
+
* The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
|
|
29
|
+
* stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
|
|
30
|
+
* in `KeyManager`, so it never collides.
|
|
31
|
+
*
|
|
32
|
+
* BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
|
|
33
|
+
* `expiresAt`) into this single key. `load()` still reads those token fields
|
|
34
|
+
* from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
|
|
35
|
+
* so upgrading users are not signed out; the next `save()` splits them apart.
|
|
23
36
|
*/
|
|
24
37
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
38
|
+
/**
|
|
39
|
+
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
40
|
+
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
41
|
+
* failure (quota / oversize keychain value) is swallowed because the session is
|
|
42
|
+
* fully re-mintable from the durable `deviceSecret`. Kept separate from
|
|
43
|
+
* {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
|
|
44
|
+
* corrupt the durable credential write.
|
|
45
|
+
*/
|
|
46
|
+
export const AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
|
|
25
47
|
/**
|
|
26
48
|
* Parse + shape-validate a stored blob. Returns `null` for anything that is
|
|
27
49
|
* not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
|
|
@@ -72,6 +94,95 @@ function deserialize(raw) {
|
|
|
72
94
|
}
|
|
73
95
|
return state;
|
|
74
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse the best-effort warm-token blob. Returns `null` for anything not a
|
|
99
|
+
* well-formed object so a corrupt warm entry simply forgoes the warm-boot
|
|
100
|
+
* optimization (the durable credential re-mints a fresh token).
|
|
101
|
+
*/
|
|
102
|
+
function parseWarmToken(raw) {
|
|
103
|
+
if (!raw) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(raw);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const candidate = parsed;
|
|
117
|
+
const warm = {};
|
|
118
|
+
if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
|
|
119
|
+
warm.accessToken = candidate.accessToken;
|
|
120
|
+
}
|
|
121
|
+
if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
|
|
122
|
+
warm.expiresAt = candidate.expiresAt;
|
|
123
|
+
}
|
|
124
|
+
return warm;
|
|
125
|
+
}
|
|
126
|
+
/** Serialize ONLY the small, re-mint-critical fields for the durable key. */
|
|
127
|
+
function serializeDurable(state) {
|
|
128
|
+
const durable = {
|
|
129
|
+
sessionId: state.sessionId,
|
|
130
|
+
userId: state.userId,
|
|
131
|
+
};
|
|
132
|
+
if (state.deviceId) {
|
|
133
|
+
durable.deviceId = state.deviceId;
|
|
134
|
+
}
|
|
135
|
+
if (state.deviceSecret) {
|
|
136
|
+
durable.deviceSecret = state.deviceSecret;
|
|
137
|
+
}
|
|
138
|
+
return JSON.stringify(durable);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Serialize the warm-token blob, or `null` when there is no token to persist
|
|
142
|
+
* (so the caller clears the warm key rather than writing an empty object).
|
|
143
|
+
*/
|
|
144
|
+
function serializeWarmToken(state) {
|
|
145
|
+
const warm = {};
|
|
146
|
+
if (state.accessToken) {
|
|
147
|
+
warm.accessToken = state.accessToken;
|
|
148
|
+
}
|
|
149
|
+
if (state.expiresAt) {
|
|
150
|
+
warm.expiresAt = state.expiresAt;
|
|
151
|
+
}
|
|
152
|
+
if (!warm.accessToken && !warm.expiresAt) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return JSON.stringify(warm);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Compose the unchanged {@link PersistedAuthState} return shape from the two
|
|
159
|
+
* on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
|
|
160
|
+
* present; when the warm key is ABSENT the token fields fall back to whatever
|
|
161
|
+
* the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
|
|
162
|
+
*/
|
|
163
|
+
function composeState(durableRaw, warmRaw) {
|
|
164
|
+
const state = deserialize(durableRaw);
|
|
165
|
+
if (!state) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (warmRaw !== null) {
|
|
169
|
+
// New split layout: the warm key is authoritative for the token fields.
|
|
170
|
+
// Drop anything the durable blob may have carried, then overlay the warm
|
|
171
|
+
// values (a warm key with no token → the session simply has no warm token).
|
|
172
|
+
delete state.accessToken;
|
|
173
|
+
delete state.expiresAt;
|
|
174
|
+
const warm = parseWarmToken(warmRaw);
|
|
175
|
+
if (warm?.accessToken) {
|
|
176
|
+
state.accessToken = warm.accessToken;
|
|
177
|
+
}
|
|
178
|
+
if (warm?.expiresAt) {
|
|
179
|
+
state.expiresAt = warm.expiresAt;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
|
|
183
|
+
// any `accessToken` / `expiresAt` from the old combined blob.
|
|
184
|
+
return state;
|
|
185
|
+
}
|
|
75
186
|
/**
|
|
76
187
|
* A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
|
|
77
188
|
* tests/SSR and as the degraded fallback of the web store when `localStorage`
|
|
@@ -83,7 +194,9 @@ export function createMemoryAuthStateStore() {
|
|
|
83
194
|
return {
|
|
84
195
|
load: async () => current,
|
|
85
196
|
save: async (state) => {
|
|
197
|
+
// Memory IS this store's durability backing — the write always lands.
|
|
86
198
|
current = state;
|
|
199
|
+
return true;
|
|
87
200
|
},
|
|
88
201
|
clear: async () => {
|
|
89
202
|
current = null;
|
|
@@ -109,8 +222,9 @@ function safeGetLocalStorage() {
|
|
|
109
222
|
}
|
|
110
223
|
}
|
|
111
224
|
/**
|
|
112
|
-
* A `localStorage`-backed {@link AuthStateStore}
|
|
113
|
-
* {@link AUTH_STATE_STORAGE_KEY}
|
|
225
|
+
* A `localStorage`-backed {@link AuthStateStore} split across the durable
|
|
226
|
+
* {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
|
|
227
|
+
* {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
|
|
114
228
|
*
|
|
115
229
|
* Resilience:
|
|
116
230
|
* - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
|
|
@@ -137,7 +251,7 @@ export function createWebAuthStateStore() {
|
|
|
137
251
|
return sessionMirror;
|
|
138
252
|
}
|
|
139
253
|
try {
|
|
140
|
-
return
|
|
254
|
+
return composeState(storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY));
|
|
141
255
|
}
|
|
142
256
|
catch {
|
|
143
257
|
return null;
|
|
@@ -145,13 +259,39 @@ export function createWebAuthStateStore() {
|
|
|
145
259
|
},
|
|
146
260
|
save: async (state) => {
|
|
147
261
|
sessionMirror = state; // mirror FIRST — authoritative even if persist fails
|
|
262
|
+
// Durable credential FIRST, then VERIFY it landed. The in-memory mirror
|
|
263
|
+
// keeps the session live for this page, but a failed DURABLE write means
|
|
264
|
+
// the mint credential will NOT survive a reload — surface it, never swallow.
|
|
265
|
+
let durablePersisted = false;
|
|
266
|
+
try {
|
|
267
|
+
const durableJson = serializeDurable(state);
|
|
268
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
|
|
269
|
+
durablePersisted = storage.getItem(AUTH_STATE_STORAGE_KEY) === durableJson;
|
|
270
|
+
if (!durablePersisted) {
|
|
271
|
+
logger.error('[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', undefined, { component: 'authStateStore' });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
logger.error('[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', error, { component: 'authStateStore' });
|
|
276
|
+
}
|
|
277
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
278
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
279
|
+
// corrupt the durable write above.
|
|
148
280
|
try {
|
|
149
|
-
|
|
281
|
+
const warmJson = serializeWarmToken(state);
|
|
282
|
+
if (warmJson) {
|
|
283
|
+
storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
287
|
+
}
|
|
150
288
|
}
|
|
151
289
|
catch {
|
|
152
|
-
// Quota / private-mode / disabled storage — non-fatal
|
|
153
|
-
// stays live via the in-memory mirror; only reload durability is lost.
|
|
290
|
+
// Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
|
|
154
291
|
}
|
|
292
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
293
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
294
|
+
return durablePersisted;
|
|
155
295
|
},
|
|
156
296
|
clear: async () => {
|
|
157
297
|
sessionMirror = null;
|
|
@@ -161,6 +301,12 @@ export function createWebAuthStateStore() {
|
|
|
161
301
|
catch {
|
|
162
302
|
// Non-fatal — see save().
|
|
163
303
|
}
|
|
304
|
+
try {
|
|
305
|
+
storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Non-fatal — see save().
|
|
309
|
+
}
|
|
164
310
|
},
|
|
165
311
|
};
|
|
166
312
|
}
|
|
@@ -168,9 +314,12 @@ export function createWebAuthStateStore() {
|
|
|
168
314
|
* A native {@link AuthStateStore} over an injected async key/value store.
|
|
169
315
|
*
|
|
170
316
|
* `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
|
|
171
|
-
* the SecureStore-backed adapter and passes it here.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
317
|
+
* the SecureStore-backed adapter and passes it here. Persistence is split across
|
|
318
|
+
* the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
|
|
319
|
+
* best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
|
|
320
|
+
* durable write is read-back-verified and its failure surfaced (not swallowed),
|
|
321
|
+
* while the warm-token write and all reads degrade gracefully exactly like the
|
|
322
|
+
* web store.
|
|
174
323
|
*/
|
|
175
324
|
export function createNativeAuthStateStore(storage) {
|
|
176
325
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
@@ -182,7 +331,11 @@ export function createNativeAuthStateStore(storage) {
|
|
|
182
331
|
return sessionMirror;
|
|
183
332
|
}
|
|
184
333
|
try {
|
|
185
|
-
|
|
334
|
+
const [durableRaw, warmRaw] = await Promise.all([
|
|
335
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY),
|
|
336
|
+
storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
337
|
+
]);
|
|
338
|
+
return composeState(durableRaw, warmRaw);
|
|
186
339
|
}
|
|
187
340
|
catch {
|
|
188
341
|
return null;
|
|
@@ -190,12 +343,41 @@ export function createNativeAuthStateStore(storage) {
|
|
|
190
343
|
},
|
|
191
344
|
save: async (state) => {
|
|
192
345
|
sessionMirror = state;
|
|
346
|
+
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
347
|
+
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
348
|
+
// only reliable proof. The mirror keeps the session live for this app run,
|
|
349
|
+
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
350
|
+
// cold restart — surface it, never swallow.
|
|
351
|
+
let durablePersisted = false;
|
|
193
352
|
try {
|
|
194
|
-
|
|
353
|
+
const durableJson = serializeDurable(state);
|
|
354
|
+
await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
|
|
355
|
+
durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
|
|
356
|
+
if (!durablePersisted) {
|
|
357
|
+
logger.error('[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', undefined, { component: 'authStateStore' });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
logger.error('[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', error, { component: 'authStateStore' });
|
|
362
|
+
}
|
|
363
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
364
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
365
|
+
// corrupt the durable write above.
|
|
366
|
+
try {
|
|
367
|
+
const warmJson = serializeWarmToken(state);
|
|
368
|
+
if (warmJson) {
|
|
369
|
+
await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
373
|
+
}
|
|
195
374
|
}
|
|
196
375
|
catch {
|
|
197
|
-
//
|
|
376
|
+
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
198
377
|
}
|
|
378
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
379
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
380
|
+
return durablePersisted;
|
|
199
381
|
},
|
|
200
382
|
clear: async () => {
|
|
201
383
|
sessionMirror = null;
|
|
@@ -205,6 +387,12 @@ export function createNativeAuthStateStore(storage) {
|
|
|
205
387
|
catch {
|
|
206
388
|
// Non-fatal.
|
|
207
389
|
}
|
|
390
|
+
try {
|
|
391
|
+
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Non-fatal.
|
|
395
|
+
}
|
|
208
396
|
},
|
|
209
397
|
};
|
|
210
398
|
}
|
|
@@ -30,64 +30,136 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
|
|
|
30
30
|
*/
|
|
31
31
|
const MIN_FAILURE_BACKOFF_MS = 5000;
|
|
32
32
|
const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
|
|
33
|
+
/**
|
|
34
|
+
* Arm 1 — the rotating device-secret mint, run under the owning client's
|
|
35
|
+
* PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
36
|
+
*
|
|
37
|
+
* The server ROTATES the presented `deviceSecret` on every successful mint and
|
|
38
|
+
* the just-presented secret is valid only for a short grace window. If two lanes
|
|
39
|
+
* (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
|
|
40
|
+
* the socket token transport, or a tab-focus reconcile) minted concurrently they
|
|
41
|
+
* would double-rotate the server and the durable store could converge on the
|
|
42
|
+
* SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
|
|
43
|
+
* user is signed out. Routing EVERY lane through this one single-flight makes
|
|
44
|
+
* concurrent callers await the SAME in-flight mint and all receive its result, so
|
|
45
|
+
* there is exactly one rotation and the store always converges on the true
|
|
46
|
+
* `current` secret.
|
|
47
|
+
*
|
|
48
|
+
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
49
|
+
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
50
|
+
* planting. This function performs NO store mutation on failure — the caller
|
|
51
|
+
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
52
|
+
* status.
|
|
53
|
+
*/
|
|
54
|
+
export async function refreshDeviceSecretArm(deps) {
|
|
55
|
+
const { oxy, store } = deps;
|
|
56
|
+
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
57
|
+
const persisted = await store.load();
|
|
58
|
+
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
59
|
+
return { status: 'no-secret' };
|
|
60
|
+
}
|
|
61
|
+
let mint;
|
|
62
|
+
try {
|
|
63
|
+
mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (extractErrorStatus(error) === 401) {
|
|
67
|
+
// Structural read (not `instanceof Error`): the thrown value can be a
|
|
68
|
+
// plain ApiError-shaped object or come from another realm.
|
|
69
|
+
const message = error?.message;
|
|
70
|
+
return typeof message === 'string' && message.includes('no_active_session')
|
|
71
|
+
? { status: 'no-session' }
|
|
72
|
+
: { status: 'invalid-secret' };
|
|
73
|
+
}
|
|
74
|
+
return { status: 'transient' };
|
|
75
|
+
}
|
|
76
|
+
const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
|
|
77
|
+
const next = {
|
|
78
|
+
...persisted,
|
|
79
|
+
deviceId: mint.state.deviceId,
|
|
80
|
+
deviceSecret: mint.nextDeviceSecret,
|
|
81
|
+
accessToken: mint.accessToken,
|
|
82
|
+
expiresAt: mint.expiresAt,
|
|
83
|
+
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
84
|
+
};
|
|
85
|
+
// Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
|
|
86
|
+
// landed BEFORE planting the token. A failed durable persist must NOT plant.
|
|
87
|
+
const persistedOk = await store.save(next);
|
|
88
|
+
if (!persistedOk) {
|
|
89
|
+
return { status: 'persist-failed' };
|
|
90
|
+
}
|
|
91
|
+
oxy.setTokens(mint.accessToken);
|
|
92
|
+
return { status: 'ok', token: mint.accessToken, sessionId: next.sessionId, userId: next.userId };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
33
95
|
/**
|
|
34
96
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
35
97
|
* when no arm could produce one.
|
|
36
98
|
*
|
|
37
|
-
* Arm 1 (`POST /session/device/token
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
99
|
+
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
100
|
+
* from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
|
|
101
|
+
* or the device has no live session: drop the secret so the mint lane stops (or
|
|
102
|
+
* clear the store on web, where there is no fallback), then fall to arm 2 on
|
|
103
|
+
* native. A transient error — or a durable-persist failure — leaves the store and
|
|
104
|
+
* returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
|
|
43
105
|
*
|
|
44
106
|
* Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
|
|
45
|
-
* re-mint via `signInWithSharedIdentity` (which plants tokens).
|
|
46
|
-
*
|
|
47
|
-
*
|
|
107
|
+
* re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
|
|
108
|
+
* recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
|
|
109
|
+
* lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
|
|
110
|
+
* in-session shared-key recovery must not leave the fast-lane credential empty.
|
|
48
111
|
*/
|
|
49
112
|
export async function refreshPersistedSession(deps) {
|
|
50
113
|
const { oxy, store } = deps;
|
|
51
114
|
const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
115
|
+
const arm1 = await refreshDeviceSecretArm({ oxy, store });
|
|
116
|
+
switch (arm1.status) {
|
|
117
|
+
case 'ok':
|
|
118
|
+
return arm1.token;
|
|
119
|
+
case 'transient':
|
|
120
|
+
logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
|
|
121
|
+
return null;
|
|
122
|
+
case 'persist-failed':
|
|
123
|
+
// The server rotated the secret but it did not durably persist. Do NOT fall
|
|
124
|
+
// to shared-key and do NOT plant — a later attempt re-mints (the process
|
|
125
|
+
// mirror still holds the rotated secret the server accepts) and can persist
|
|
126
|
+
// once storage recovers. Never advertise a session on an unsaved secret.
|
|
127
|
+
logger.error('Device-secret mint rotated the secret but it could not be durably persisted — refusing to plant (a later attempt re-mints)', undefined, { component: 'refresh', method: 'refreshPersistedSession' });
|
|
128
|
+
return null;
|
|
129
|
+
case 'invalid-secret':
|
|
130
|
+
case 'no-session': {
|
|
131
|
+
// 401: secret diverged or no live session. On a shared-key device drop only
|
|
132
|
+
// the secret (keep the identity so arm 2 can recover); otherwise (web) the
|
|
133
|
+
// session is over — clear the store.
|
|
134
|
+
const persisted = await store.load();
|
|
135
|
+
if (allowSharedKeyFallback) {
|
|
136
|
+
if (persisted) {
|
|
74
137
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
75
138
|
}
|
|
76
|
-
else {
|
|
77
|
-
await store.clear();
|
|
78
|
-
}
|
|
79
|
-
// Fall through to the native shared-key arm.
|
|
80
139
|
}
|
|
81
140
|
else {
|
|
82
|
-
|
|
83
|
-
return null;
|
|
141
|
+
await store.clear();
|
|
84
142
|
}
|
|
143
|
+
break;
|
|
85
144
|
}
|
|
145
|
+
case 'no-secret':
|
|
146
|
+
break;
|
|
86
147
|
}
|
|
87
148
|
if (allowSharedKeyFallback) {
|
|
88
149
|
try {
|
|
89
150
|
const session = await oxy.signInWithSharedIdentity();
|
|
90
151
|
if (session?.accessToken) {
|
|
152
|
+
// Repopulate the fast device-secret lane from the shared-key re-mint.
|
|
153
|
+
if (session.deviceId && session.deviceSecret) {
|
|
154
|
+
await store.save({
|
|
155
|
+
sessionId: session.sessionId,
|
|
156
|
+
userId: session.user.id,
|
|
157
|
+
deviceId: session.deviceId,
|
|
158
|
+
deviceSecret: session.deviceSecret,
|
|
159
|
+
accessToken: session.accessToken,
|
|
160
|
+
expiresAt: session.expiresAt,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
91
163
|
return session.accessToken;
|
|
92
164
|
}
|
|
93
165
|
}
|