@oxyhq/core 9.2.2 → 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/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 +1 -1
- 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
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import type { OxyServices } from '../../OxyServices';
|
|
2
2
|
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
3
3
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
refreshPersistedSession,
|
|
6
|
+
startTokenRefreshScheduler,
|
|
7
|
+
type DeviceSecretMintOutcome,
|
|
8
|
+
} from '../refresh';
|
|
9
|
+
import {
|
|
10
|
+
createMemoryAuthStateStore,
|
|
11
|
+
type AuthStateStore,
|
|
12
|
+
type PersistedAuthState,
|
|
13
|
+
} from '../authStateStore';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A real, per-client device-secret mint single-flight matching
|
|
17
|
+
* `HttpService.runSingleFlightDeviceSecretMint`: concurrent callers await the
|
|
18
|
+
* SAME in-flight mint and all receive its result; a fresh call after it settles
|
|
19
|
+
* starts a new one.
|
|
20
|
+
*/
|
|
21
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
22
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
23
|
+
return (mint) => {
|
|
24
|
+
if (!inFlight) {
|
|
25
|
+
inFlight = mint().finally(() => {
|
|
26
|
+
inFlight = null;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return inFlight;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
/** The persisted zero-cookie mint credential the refresh reads. */
|
|
8
34
|
const STORED: PersistedAuthState = {
|
|
@@ -37,6 +63,9 @@ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setT
|
|
|
37
63
|
setTokens,
|
|
38
64
|
mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
39
65
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
66
|
+
// The rotating mint runs under the client's process-wide single-flight; the
|
|
67
|
+
// arm reaches for it via `oxy.httpService.runSingleFlightDeviceSecretMint`.
|
|
68
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
40
69
|
} as unknown as OxyServices;
|
|
41
70
|
return { oxy, setTokens };
|
|
42
71
|
}
|
|
@@ -169,6 +198,116 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
169
198
|
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
170
199
|
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
171
200
|
});
|
|
201
|
+
|
|
202
|
+
it('persists the recovered credential from the shared-key re-mint (repopulates the fast lane)', async () => {
|
|
203
|
+
// Bug #3: an in-session shared-key recovery must repopulate the durable
|
|
204
|
+
// device credential, not leave the fast device-secret lane empty.
|
|
205
|
+
const store = createMemoryAuthStateStore(); // no persisted secret → arm 1 skips
|
|
206
|
+
const RECOVERED: SessionLoginResponse = {
|
|
207
|
+
sessionId: 'sess-shared',
|
|
208
|
+
deviceId: 'dev-shared',
|
|
209
|
+
deviceSecret: 'ds-shared-secret',
|
|
210
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
211
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
212
|
+
accessToken: 'access-shared',
|
|
213
|
+
};
|
|
214
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity: jest.fn(async () => RECOVERED) });
|
|
215
|
+
|
|
216
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
217
|
+
|
|
218
|
+
expect(token).toBe('access-shared');
|
|
219
|
+
expect(await store.load()).toEqual({
|
|
220
|
+
sessionId: 'sess-shared',
|
|
221
|
+
userId: 'user-shared',
|
|
222
|
+
deviceId: 'dev-shared',
|
|
223
|
+
deviceSecret: 'ds-shared-secret',
|
|
224
|
+
accessToken: 'access-shared',
|
|
225
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
|
|
231
|
+
it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
|
|
232
|
+
// The server rotates the secret on every mint. Two concurrent lanes must
|
|
233
|
+
// therefore share ONE in-flight mint (one rotation) or the store could
|
|
234
|
+
// converge on a superseded secret.
|
|
235
|
+
const store = createMemoryAuthStateStore();
|
|
236
|
+
await store.save(STORED);
|
|
237
|
+
|
|
238
|
+
let release: (() => void) | null = null;
|
|
239
|
+
const gate = new Promise<void>((resolve) => {
|
|
240
|
+
release = resolve;
|
|
241
|
+
});
|
|
242
|
+
const mintFromDeviceSecret = jest.fn(async (deviceId: string, deviceSecret: string) => {
|
|
243
|
+
// Block until BOTH callers have entered so the single-flight is exercised.
|
|
244
|
+
await gate;
|
|
245
|
+
expect(deviceId).toBe('dev-mint');
|
|
246
|
+
expect(deviceSecret).toBe('ds-secret-orig');
|
|
247
|
+
return MINT;
|
|
248
|
+
});
|
|
249
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
250
|
+
|
|
251
|
+
const first = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
252
|
+
const second = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
253
|
+
// Both callers entered while the mint is in flight.
|
|
254
|
+
release?.();
|
|
255
|
+
const [t1, t2] = await Promise.all([first, second]);
|
|
256
|
+
|
|
257
|
+
// Exactly one server rotation despite two concurrent callers…
|
|
258
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledTimes(1);
|
|
259
|
+
// …both callers received the same minted token…
|
|
260
|
+
expect(t1).toBe('access-new');
|
|
261
|
+
expect(t2).toBe('access-new');
|
|
262
|
+
// …and the durable store converged on the rotated (current) secret.
|
|
263
|
+
expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
|
|
264
|
+
// The token was planted exactly once (inside the single-flighted mint).
|
|
265
|
+
expect(setTokens).toHaveBeenCalledTimes(1);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('refreshPersistedSession — durable persist failure is fatal to the mint', () => {
|
|
270
|
+
it('does NOT plant a token when the rotated secret cannot be durably persisted', async () => {
|
|
271
|
+
// Bug #2: a mint that rotated the server secret but could not persist it must
|
|
272
|
+
// not leave the process advertising a session on an unsaved, soon-dead secret.
|
|
273
|
+
const failingStore: AuthStateStore = {
|
|
274
|
+
load: async () => STORED,
|
|
275
|
+
save: async () => false, // durable write did not land
|
|
276
|
+
clear: async () => undefined,
|
|
277
|
+
};
|
|
278
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
279
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
280
|
+
|
|
281
|
+
const token = await refreshPersistedSession({
|
|
282
|
+
oxy,
|
|
283
|
+
store: failingStore,
|
|
284
|
+
allowSharedKeyFallback: false,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// The mint ran (the server rotated) but the token was NOT planted…
|
|
288
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
290
|
+
// …and the lane reports failure rather than a healthy session.
|
|
291
|
+
expect(token).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('does NOT fall through to the shared-key arm on a persist failure', async () => {
|
|
295
|
+
const failingStore: AuthStateStore = {
|
|
296
|
+
load: async () => STORED,
|
|
297
|
+
save: async () => false,
|
|
298
|
+
clear: async () => undefined,
|
|
299
|
+
};
|
|
300
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
301
|
+
const { oxy } = makeOxy({
|
|
302
|
+
mintFromDeviceSecret: async () => MINT,
|
|
303
|
+
signInWithSharedIdentity,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await refreshPersistedSession({ oxy, store: failingStore, allowSharedKeyFallback: true });
|
|
307
|
+
|
|
308
|
+
// A storage failure is not a bad-secret signal — the shared-key arm must not run.
|
|
309
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
172
311
|
});
|
|
173
312
|
|
|
174
313
|
describe('startTokenRefreshScheduler', () => {
|
|
@@ -68,7 +68,21 @@ export interface PersistedAuthState {
|
|
|
68
68
|
*/
|
|
69
69
|
export interface AuthStateStore {
|
|
70
70
|
load(): Promise<PersistedAuthState | null>;
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Persist the credential blob and report whether it durably landed.
|
|
73
|
+
*
|
|
74
|
+
* Resolves `true` when the state is retained consistent with this store's
|
|
75
|
+
* durability guarantee — a durable backing whose read-back matched, or a
|
|
76
|
+
* degraded/in-memory store that held it in memory. Resolves `false` when a
|
|
77
|
+
* DURABLE backing was expected but the write did NOT land (read-back mismatch
|
|
78
|
+
* or a thrown write); the in-memory mirror still keeps the session live for
|
|
79
|
+
* this process, but it will be lost on reload.
|
|
80
|
+
*
|
|
81
|
+
* A lane persisting a ROTATED device secret (the mint's `nextDeviceSecret`)
|
|
82
|
+
* MUST treat `false` as fatal for that mint: it must NOT plant/advertise a
|
|
83
|
+
* session built on a secret that will not survive a reload.
|
|
84
|
+
*/
|
|
85
|
+
save(state: PersistedAuthState): Promise<boolean>;
|
|
72
86
|
clear(): Promise<void>;
|
|
73
87
|
}
|
|
74
88
|
|
|
@@ -275,7 +289,9 @@ export function createMemoryAuthStateStore(): AuthStateStore {
|
|
|
275
289
|
return {
|
|
276
290
|
load: async () => current,
|
|
277
291
|
save: async (state) => {
|
|
292
|
+
// Memory IS this store's durability backing — the write always lands.
|
|
278
293
|
current = state;
|
|
294
|
+
return true;
|
|
279
295
|
},
|
|
280
296
|
clear: async () => {
|
|
281
297
|
current = null;
|
|
@@ -376,6 +392,9 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
376
392
|
} catch {
|
|
377
393
|
// Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
|
|
378
394
|
}
|
|
395
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
396
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
397
|
+
return durablePersisted;
|
|
379
398
|
},
|
|
380
399
|
clear: async () => {
|
|
381
400
|
sessionMirror = null;
|
|
@@ -462,6 +481,9 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
462
481
|
} catch {
|
|
463
482
|
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
464
483
|
}
|
|
484
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
485
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
486
|
+
return durablePersisted;
|
|
465
487
|
},
|
|
466
488
|
clear: async () => {
|
|
467
489
|
sessionMirror = null;
|
package/src/session/refresh.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Framework-free; no module-level mutable state.
|
|
22
22
|
*/
|
|
23
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
23
24
|
import type { OxyServices } from '../OxyServices';
|
|
24
25
|
import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
|
|
25
26
|
import type { AuthStateStore, PersistedAuthState } from './authStateStore';
|
|
@@ -71,68 +72,173 @@ export interface RefreshDeps {
|
|
|
71
72
|
allowSharedKeyFallback?: boolean;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
77
|
+
* the re-mint handler and the cold boot can react per the transport contract
|
|
78
|
+
* without re-classifying the raw error:
|
|
79
|
+
* - `ok` — minted, persisted the rotated secret, planted the token.
|
|
80
|
+
* - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
|
|
81
|
+
* - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
|
|
82
|
+
* diverged (another tab/device rotated it past the grace window).
|
|
83
|
+
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
84
|
+
* session (authoritative signed-out).
|
|
85
|
+
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
86
|
+
* - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
|
|
87
|
+
* the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
|
|
88
|
+
* deliberately NOT planted: advertising a healthy session on a secret that
|
|
89
|
+
* will not survive a reload is exactly the divergence that logs users out.
|
|
90
|
+
*/
|
|
91
|
+
export type DeviceSecretMintOutcome =
|
|
92
|
+
| { status: 'ok'; token: string; sessionId: string; userId: string }
|
|
93
|
+
| { status: 'no-secret' }
|
|
94
|
+
| { status: 'invalid-secret' }
|
|
95
|
+
| { status: 'no-session' }
|
|
96
|
+
| { status: 'transient' }
|
|
97
|
+
| { status: 'persist-failed' };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Arm 1 — the rotating device-secret mint, run under the owning client's
|
|
101
|
+
* PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
102
|
+
*
|
|
103
|
+
* The server ROTATES the presented `deviceSecret` on every successful mint and
|
|
104
|
+
* the just-presented secret is valid only for a short grace window. If two lanes
|
|
105
|
+
* (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
|
|
106
|
+
* the socket token transport, or a tab-focus reconcile) minted concurrently they
|
|
107
|
+
* would double-rotate the server and the durable store could converge on the
|
|
108
|
+
* SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
|
|
109
|
+
* user is signed out. Routing EVERY lane through this one single-flight makes
|
|
110
|
+
* concurrent callers await the SAME in-flight mint and all receive its result, so
|
|
111
|
+
* there is exactly one rotation and the store always converges on the true
|
|
112
|
+
* `current` secret.
|
|
113
|
+
*
|
|
114
|
+
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
115
|
+
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
116
|
+
* planting. This function performs NO store mutation on failure — the caller
|
|
117
|
+
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
118
|
+
* status.
|
|
119
|
+
*/
|
|
120
|
+
export async function refreshDeviceSecretArm(deps: {
|
|
121
|
+
oxy: OxyServices;
|
|
122
|
+
store: AuthStateStore;
|
|
123
|
+
}): Promise<DeviceSecretMintOutcome> {
|
|
124
|
+
const { oxy, store } = deps;
|
|
125
|
+
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
126
|
+
const persisted = await store.load();
|
|
127
|
+
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
128
|
+
return { status: 'no-secret' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let mint: DeviceTokenMintResponse;
|
|
132
|
+
try {
|
|
133
|
+
mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (extractErrorStatus(error) === 401) {
|
|
136
|
+
// Structural read (not `instanceof Error`): the thrown value can be a
|
|
137
|
+
// plain ApiError-shaped object or come from another realm.
|
|
138
|
+
const message = (error as { message?: unknown })?.message;
|
|
139
|
+
return typeof message === 'string' && message.includes('no_active_session')
|
|
140
|
+
? { status: 'no-session' }
|
|
141
|
+
: { status: 'invalid-secret' };
|
|
142
|
+
}
|
|
143
|
+
return { status: 'transient' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
|
|
147
|
+
const next: PersistedAuthState = {
|
|
148
|
+
...persisted,
|
|
149
|
+
deviceId: mint.state.deviceId,
|
|
150
|
+
deviceSecret: mint.nextDeviceSecret,
|
|
151
|
+
accessToken: mint.accessToken,
|
|
152
|
+
expiresAt: mint.expiresAt,
|
|
153
|
+
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
154
|
+
};
|
|
155
|
+
// Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
|
|
156
|
+
// landed BEFORE planting the token. A failed durable persist must NOT plant.
|
|
157
|
+
const persistedOk = await store.save(next);
|
|
158
|
+
if (!persistedOk) {
|
|
159
|
+
return { status: 'persist-failed' };
|
|
160
|
+
}
|
|
161
|
+
oxy.setTokens(mint.accessToken);
|
|
162
|
+
return { status: 'ok', token: mint.accessToken, sessionId: next.sessionId, userId: next.userId };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
74
166
|
/**
|
|
75
167
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
76
168
|
* when no arm could produce one.
|
|
77
169
|
*
|
|
78
|
-
* Arm 1 (`POST /session/device/token
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
170
|
+
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
171
|
+
* from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
|
|
172
|
+
* or the device has no live session: drop the secret so the mint lane stops (or
|
|
173
|
+
* clear the store on web, where there is no fallback), then fall to arm 2 on
|
|
174
|
+
* native. A transient error — or a durable-persist failure — leaves the store and
|
|
175
|
+
* returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
|
|
84
176
|
*
|
|
85
177
|
* Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
|
|
86
|
-
* re-mint via `signInWithSharedIdentity` (which plants tokens).
|
|
87
|
-
*
|
|
88
|
-
*
|
|
178
|
+
* re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
|
|
179
|
+
* recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
|
|
180
|
+
* lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
|
|
181
|
+
* in-session shared-key recovery must not leave the fast-lane credential empty.
|
|
89
182
|
*/
|
|
90
183
|
export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
|
|
91
184
|
const { oxy, store } = deps;
|
|
92
185
|
const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
|
|
93
186
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
const arm1 = await refreshDeviceSecretArm({ oxy, store });
|
|
188
|
+
switch (arm1.status) {
|
|
189
|
+
case 'ok':
|
|
190
|
+
return arm1.token;
|
|
191
|
+
case 'transient':
|
|
192
|
+
logger.debug(
|
|
193
|
+
'Persisted deviceSecret mint failed (transient) — keeping store',
|
|
194
|
+
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
195
|
+
);
|
|
196
|
+
return null;
|
|
197
|
+
case 'persist-failed':
|
|
198
|
+
// The server rotated the secret but it did not durably persist. Do NOT fall
|
|
199
|
+
// to shared-key and do NOT plant — a later attempt re-mints (the process
|
|
200
|
+
// mirror still holds the rotated secret the server accepts) and can persist
|
|
201
|
+
// once storage recovers. Never advertise a session on an unsaved secret.
|
|
202
|
+
logger.error(
|
|
203
|
+
'Device-secret mint rotated the secret but it could not be durably persisted — refusing to plant (a later attempt re-mints)',
|
|
204
|
+
undefined,
|
|
205
|
+
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
206
|
+
);
|
|
207
|
+
return null;
|
|
208
|
+
case 'invalid-secret':
|
|
209
|
+
case 'no-session': {
|
|
210
|
+
// 401: secret diverged or no live session. On a shared-key device drop only
|
|
211
|
+
// the secret (keep the identity so arm 2 can recover); otherwise (web) the
|
|
212
|
+
// session is over — clear the store.
|
|
213
|
+
const persisted = await store.load();
|
|
214
|
+
if (allowSharedKeyFallback) {
|
|
215
|
+
if (persisted) {
|
|
116
216
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
117
|
-
} else {
|
|
118
|
-
await store.clear();
|
|
119
217
|
}
|
|
120
|
-
// Fall through to the native shared-key arm.
|
|
121
218
|
} else {
|
|
122
|
-
|
|
123
|
-
'Persisted deviceSecret mint failed (transient) — keeping store',
|
|
124
|
-
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
125
|
-
error,
|
|
126
|
-
);
|
|
127
|
-
return null;
|
|
219
|
+
await store.clear();
|
|
128
220
|
}
|
|
221
|
+
break;
|
|
129
222
|
}
|
|
223
|
+
case 'no-secret':
|
|
224
|
+
break;
|
|
130
225
|
}
|
|
131
226
|
|
|
132
227
|
if (allowSharedKeyFallback) {
|
|
133
228
|
try {
|
|
134
229
|
const session = await oxy.signInWithSharedIdentity();
|
|
135
230
|
if (session?.accessToken) {
|
|
231
|
+
// Repopulate the fast device-secret lane from the shared-key re-mint.
|
|
232
|
+
if (session.deviceId && session.deviceSecret) {
|
|
233
|
+
await store.save({
|
|
234
|
+
sessionId: session.sessionId,
|
|
235
|
+
userId: session.user.id,
|
|
236
|
+
deviceId: session.deviceId,
|
|
237
|
+
deviceSecret: session.deviceSecret,
|
|
238
|
+
accessToken: session.accessToken,
|
|
239
|
+
expiresAt: session.expiresAt,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
136
242
|
return session.accessToken;
|
|
137
243
|
}
|
|
138
244
|
} catch (error) {
|