@oxyhq/core 9.2.2 → 9.2.4
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/session/SessionClient.js +9 -4
- package/dist/cjs/session/authStateStore.js +8 -0
- 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/session/SessionClient.js +9 -4
- package/dist/esm/session/authStateStore.js +8 -0
- 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 +26 -4
- package/dist/types/session/authStateStore.d.ts +15 -1
- 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/session/SessionClient.ts +35 -7
- package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
- package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
- package/src/session/__tests__/authStateStore.test.ts +64 -6
- package/src/session/__tests__/refresh.test.ts +141 -2
- package/src/session/authStateStore.ts +23 -1
- package/src/session/refresh.ts +146 -40
package/dist/esm/HttpService.js
CHANGED
|
@@ -132,6 +132,7 @@ export class HttpService {
|
|
|
132
132
|
this.tokenRefreshCooldownUntil = 0;
|
|
133
133
|
this.authRefreshHandler = null;
|
|
134
134
|
this.accessTokenProvider = null;
|
|
135
|
+
this.deviceSecretMintInFlight = null;
|
|
135
136
|
/**
|
|
136
137
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
137
138
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -834,6 +835,32 @@ export class HttpService {
|
|
|
834
835
|
}
|
|
835
836
|
return this.tokenRefreshPromise;
|
|
836
837
|
}
|
|
838
|
+
/**
|
|
839
|
+
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
840
|
+
* (`POST /session/device/token`).
|
|
841
|
+
*
|
|
842
|
+
* The server rotates the presented `deviceSecret` on every successful mint, so
|
|
843
|
+
* two concurrent mints would double-rotate and the durable store could end up
|
|
844
|
+
* holding a superseded secret → a later cold-boot mint 401s → the user is
|
|
845
|
+
* signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
|
|
846
|
+
* the device-first cold boot, the socket token transport, the tab-focus
|
|
847
|
+
* reconcile) funnels its `refreshDeviceSecretArm` call through here, so
|
|
848
|
+
* concurrent callers await the SAME in-flight mint and all receive its result —
|
|
849
|
+
* exactly one server rotation.
|
|
850
|
+
*
|
|
851
|
+
* Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
|
|
852
|
+
* handler incl. the native shared-key arm + the failure cooldown): this inner
|
|
853
|
+
* guard serializes the rotation itself across BOTH the handler and the
|
|
854
|
+
* handler-independent cold boot, which never runs through `refreshAccessToken`.
|
|
855
|
+
*/
|
|
856
|
+
runSingleFlightDeviceSecretMint(mint) {
|
|
857
|
+
if (!this.deviceSecretMintInFlight) {
|
|
858
|
+
this.deviceSecretMintInFlight = mint().finally(() => {
|
|
859
|
+
this.deviceSecretMintInFlight = null;
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
return this.deviceSecretMintInFlight;
|
|
863
|
+
}
|
|
837
864
|
/**
|
|
838
865
|
* Unwrap standardized API response format
|
|
839
866
|
*/
|
|
@@ -7,31 +7,23 @@
|
|
|
7
7
|
* the app renders with a "Sign in with Oxy" button.
|
|
8
8
|
*
|
|
9
9
|
* Ordered steps (first to yield a session wins):
|
|
10
|
-
* 1. `
|
|
10
|
+
* 1. `warm-token-plant` (web + native) — the fastest path: when the persisted
|
|
11
|
+
* store still holds a warm access token that is valid for more than the
|
|
12
|
+
* refresh lead window, plant it and yield the session with NO network
|
|
13
|
+
* round-trip. The background scheduler rotates it shortly after.
|
|
14
|
+
* 2. `device-secret-mint` (web + native) — the zero-cookie transport: when the
|
|
11
15
|
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
12
16
|
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
13
17
|
* navigation) and rotate the secret in-use.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
19
|
+
* 4. Signed out.
|
|
16
20
|
*
|
|
17
21
|
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
18
22
|
*/
|
|
19
23
|
import { runColdBoot } from '../utils/coldBoot.js';
|
|
20
24
|
import { isNative as detectNative } from '../utils/platform.js';
|
|
21
|
-
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
22
25
|
import { logger } from '../utils/loggerUtils.js';
|
|
23
|
-
|
|
24
|
-
if (extractErrorStatus(error) === 401) {
|
|
25
|
-
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
26
|
-
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
27
|
-
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
28
|
-
const message = error?.message;
|
|
29
|
-
return typeof message === 'string' && message.includes('no_active_session')
|
|
30
|
-
? 'no_active_session'
|
|
31
|
-
: 'invalid_secret';
|
|
32
|
-
}
|
|
33
|
-
return 'transient';
|
|
34
|
-
}
|
|
26
|
+
import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh.js';
|
|
35
27
|
/**
|
|
36
28
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
37
29
|
* a side effect, invokes `onSession` (winning session, token already planted) or
|
|
@@ -44,60 +36,98 @@ export async function runSessionColdBoot(opts) {
|
|
|
44
36
|
// bundler re-evaluation.
|
|
45
37
|
let signedOutReason = 'no_session';
|
|
46
38
|
const steps = [];
|
|
47
|
-
// 1.
|
|
48
|
-
//
|
|
49
|
-
//
|
|
39
|
+
// 1. warm-token-plant (web + native) — the fastest path. When the persisted
|
|
40
|
+
// store already holds a still-valid warm access token (its expiry more than
|
|
41
|
+
// the refresh lead window away) plus its owning session identity, plant it
|
|
42
|
+
// and yield the session IMMEDIATELY, skipping the blocking mint round-trip on
|
|
43
|
+
// first paint. The token is used AS-IS: this step NEVER mints, rotates, or
|
|
44
|
+
// persists anything. The proactive `startTokenRefreshScheduler` + the
|
|
45
|
+
// request-time preflight (both wired in the services provider) rotate it in
|
|
46
|
+
// the background; a revoked token self-heals via the 401 -> re-mint -> clear
|
|
47
|
+
// path. This exposure is sanctioned by `authStateStore.ts` (~L30-36): the
|
|
48
|
+
// warm token is short-lived and adds nothing over the already-persisted
|
|
49
|
+
// `deviceSecret`.
|
|
50
50
|
steps.push({
|
|
51
|
-
id: '
|
|
51
|
+
id: 'warm-token-plant',
|
|
52
52
|
run: async () => {
|
|
53
53
|
const persisted = await store.load();
|
|
54
|
-
if (!persisted?.
|
|
54
|
+
if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
|
|
55
55
|
return { kind: 'skip' };
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const next = {
|
|
65
|
-
...persisted,
|
|
66
|
-
deviceId: mint.state.deviceId,
|
|
67
|
-
deviceSecret: mint.nextDeviceSecret,
|
|
68
|
-
accessToken: mint.accessToken,
|
|
69
|
-
expiresAt: mint.expiresAt,
|
|
70
|
-
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
71
|
-
};
|
|
72
|
-
await store.save(next);
|
|
73
|
-
oxy.setTokens(mint.accessToken);
|
|
74
|
-
return {
|
|
75
|
-
kind: 'session',
|
|
76
|
-
session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
|
|
77
|
-
};
|
|
57
|
+
// Guard a malformed `expiresAt` (Date.parse -> NaN): treat as not-valid and
|
|
58
|
+
// fall through to the mint lane. A token still inside the refresh lead
|
|
59
|
+
// window (or already expired) is likewise skipped — let the mint lane get a
|
|
60
|
+
// fresh one rather than plant a token about to expire.
|
|
61
|
+
const expiresAtMs = new Date(persisted.expiresAt).getTime();
|
|
62
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + TOKEN_REFRESH_LEAD_MS) {
|
|
63
|
+
return { kind: 'skip' };
|
|
78
64
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
65
|
+
oxy.setTokens(persisted.accessToken);
|
|
66
|
+
return {
|
|
67
|
+
kind: 'session',
|
|
68
|
+
session: {
|
|
69
|
+
sessionId: persisted.sessionId,
|
|
70
|
+
userId: persisted.userId,
|
|
71
|
+
accessToken: persisted.accessToken,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
// 2. device-secret-mint (web + native) — the zero-cookie fast path. When the
|
|
77
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
78
|
+
// a single bearer-less POST (no cookie, no navigation). The mint itself runs
|
|
79
|
+
// through `refreshDeviceSecretArm`, which acquires the client's PROCESS-WIDE
|
|
80
|
+
// single-flight, persists the rotated `nextDeviceSecret` BEFORE planting the
|
|
81
|
+
// token, and returns a classified outcome — so this step can never
|
|
82
|
+
// double-rotate the server against the scheduler/transport/401 lanes, and
|
|
83
|
+
// the durable store always converges on the true `current` secret.
|
|
84
|
+
steps.push({
|
|
85
|
+
id: 'device-secret-mint',
|
|
86
|
+
run: async () => {
|
|
87
|
+
const result = await refreshDeviceSecretArm({ oxy, store });
|
|
88
|
+
switch (result.status) {
|
|
89
|
+
case 'ok':
|
|
90
|
+
// The arm persisted the rotated secret and planted the token.
|
|
91
|
+
return {
|
|
92
|
+
kind: 'session',
|
|
93
|
+
session: {
|
|
94
|
+
sessionId: result.sessionId,
|
|
95
|
+
userId: result.userId,
|
|
96
|
+
accessToken: result.token,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
case 'invalid-secret': {
|
|
82
100
|
// Stale/diverged secret — drop it so the mint lane stops firing. On
|
|
83
101
|
// native the shared-key step below can still recover; on web this ends
|
|
84
102
|
// signed out. Setting it undefined drops the key on the store's JSON
|
|
85
103
|
// serialization, and the mint guard treats undefined as absent.
|
|
86
|
-
await store.
|
|
104
|
+
const persisted = await store.load();
|
|
105
|
+
if (persisted) {
|
|
106
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
107
|
+
}
|
|
87
108
|
return { kind: 'skip' };
|
|
88
109
|
}
|
|
89
|
-
|
|
90
|
-
// Device known, no live session — authoritative signed-out.
|
|
110
|
+
case 'no-session':
|
|
111
|
+
// Device known, no live session — authoritative signed-out. Keep the
|
|
112
|
+
// secret (the device may sign in again).
|
|
91
113
|
signedOutReason = 'no_session';
|
|
92
114
|
return { kind: 'skip' };
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
case 'persist-failed':
|
|
116
|
+
// The mint rotated the secret but it could not be durably persisted —
|
|
117
|
+
// refuse to advertise a session that will not survive a reload. Keep
|
|
118
|
+
// the secret; a later boot/attempt re-mints once storage recovers.
|
|
119
|
+
logger.error('device-secret mint rotated the secret but it could not be durably persisted — not planting', undefined, { component: 'sessionColdBoot', method: 'device-secret-mint' });
|
|
120
|
+
return { kind: 'skip' };
|
|
121
|
+
case 'transient':
|
|
122
|
+
// Network / 5xx: keep the secret; a later attempt can succeed.
|
|
123
|
+
logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
|
|
124
|
+
return { kind: 'skip' };
|
|
125
|
+
case 'no-secret':
|
|
126
|
+
return { kind: 'skip' };
|
|
97
127
|
}
|
|
98
128
|
},
|
|
99
129
|
});
|
|
100
|
-
//
|
|
130
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
101
131
|
steps.push({
|
|
102
132
|
id: 'shared-key-signin',
|
|
103
133
|
enabled: () => isNative,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import _cjs_elliptic from 'elliptic';
|
|
8
8
|
const { ec: EC } = _cjs_elliptic;
|
|
9
9
|
import { isWeb, isIOS, isAndroid } from '../utils/platform.js';
|
|
10
|
-
import { isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore } from '@oxyhq/protocol';
|
|
10
|
+
import { isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
|
|
11
11
|
import { logger } from '../utils/loggerUtils.js';
|
|
12
12
|
import { isDev } from '../shared/utils/debugUtils.js';
|
|
13
13
|
/**
|
|
@@ -230,12 +230,21 @@ export class KeyManager {
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
else if (isAndroid()) {
|
|
233
|
-
// Android:
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
await
|
|
233
|
+
// Android: write through the cross-app bridge (`@oxyhq/expo-oxy-identity`)
|
|
234
|
+
// when present — it persists into Commons's hardware-backed
|
|
235
|
+
// EncryptedSharedPreferences behind a signature-protected ContentProvider,
|
|
236
|
+
// so same-key Oxy apps can read it. When the bridge is not linked, fall
|
|
237
|
+
// back to the package-private secure store (no cross-app sharing).
|
|
238
|
+
const bridge = await loadSharedIdentityBridge();
|
|
239
|
+
if (bridge) {
|
|
240
|
+
await bridge.putShared(privateKey, publicKey);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
|
|
244
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
245
|
+
});
|
|
246
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
247
|
+
}
|
|
239
248
|
}
|
|
240
249
|
// Update cache
|
|
241
250
|
KeyManager.cachedSharedPublicKey = publicKey;
|
|
@@ -266,7 +275,15 @@ export class KeyManager {
|
|
|
266
275
|
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
267
276
|
}
|
|
268
277
|
else if (isAndroid()) {
|
|
269
|
-
|
|
278
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
279
|
+
// back to the package-private store the fallback write path used.
|
|
280
|
+
const bridge = await loadSharedIdentityBridge();
|
|
281
|
+
if (bridge) {
|
|
282
|
+
publicKey = (await bridge.getShared())?.publicKey ?? null;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
286
|
+
}
|
|
270
287
|
}
|
|
271
288
|
// Cache result
|
|
272
289
|
KeyManager.cachedSharedPublicKey = publicKey;
|
|
@@ -300,7 +317,15 @@ export class KeyManager {
|
|
|
300
317
|
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
301
318
|
}
|
|
302
319
|
else if (isAndroid()) {
|
|
303
|
-
|
|
320
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
321
|
+
// back to the package-private store the fallback write path used.
|
|
322
|
+
const bridge = await loadSharedIdentityBridge();
|
|
323
|
+
if (bridge) {
|
|
324
|
+
privateKey = (await bridge.getShared())?.privateKey ?? null;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
328
|
+
}
|
|
304
329
|
}
|
|
305
330
|
return privateKey;
|
|
306
331
|
}
|
|
@@ -373,10 +398,18 @@ export class KeyManager {
|
|
|
373
398
|
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
374
399
|
}
|
|
375
400
|
else if (isAndroid()) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
401
|
+
// Android: write through the cross-app bridge when present; otherwise the
|
|
402
|
+
// package-private store (kept consistent with the read fallback).
|
|
403
|
+
const bridge = await loadSharedIdentityBridge();
|
|
404
|
+
if (bridge) {
|
|
405
|
+
await bridge.putShared(canonicalPrivate, publicKey);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
|
|
409
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
410
|
+
});
|
|
411
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
412
|
+
}
|
|
380
413
|
}
|
|
381
414
|
// Update cache
|
|
382
415
|
KeyManager.cachedSharedPublicKey = publicKey;
|
package/dist/esm/index.js
CHANGED
|
@@ -166,7 +166,7 @@ export { AccountDialogController, createAccountDialogController, } from './sessi
|
|
|
166
166
|
// via `POST /session/device/token`.
|
|
167
167
|
// ---------------------------------------------------------------------------
|
|
168
168
|
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore.js';
|
|
169
|
-
export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
|
|
169
|
+
export { refreshPersistedSession, refreshDeviceSecretArm, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
|
|
170
170
|
export { runSessionColdBoot } from './boot/sessionColdBoot.js';
|
|
171
171
|
// API response contracts (request/response Zod schemas + inferred types) live in
|
|
172
172
|
// `@oxyhq/contracts` — the single source of truth shared by the backend and every
|
|
@@ -81,7 +81,7 @@ export class SessionClient {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
84
|
-
applyState(raw) {
|
|
84
|
+
applyState(raw, origin = 'push') {
|
|
85
85
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
86
86
|
if (!next) {
|
|
87
87
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -105,7 +105,7 @@ export class SessionClient {
|
|
|
105
105
|
this.notify();
|
|
106
106
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
107
107
|
try {
|
|
108
|
-
this.options.onUnauthenticated();
|
|
108
|
+
this.options.onUnauthenticated(origin);
|
|
109
109
|
}
|
|
110
110
|
catch (error) {
|
|
111
111
|
logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
@@ -151,7 +151,10 @@ export class SessionClient {
|
|
|
151
151
|
logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
|
-
this
|
|
154
|
+
// A `sync` is always the response to a direct REST call this client made
|
|
155
|
+
// (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
|
|
156
|
+
// verdict.
|
|
157
|
+
this.applyState(sync.state, 'request');
|
|
155
158
|
if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
|
|
156
159
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
157
160
|
}
|
|
@@ -289,7 +292,9 @@ export class SessionClient {
|
|
|
289
292
|
},
|
|
290
293
|
});
|
|
291
294
|
socket.on('session_state', (payload) => {
|
|
292
|
-
|
|
295
|
+
// A socket broadcast is a `push`-origin — potentially transient, so an
|
|
296
|
+
// empty state here must not erase the durable device credential.
|
|
297
|
+
const applied = this.applyState(payload, 'push');
|
|
293
298
|
if (!applied)
|
|
294
299
|
return;
|
|
295
300
|
// A push changed the active account on another device/tab — re-fetch state
|
|
@@ -194,7 +194,9 @@ export function createMemoryAuthStateStore() {
|
|
|
194
194
|
return {
|
|
195
195
|
load: async () => current,
|
|
196
196
|
save: async (state) => {
|
|
197
|
+
// Memory IS this store's durability backing — the write always lands.
|
|
197
198
|
current = state;
|
|
199
|
+
return true;
|
|
198
200
|
},
|
|
199
201
|
clear: async () => {
|
|
200
202
|
current = null;
|
|
@@ -287,6 +289,9 @@ export function createWebAuthStateStore() {
|
|
|
287
289
|
catch {
|
|
288
290
|
// Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
|
|
289
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;
|
|
290
295
|
},
|
|
291
296
|
clear: async () => {
|
|
292
297
|
sessionMirror = null;
|
|
@@ -370,6 +375,9 @@ export function createNativeAuthStateStore(storage) {
|
|
|
370
375
|
catch {
|
|
371
376
|
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
372
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;
|
|
373
381
|
},
|
|
374
382
|
clear: async () => {
|
|
375
383
|
sessionMirror = null;
|
|
@@ -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
|
}
|