@oxyhq/core 8.0.0 → 8.1.0
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/coldBootV2.js +97 -2
- package/dist/cjs/boot/deviceBootReturn.js +15 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +28 -0
- package/dist/cjs/session/authStateStore.js +6 -0
- package/dist/cjs/session/refresh.js +9 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +97 -2
- package/dist/esm/boot/deviceBootReturn.js +15 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +29 -1
- package/dist/esm/session/authStateStore.js +6 -0
- package/dist/esm/session/refresh.js +9 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +17 -1
- package/dist/types/session/authStateStore.d.ts +20 -0
- package/package.json +2 -2
- package/src/boot/__tests__/coldBootV2.test.ts +215 -1
- package/src/boot/__tests__/deviceBootReturn.test.ts +32 -0
- package/src/boot/coldBootV2.ts +117 -2
- package/src/boot/deviceBootReturn.ts +15 -0
- package/src/mixins/OxyServices.deviceBoot.ts +38 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +44 -1
- package/src/session/__tests__/authStateStore.test.ts +27 -0
- package/src/session/__tests__/refresh.test.ts +14 -0
- package/src/session/authStateStore.ts +26 -0
- package/src/session/refresh.ts +9 -0
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { resolveUserId } from '@oxyhq/contracts';
|
|
29
29
|
import { runColdBoot } from '../utils/coldBoot.js';
|
|
30
30
|
import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform.js';
|
|
31
|
+
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
31
32
|
import { KeyManager } from '../crypto/keyManager.js';
|
|
32
33
|
import { logger } from '../utils/loggerUtils.js';
|
|
33
34
|
import { refreshPersistedSession } from '../session/refresh.js';
|
|
@@ -159,6 +160,18 @@ export function isSameApex(pageHost, apiHost) {
|
|
|
159
160
|
function sessionFromPersisted(state, accessToken) {
|
|
160
161
|
return { sessionId: state.sessionId, userId: state.userId, accessToken };
|
|
161
162
|
}
|
|
163
|
+
function classifyMintFailure(error) {
|
|
164
|
+
if (extractErrorStatus(error) === 401) {
|
|
165
|
+
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
166
|
+
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
167
|
+
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
168
|
+
const message = error?.message;
|
|
169
|
+
return typeof message === 'string' && message.includes('no_active_session')
|
|
170
|
+
? 'no_active_session'
|
|
171
|
+
: 'invalid_secret';
|
|
172
|
+
}
|
|
173
|
+
return 'transient';
|
|
174
|
+
}
|
|
162
175
|
/**
|
|
163
176
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
164
177
|
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
@@ -174,7 +187,73 @@ export async function runSessionColdBoot(opts) {
|
|
|
174
187
|
// they cannot leak across boots or break under bundler re-evaluation.
|
|
175
188
|
let signedOutReason = 'no_session';
|
|
176
189
|
let navigating = false;
|
|
190
|
+
// Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
|
|
191
|
+
// device is authoritatively signed out, so the migratory fallback lanes below
|
|
192
|
+
// (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
|
|
193
|
+
// there is no session and must not bounce a known-signed-out device.
|
|
194
|
+
let deviceKnownSignedOut = false;
|
|
177
195
|
const steps = [];
|
|
196
|
+
// 0. device-secret-mint (phase 2c) — the zero-cookie fast path. When the
|
|
197
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
198
|
+
// a single bearer-less POST (no cookie, no navigation). FIRST in the chain
|
|
199
|
+
// so it wins over the migratory cookie lanes below. Gated OFF while a
|
|
200
|
+
// #oxy_boot return fragment is present so `bootstrap-return` still consumes
|
|
201
|
+
// + strips it first (a device holding a secret never triggers that hop, so
|
|
202
|
+
// this only defers a rare stale/forged fragment). The rest of the chain
|
|
203
|
+
// stays as the additive migratory fallback for devices not yet on the secret.
|
|
204
|
+
steps.push({
|
|
205
|
+
id: 'device-secret-mint',
|
|
206
|
+
enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
|
|
207
|
+
run: async () => {
|
|
208
|
+
const persisted = await store.load();
|
|
209
|
+
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
210
|
+
return { kind: 'skip' };
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
214
|
+
// Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
|
|
215
|
+
// fields, + the server's authoritative active account) BEFORE planting
|
|
216
|
+
// the minted access token, so a multi-tab race that rotates again can
|
|
217
|
+
// never strand this tab with a superseded secret.
|
|
218
|
+
const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
|
|
219
|
+
const next = {
|
|
220
|
+
...persisted,
|
|
221
|
+
deviceId: mint.state.deviceId,
|
|
222
|
+
deviceSecret: mint.nextDeviceSecret,
|
|
223
|
+
accessToken: mint.accessToken,
|
|
224
|
+
expiresAt: mint.expiresAt,
|
|
225
|
+
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
226
|
+
};
|
|
227
|
+
await store.save(next);
|
|
228
|
+
oxy.setTokens(mint.accessToken);
|
|
229
|
+
return {
|
|
230
|
+
kind: 'session',
|
|
231
|
+
session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
const failure = classifyMintFailure(error);
|
|
236
|
+
if (failure === 'invalid_secret') {
|
|
237
|
+
// Stale/diverged secret — drop it so the mint lane stops firing, then
|
|
238
|
+
// fall through to the migratory refresh/cookie lanes. Setting it
|
|
239
|
+
// undefined drops the key on the store's JSON serialization, and the
|
|
240
|
+
// mint guard treats undefined as absent.
|
|
241
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
242
|
+
return { kind: 'skip' };
|
|
243
|
+
}
|
|
244
|
+
if (failure === 'no_active_session') {
|
|
245
|
+
// Device known, no live session — authoritative signed-out. KEEP the
|
|
246
|
+
// secret and stop the chain (do not bounce a known-signed-out device).
|
|
247
|
+
deviceKnownSignedOut = true;
|
|
248
|
+
signedOutReason = 'no_session';
|
|
249
|
+
return { kind: 'skip' };
|
|
250
|
+
}
|
|
251
|
+
// Transient (network / 5xx): keep the secret, let the fallback lanes try.
|
|
252
|
+
logger.debug('device-secret mint failed (transient) — keeping secret, falling back', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
|
|
253
|
+
return { kind: 'skip' };
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
});
|
|
178
257
|
// 1. bootstrap-return (web) — consume a #oxy_boot fragment.
|
|
179
258
|
steps.push({
|
|
180
259
|
id: 'bootstrap-return',
|
|
@@ -208,6 +287,7 @@ export async function runSessionColdBoot(opts) {
|
|
|
208
287
|
// 2. stored-tokens — warm-plant or rotate the persisted refresh family.
|
|
209
288
|
steps.push({
|
|
210
289
|
id: 'stored-tokens',
|
|
290
|
+
enabled: () => !deviceKnownSignedOut,
|
|
211
291
|
run: async () => {
|
|
212
292
|
const persisted = await store.load();
|
|
213
293
|
if (!persisted) {
|
|
@@ -234,7 +314,7 @@ export async function runSessionColdBoot(opts) {
|
|
|
234
314
|
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
235
315
|
steps.push({
|
|
236
316
|
id: 'shared-key-signin',
|
|
237
|
-
enabled: () => isNative,
|
|
317
|
+
enabled: () => isNative && !deviceKnownSignedOut,
|
|
238
318
|
run: async () => {
|
|
239
319
|
const session = await oxy.signInWithSharedIdentity();
|
|
240
320
|
if (!session?.accessToken) {
|
|
@@ -267,7 +347,7 @@ export async function runSessionColdBoot(opts) {
|
|
|
267
347
|
// 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
|
|
268
348
|
steps.push({
|
|
269
349
|
id: 'bootstrap-hop',
|
|
270
|
-
enabled: () => isWeb,
|
|
350
|
+
enabled: () => isWeb && !deviceKnownSignedOut,
|
|
271
351
|
run: async () => {
|
|
272
352
|
const pageHost = dom.getLocationHostname();
|
|
273
353
|
let apiHost = null;
|
|
@@ -300,6 +380,21 @@ export async function runSessionColdBoot(opts) {
|
|
|
300
380
|
accessToken: bundle.accessToken,
|
|
301
381
|
expiresAt: bundle.expiresAt,
|
|
302
382
|
};
|
|
383
|
+
// Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
|
|
384
|
+
// but NOT a deviceId. Persist the secret and carry any prior deviceId
|
|
385
|
+
// (from a deviceId-bearing login lane) forward so the mint lane stays
|
|
386
|
+
// usable — this overwrite must not orphan it.
|
|
387
|
+
const prior = await store.load();
|
|
388
|
+
if (prior?.deviceId) {
|
|
389
|
+
next.deviceId = prior.deviceId;
|
|
390
|
+
}
|
|
391
|
+
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
392
|
+
// prior one when the bundle omits it — this lane also runs as the
|
|
393
|
+
// TRANSIENT-mint fallback, and must not orphan a still-valid secret.
|
|
394
|
+
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
395
|
+
if (carriedSecret) {
|
|
396
|
+
next.deviceSecret = carriedSecret;
|
|
397
|
+
}
|
|
303
398
|
await store.save(next);
|
|
304
399
|
oxy.setTokens(bundle.accessToken);
|
|
305
400
|
return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
|
|
@@ -129,6 +129,21 @@ export async function consumeDeviceBootReturn(deps) {
|
|
|
129
129
|
accessToken: bundle.accessToken,
|
|
130
130
|
expiresAt: bundle.expiresAt,
|
|
131
131
|
};
|
|
132
|
+
// Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
|
|
133
|
+
// but NOT a deviceId. Persist the secret, and carry any prior deviceId
|
|
134
|
+
// forward (from a deviceId-bearing login lane) so the pair stays usable by
|
|
135
|
+
// the zero-cookie mint — an overwrite here must not orphan the mint lane.
|
|
136
|
+
const prior = await deps.store.load();
|
|
137
|
+
if (prior?.deviceId) {
|
|
138
|
+
next.deviceId = prior.deviceId;
|
|
139
|
+
}
|
|
140
|
+
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
141
|
+
// prior one when the bundle omits it so a cookie-lane boot can never orphan
|
|
142
|
+
// a still-valid secret captured by an earlier login lane.
|
|
143
|
+
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
144
|
+
if (carriedSecret) {
|
|
145
|
+
next.deviceSecret = carriedSecret;
|
|
146
|
+
}
|
|
132
147
|
await deps.store.save(next);
|
|
133
148
|
deps.plantAccessToken(bundle.accessToken);
|
|
134
149
|
return {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* and `setTokens`, so the same network primitive can be reused from either
|
|
16
16
|
* without double-planting.
|
|
17
17
|
*/
|
|
18
|
-
import { authTokenBundleSchema, tokenRefreshResponseSchema, deviceTokenIssueResponseSchema, webSessionResultSchema, safeParseContract, } from '@oxyhq/contracts';
|
|
18
|
+
import { authTokenBundleSchema, tokenRefreshResponseSchema, deviceTokenIssueResponseSchema, deviceTokenMintResponseSchema, webSessionResultSchema, safeParseContract, } from '@oxyhq/contracts';
|
|
19
19
|
export function OxyServicesDeviceBootMixin(Base) {
|
|
20
20
|
return class extends Base {
|
|
21
21
|
/**
|
|
@@ -102,6 +102,34 @@ export function OxyServicesDeviceBootMixin(Base) {
|
|
|
102
102
|
throw this.handleError(error);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
|
|
107
|
+
* `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
|
|
108
|
+
* possession of the secret IS the device-ownership proof. Returns a fresh
|
|
109
|
+
* short access token for the device's active account plus `nextDeviceSecret`
|
|
110
|
+
* (rotation-in-use) and the projected device-session `state`.
|
|
111
|
+
*
|
|
112
|
+
* `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
|
|
113
|
+
* a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
|
|
114
|
+
* retry dance (which would pointlessly rotate the refresh family). The cold
|
|
115
|
+
* boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
|
|
116
|
+
* decide whether to drop the secret and fall back or resolve signed-out.
|
|
117
|
+
*
|
|
118
|
+
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
119
|
+
*/
|
|
120
|
+
async mintFromDeviceSecret(deviceId, deviceSecret) {
|
|
121
|
+
try {
|
|
122
|
+
const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret }, { cache: false, skipAuth: true });
|
|
123
|
+
const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
|
|
124
|
+
if (!parsed) {
|
|
125
|
+
throw new Error('session/device/token returned an unexpected response shape');
|
|
126
|
+
}
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw this.handleError(error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
105
133
|
/**
|
|
106
134
|
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
107
135
|
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
@@ -64,6 +64,12 @@ function deserialize(raw) {
|
|
|
64
64
|
if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
|
|
65
65
|
state.deviceToken = candidate.deviceToken;
|
|
66
66
|
}
|
|
67
|
+
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
68
|
+
state.deviceId = candidate.deviceId;
|
|
69
|
+
}
|
|
70
|
+
if (typeof candidate.deviceSecret === 'string' && candidate.deviceSecret.length > 0) {
|
|
71
|
+
state.deviceSecret = candidate.deviceSecret;
|
|
72
|
+
}
|
|
67
73
|
if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
|
|
68
74
|
state.accessToken = candidate.accessToken;
|
|
69
75
|
}
|
|
@@ -85,6 +85,15 @@ export async function refreshPersistedSession(deps) {
|
|
|
85
85
|
if (persisted.deviceToken) {
|
|
86
86
|
next.deviceToken = persisted.deviceToken;
|
|
87
87
|
}
|
|
88
|
+
// The refresh response carries no device credentials — carry the persisted
|
|
89
|
+
// deviceId/deviceSecret (phase 2c) forward so a rotation never drops the
|
|
90
|
+
// zero-cookie mint lane (mirrors the deviceToken preservation above).
|
|
91
|
+
if (persisted.deviceId) {
|
|
92
|
+
next.deviceId = persisted.deviceId;
|
|
93
|
+
}
|
|
94
|
+
if (persisted.deviceSecret) {
|
|
95
|
+
next.deviceSecret = persisted.deviceSecret;
|
|
96
|
+
}
|
|
88
97
|
await store.save(next);
|
|
89
98
|
return rotated.accessToken;
|
|
90
99
|
}
|