@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
|
@@ -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 { type AuthTokenBundle, type TokenRefreshResponse, type WebSessionResult } from '@oxyhq/contracts';
|
|
18
|
+
import { type AuthTokenBundle, type DeviceTokenMintResponse, type TokenRefreshResponse, type WebSessionResult } from '@oxyhq/contracts';
|
|
19
19
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
20
20
|
export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
21
21
|
new (...args: any[]): {
|
|
@@ -51,6 +51,22 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
|
|
|
51
51
|
* @throws if the response does not match {@link deviceTokenIssueResponseSchema}.
|
|
52
52
|
*/
|
|
53
53
|
issueNativeDeviceToken(): Promise<string>;
|
|
54
|
+
/**
|
|
55
|
+
* Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
|
|
56
|
+
* `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
|
|
57
|
+
* possession of the secret IS the device-ownership proof. Returns a fresh
|
|
58
|
+
* short access token for the device's active account plus `nextDeviceSecret`
|
|
59
|
+
* (rotation-in-use) and the projected device-session `state`.
|
|
60
|
+
*
|
|
61
|
+
* `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
|
|
62
|
+
* a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
|
|
63
|
+
* retry dance (which would pointlessly rotate the refresh family). The cold
|
|
64
|
+
* boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
|
|
65
|
+
* decide whether to drop the secret and fall back or resolve signed-out.
|
|
66
|
+
*
|
|
67
|
+
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
68
|
+
*/
|
|
69
|
+
mintFromDeviceSecret(deviceId: string, deviceSecret: string): Promise<DeviceTokenMintResponse>;
|
|
54
70
|
/**
|
|
55
71
|
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
56
72
|
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
@@ -38,6 +38,26 @@ export interface PersistedAuthState {
|
|
|
38
38
|
refreshToken: string;
|
|
39
39
|
userId: string;
|
|
40
40
|
deviceToken?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The stable device identifier this session is bound to (phase 2c —
|
|
43
|
+
* zero-cookie transport). Persisted alongside {@link deviceSecret} because the
|
|
44
|
+
* `POST /session/device/token` mint presents BOTH — the secret is the proof,
|
|
45
|
+
* the deviceId selects the device doc. Sourced from the lanes that carry it
|
|
46
|
+
* (password login / 2FA / QR claim / challenge verify); the cookie-bootstrap
|
|
47
|
+
* lanes (`AuthTokenBundle`) omit it and preserve any prior value. Additive: a
|
|
48
|
+
* blob without it simply never takes the mint lane and falls back to the
|
|
49
|
+
* refresh family.
|
|
50
|
+
*/
|
|
51
|
+
deviceId?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The rotating device secret (phase 2c — zero-cookie transport). Possession of
|
|
54
|
+
* it mints a short access token for the device's active account via
|
|
55
|
+
* `POST /session/device/token`, replacing the cookie lane. Rotated in-use: the
|
|
56
|
+
* mint returns `nextDeviceSecret`, which the cold boot persists BEFORE planting
|
|
57
|
+
* the minted access token (multi-tab anti-loss). Additive and optional — same
|
|
58
|
+
* XSS risk profile as the already-persisted `refreshToken`.
|
|
59
|
+
*/
|
|
60
|
+
deviceSecret?: string;
|
|
41
61
|
/** Optional warm-boot access token (short-lived; see interface docs). */
|
|
42
62
|
accessToken?: string;
|
|
43
63
|
/** Optional warm-boot access-token expiry, ISO-8601. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.1.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
-
"@oxyhq/contracts": "^0.
|
|
97
|
+
"@oxyhq/contracts": "^0.12.0",
|
|
98
98
|
"@oxyhq/protocol": "^0.1.2",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { OxyServices } from '../../OxyServices';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AuthTokenBundle,
|
|
4
|
+
DeviceTokenMintResponse,
|
|
5
|
+
TokenRefreshResponse,
|
|
6
|
+
WebSessionResult,
|
|
7
|
+
} from '@oxyhq/contracts';
|
|
3
8
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
9
|
import {
|
|
5
10
|
runSessionColdBoot,
|
|
@@ -34,6 +39,7 @@ interface OxyOverrides {
|
|
|
34
39
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
35
40
|
issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
|
|
36
41
|
requestWebSession?: OxyServices['requestWebSession'];
|
|
42
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
@@ -48,12 +54,37 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
48
54
|
requestWebSession:
|
|
49
55
|
overrides.requestWebSession
|
|
50
56
|
?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
|
|
57
|
+
// Default: no persisted secret in these fixtures, so the mint step skips
|
|
58
|
+
// before ever calling this. Tests that exercise the mint pass an override.
|
|
59
|
+
mintFromDeviceSecret:
|
|
60
|
+
overrides.mintFromDeviceSecret
|
|
61
|
+
?? (async () => {
|
|
62
|
+
throw new Error('mintFromDeviceSecret not stubbed');
|
|
63
|
+
}),
|
|
51
64
|
buildBootstrapUrl: (returnTo: string, state: string) =>
|
|
52
65
|
`https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
|
|
53
66
|
} as unknown as OxyServices;
|
|
54
67
|
return { oxy, setTokens };
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
const MINT: DeviceTokenMintResponse = {
|
|
71
|
+
accessToken: 'access-minted',
|
|
72
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
73
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
74
|
+
state: {
|
|
75
|
+
deviceId: 'dev-mint',
|
|
76
|
+
accounts: [{ accountId: 'user-mint', sessionId: 'sess-mint', authuser: 0 }],
|
|
77
|
+
activeAccountId: 'user-mint',
|
|
78
|
+
revision: 2,
|
|
79
|
+
updatedAt: 1_700_000_000_000,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
|
|
84
|
+
function mint401(body: string): Error & { status: number } {
|
|
85
|
+
return Object.assign(new Error(body), { status: 401 });
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
interface DomHandle {
|
|
58
89
|
dom: ColdBootDom;
|
|
59
90
|
navigate: jest.Mock;
|
|
@@ -183,6 +214,189 @@ describe('runSessionColdBoot — step ordering', () => {
|
|
|
183
214
|
});
|
|
184
215
|
});
|
|
185
216
|
|
|
217
|
+
describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
|
|
218
|
+
function makeCredStore(extra: Partial<import('../../session/authStateStore').PersistedAuthState> = {}) {
|
|
219
|
+
const store = createMemoryAuthStateStore();
|
|
220
|
+
return { store, seed: async () => {
|
|
221
|
+
await store.save({
|
|
222
|
+
sessionId: 'sess-old',
|
|
223
|
+
refreshToken: 'r-abcdefghij',
|
|
224
|
+
userId: 'user-old',
|
|
225
|
+
deviceId: 'dev-mint',
|
|
226
|
+
deviceSecret: 'ds-secret-orig',
|
|
227
|
+
...extra,
|
|
228
|
+
});
|
|
229
|
+
} };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
|
|
233
|
+
const { store, seed } = makeCredStore();
|
|
234
|
+
await seed();
|
|
235
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
236
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
237
|
+
const saveSpy = jest.spyOn(store, 'save');
|
|
238
|
+
const onSession = jest.fn();
|
|
239
|
+
|
|
240
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom, onSession });
|
|
241
|
+
|
|
242
|
+
expect(outcome).toEqual({ kind: 'session', via: 'device-secret-mint', session: expect.any(Object) });
|
|
243
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
244
|
+
expect(setTokens).toHaveBeenCalledWith('access-minted');
|
|
245
|
+
// Session identity comes from the mint's authoritative active account.
|
|
246
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({ sessionId: 'sess-mint', userId: 'user-mint', via: 'device-secret-mint' }),
|
|
248
|
+
);
|
|
249
|
+
// Rotation-in-use anti-loss: the store held the NEXT secret before the plant.
|
|
250
|
+
const lastSaveOrder = Math.max(...saveSpy.mock.invocationCallOrder);
|
|
251
|
+
const firstPlantOrder = Math.min(...setTokens.mock.invocationCallOrder);
|
|
252
|
+
expect(lastSaveOrder).toBeLessThan(firstPlantOrder);
|
|
253
|
+
expect(await store.load()).toMatchObject({
|
|
254
|
+
deviceSecret: 'ds-next-secret',
|
|
255
|
+
sessionId: 'sess-mint',
|
|
256
|
+
userId: 'user-mint',
|
|
257
|
+
accessToken: 'access-minted',
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('skips (no mint) when only one of deviceId / deviceSecret is persisted', async () => {
|
|
262
|
+
const store = createMemoryAuthStateStore();
|
|
263
|
+
await store.save({ sessionId: 's', refreshToken: 'r-abcdefghij', userId: 'u', deviceSecret: 'ds-only' });
|
|
264
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
265
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
266
|
+
|
|
267
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
268
|
+
|
|
269
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
270
|
+
// Falls through to the migratory refresh family.
|
|
271
|
+
expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('401 invalid_device_secret → drops the secret (keeps deviceId) and falls through', async () => {
|
|
275
|
+
const { store, seed } = makeCredStore();
|
|
276
|
+
await seed();
|
|
277
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
278
|
+
throw mint401('invalid_device_secret');
|
|
279
|
+
});
|
|
280
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
281
|
+
|
|
282
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
283
|
+
|
|
284
|
+
expect(mintFromDeviceSecret).toHaveBeenCalled();
|
|
285
|
+
// The migratory refresh lane recovers the session.
|
|
286
|
+
expect(outcome.kind).toBe('session');
|
|
287
|
+
expect(outcome).toMatchObject({ via: 'stored-tokens' });
|
|
288
|
+
const persisted = await store.load();
|
|
289
|
+
expect(persisted?.deviceSecret).toBeUndefined();
|
|
290
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('401 no_active_session → keeps the secret, signed out, NEVER falls to the hop', async () => {
|
|
294
|
+
const { store, seed } = makeCredStore();
|
|
295
|
+
await seed();
|
|
296
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
297
|
+
throw mint401('no_active_session');
|
|
298
|
+
});
|
|
299
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
300
|
+
const refreshWithToken = jest.spyOn(oxy, 'refreshWithToken');
|
|
301
|
+
const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
|
|
302
|
+
const domHandle = makeDom();
|
|
303
|
+
const onSignedOut = jest.fn();
|
|
304
|
+
|
|
305
|
+
const outcome = await runSessionColdBoot({
|
|
306
|
+
oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
310
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
311
|
+
// The known-signed-out device is not bounced and no fallback lane runs.
|
|
312
|
+
expect(refreshWithToken).not.toHaveBeenCalled();
|
|
313
|
+
expect(requestWebSession).not.toHaveBeenCalled();
|
|
314
|
+
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
315
|
+
// The secret is retained — the device may sign in again.
|
|
316
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('classifies a PLAIN-OBJECT 401 (no Error prototype) — no_active_session still keeps the secret', async () => {
|
|
320
|
+
const { store, seed } = makeCredStore();
|
|
321
|
+
await seed();
|
|
322
|
+
// Cross-realm / ApiError-shaped throw: not `instanceof Error`. Must still be
|
|
323
|
+
// read as no_active_session — misreading it as a stale secret would drop it.
|
|
324
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
325
|
+
throw { status: 401, message: 'no_active_session' };
|
|
326
|
+
});
|
|
327
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
328
|
+
const onSignedOut = jest.fn();
|
|
329
|
+
|
|
330
|
+
const outcome = await runSessionColdBoot({
|
|
331
|
+
oxy, store, platform: WEB, dom: makeDom().dom, onSignedOut,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
335
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('transient (non-401) mint error → keeps the secret and falls through', async () => {
|
|
339
|
+
const { store, seed } = makeCredStore();
|
|
340
|
+
await seed();
|
|
341
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
342
|
+
throw new Error('network down');
|
|
343
|
+
});
|
|
344
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
345
|
+
|
|
346
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
347
|
+
|
|
348
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'stored-tokens' });
|
|
349
|
+
// The secret survives a transient failure (rotation preserved through refresh).
|
|
350
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('transient mint + cookie-lane hop whose bundle omits a secret → prior secret preserved', async () => {
|
|
354
|
+
const { store, seed } = makeCredStore();
|
|
355
|
+
await seed();
|
|
356
|
+
// Mint down, refresh family down → the chain lands on bootstrap-hop, whose
|
|
357
|
+
// web-session BUNDLE carries no deviceSecret. The still-valid prior secret
|
|
358
|
+
// must survive the store overwrite (else the mint lane is orphaned forever).
|
|
359
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
360
|
+
throw new Error('network down');
|
|
361
|
+
});
|
|
362
|
+
const refreshWithToken = jest.fn(async () => {
|
|
363
|
+
throw new Error('refresh down');
|
|
364
|
+
});
|
|
365
|
+
const requestWebSession = jest.fn(
|
|
366
|
+
async () => ({ reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' }) as WebSessionResult,
|
|
367
|
+
);
|
|
368
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret, refreshWithToken, requestWebSession });
|
|
369
|
+
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
370
|
+
|
|
371
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
|
|
372
|
+
|
|
373
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'bootstrap-hop' });
|
|
374
|
+
const persisted = await store.load();
|
|
375
|
+
expect(persisted?.deviceSecret).toBe('ds-secret-orig');
|
|
376
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('is gated OFF while a #oxy_boot return fragment is present (bootstrap-return wins)', async () => {
|
|
380
|
+
const { store, seed } = makeCredStore();
|
|
381
|
+
await seed();
|
|
382
|
+
const frag = { v: 1, state: 'state-match', reason: 'session', code: 'c'.repeat(24), deviceToken: 'd'.repeat(24) };
|
|
383
|
+
const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
|
|
384
|
+
.toString('base64')
|
|
385
|
+
.replace(/\+/g, '-')
|
|
386
|
+
.replace(/\//g, '_')
|
|
387
|
+
.replace(/=+$/, '');
|
|
388
|
+
const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
|
|
389
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
390
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
391
|
+
|
|
392
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
|
|
393
|
+
|
|
394
|
+
expect(outcome).toMatchObject({ via: 'bootstrap-return' });
|
|
395
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
396
|
+
expect(domHandle.strip).toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
186
400
|
describe('runSessionColdBoot — bootstrap-hop (web)', () => {
|
|
187
401
|
it('same-apex: resolves a session via the inline web-session fetch (no navigation)', async () => {
|
|
188
402
|
const store = createMemoryAuthStateStore();
|
|
@@ -119,6 +119,38 @@ describe('consumeDeviceBootReturn', () => {
|
|
|
119
119
|
expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
it('captures the bundle deviceSecret (phase 2c) and preserves a prior deviceId', async () => {
|
|
123
|
+
const { deps, store } = makeDeps(encodeHash(fragmentObject()), {
|
|
124
|
+
exchangeBootCode: async () => ({ ...BUNDLE, deviceSecret: 'ds-from-exchange' }),
|
|
125
|
+
});
|
|
126
|
+
// A prior deviceId-bearing login persisted a deviceId; the bundle carries no
|
|
127
|
+
// deviceId, so the overwrite must preserve it to keep the mint lane usable.
|
|
128
|
+
await store.save({ sessionId: 'prev', refreshToken: 'r-prevabcdefghij', userId: 'user-1', deviceId: 'dev-prev' });
|
|
129
|
+
|
|
130
|
+
await consumeDeviceBootReturn(deps);
|
|
131
|
+
|
|
132
|
+
const persisted = await store.load();
|
|
133
|
+
expect(persisted?.deviceSecret).toBe('ds-from-exchange');
|
|
134
|
+
expect(persisted?.deviceId).toBe('dev-prev');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('preserves a prior deviceSecret when the bundle omits one (never orphans the mint lane)', async () => {
|
|
138
|
+
const { deps, store } = makeDeps(encodeHash(fragmentObject()));
|
|
139
|
+
await store.save({
|
|
140
|
+
sessionId: 'prev',
|
|
141
|
+
refreshToken: 'r-prevabcdefghij',
|
|
142
|
+
userId: 'user-1',
|
|
143
|
+
deviceId: 'dev-prev',
|
|
144
|
+
deviceSecret: 'ds-still-valid',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await consumeDeviceBootReturn(deps);
|
|
148
|
+
|
|
149
|
+
const persisted = await store.load();
|
|
150
|
+
expect(persisted?.deviceSecret).toBe('ds-still-valid');
|
|
151
|
+
expect(persisted?.deviceId).toBe('dev-prev');
|
|
152
|
+
});
|
|
153
|
+
|
|
122
154
|
it('rejects a state mismatch without persisting or exchanging', async () => {
|
|
123
155
|
const { deps, calls, store } = makeDeps(encodeHash(fragmentObject()), { expectedState: 'WRONG' });
|
|
124
156
|
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
|
package/src/boot/coldBootV2.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { resolveUserId } from '@oxyhq/contracts';
|
|
29
29
|
import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
|
|
30
30
|
import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform';
|
|
31
|
+
import { extractErrorStatus } from '../utils/errorUtils';
|
|
31
32
|
import { KeyManager } from '../crypto/keyManager';
|
|
32
33
|
import { logger } from '../utils/loggerUtils';
|
|
33
34
|
import type { OxyServices } from '../OxyServices';
|
|
@@ -225,6 +226,34 @@ function sessionFromPersisted(state: PersistedAuthState, accessToken: string): D
|
|
|
225
226
|
return { sessionId: state.sessionId, userId: state.userId, accessToken };
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* How a `mintFromDeviceSecret` (phase 2c) call failed, distinguished so the cold
|
|
231
|
+
* boot can react per the transport contract:
|
|
232
|
+
* - `invalid_secret` — the presented secret no longer matches (another tab/
|
|
233
|
+
* device rotated it, or theft divergence). Drop it and fall back.
|
|
234
|
+
* - `no_active_session` — the device is known but has no live session.
|
|
235
|
+
* Authoritative signed-out; keep the secret, do not fall back to the hop.
|
|
236
|
+
* - `transient` — network / 5xx. Keep the secret and let the fallback lanes try.
|
|
237
|
+
*
|
|
238
|
+
* The mint is bearer-less (`skipAuth`), so `HttpService` surfaces the server's
|
|
239
|
+
* 401 body string (`invalid_device_secret` | `no_active_session`) as the thrown
|
|
240
|
+
* error's `message`; any non-401 is transport/server failure.
|
|
241
|
+
*/
|
|
242
|
+
type MintFailure = 'invalid_secret' | 'no_active_session' | 'transient';
|
|
243
|
+
|
|
244
|
+
function classifyMintFailure(error: unknown): MintFailure {
|
|
245
|
+
if (extractErrorStatus(error) === 401) {
|
|
246
|
+
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
247
|
+
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
248
|
+
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
249
|
+
const message = (error as { message?: unknown })?.message;
|
|
250
|
+
return typeof message === 'string' && message.includes('no_active_session')
|
|
251
|
+
? 'no_active_session'
|
|
252
|
+
: 'invalid_secret';
|
|
253
|
+
}
|
|
254
|
+
return 'transient';
|
|
255
|
+
}
|
|
256
|
+
|
|
228
257
|
/**
|
|
229
258
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
230
259
|
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
@@ -243,9 +272,79 @@ export async function runSessionColdBoot(
|
|
|
243
272
|
// they cannot leak across boots or break under bundler re-evaluation.
|
|
244
273
|
let signedOutReason: SignedOutReason = 'no_session';
|
|
245
274
|
let navigating = false;
|
|
275
|
+
// Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
|
|
276
|
+
// device is authoritatively signed out, so the migratory fallback lanes below
|
|
277
|
+
// (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
|
|
278
|
+
// there is no session and must not bounce a known-signed-out device.
|
|
279
|
+
let deviceKnownSignedOut = false;
|
|
246
280
|
|
|
247
281
|
const steps: Array<ColdBootStep<DeviceBootSession>> = [];
|
|
248
282
|
|
|
283
|
+
// 0. device-secret-mint (phase 2c) — the zero-cookie fast path. When the
|
|
284
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
285
|
+
// a single bearer-less POST (no cookie, no navigation). FIRST in the chain
|
|
286
|
+
// so it wins over the migratory cookie lanes below. Gated OFF while a
|
|
287
|
+
// #oxy_boot return fragment is present so `bootstrap-return` still consumes
|
|
288
|
+
// + strips it first (a device holding a secret never triggers that hop, so
|
|
289
|
+
// this only defers a rare stale/forged fragment). The rest of the chain
|
|
290
|
+
// stays as the additive migratory fallback for devices not yet on the secret.
|
|
291
|
+
steps.push({
|
|
292
|
+
id: 'device-secret-mint',
|
|
293
|
+
enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
|
|
294
|
+
run: async () => {
|
|
295
|
+
const persisted = await store.load();
|
|
296
|
+
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
297
|
+
return { kind: 'skip' };
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
301
|
+
// Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
|
|
302
|
+
// fields, + the server's authoritative active account) BEFORE planting
|
|
303
|
+
// the minted access token, so a multi-tab race that rotates again can
|
|
304
|
+
// never strand this tab with a superseded secret.
|
|
305
|
+
const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
|
|
306
|
+
const next: PersistedAuthState = {
|
|
307
|
+
...persisted,
|
|
308
|
+
deviceId: mint.state.deviceId,
|
|
309
|
+
deviceSecret: mint.nextDeviceSecret,
|
|
310
|
+
accessToken: mint.accessToken,
|
|
311
|
+
expiresAt: mint.expiresAt,
|
|
312
|
+
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
313
|
+
};
|
|
314
|
+
await store.save(next);
|
|
315
|
+
oxy.setTokens(mint.accessToken);
|
|
316
|
+
return {
|
|
317
|
+
kind: 'session',
|
|
318
|
+
session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
|
|
319
|
+
};
|
|
320
|
+
} catch (error) {
|
|
321
|
+
const failure = classifyMintFailure(error);
|
|
322
|
+
if (failure === 'invalid_secret') {
|
|
323
|
+
// Stale/diverged secret — drop it so the mint lane stops firing, then
|
|
324
|
+
// fall through to the migratory refresh/cookie lanes. Setting it
|
|
325
|
+
// undefined drops the key on the store's JSON serialization, and the
|
|
326
|
+
// mint guard treats undefined as absent.
|
|
327
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
328
|
+
return { kind: 'skip' };
|
|
329
|
+
}
|
|
330
|
+
if (failure === 'no_active_session') {
|
|
331
|
+
// Device known, no live session — authoritative signed-out. KEEP the
|
|
332
|
+
// secret and stop the chain (do not bounce a known-signed-out device).
|
|
333
|
+
deviceKnownSignedOut = true;
|
|
334
|
+
signedOutReason = 'no_session';
|
|
335
|
+
return { kind: 'skip' };
|
|
336
|
+
}
|
|
337
|
+
// Transient (network / 5xx): keep the secret, let the fallback lanes try.
|
|
338
|
+
logger.debug(
|
|
339
|
+
'device-secret mint failed (transient) — keeping secret, falling back',
|
|
340
|
+
{ component: 'coldBootV2', method: 'device-secret-mint' },
|
|
341
|
+
error,
|
|
342
|
+
);
|
|
343
|
+
return { kind: 'skip' };
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
249
348
|
// 1. bootstrap-return (web) — consume a #oxy_boot fragment.
|
|
250
349
|
steps.push({
|
|
251
350
|
id: 'bootstrap-return',
|
|
@@ -279,6 +378,7 @@ export async function runSessionColdBoot(
|
|
|
279
378
|
// 2. stored-tokens — warm-plant or rotate the persisted refresh family.
|
|
280
379
|
steps.push({
|
|
281
380
|
id: 'stored-tokens',
|
|
381
|
+
enabled: () => !deviceKnownSignedOut,
|
|
282
382
|
run: async () => {
|
|
283
383
|
const persisted = await store.load();
|
|
284
384
|
if (!persisted) {
|
|
@@ -308,7 +408,7 @@ export async function runSessionColdBoot(
|
|
|
308
408
|
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
309
409
|
steps.push({
|
|
310
410
|
id: 'shared-key-signin',
|
|
311
|
-
enabled: () => isNative,
|
|
411
|
+
enabled: () => isNative && !deviceKnownSignedOut,
|
|
312
412
|
run: async () => {
|
|
313
413
|
const session = await oxy.signInWithSharedIdentity();
|
|
314
414
|
if (!session?.accessToken) {
|
|
@@ -345,7 +445,7 @@ export async function runSessionColdBoot(
|
|
|
345
445
|
// 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
|
|
346
446
|
steps.push({
|
|
347
447
|
id: 'bootstrap-hop',
|
|
348
|
-
enabled: () => isWeb,
|
|
448
|
+
enabled: () => isWeb && !deviceKnownSignedOut,
|
|
349
449
|
run: async () => {
|
|
350
450
|
const pageHost = dom.getLocationHostname();
|
|
351
451
|
let apiHost: string | null = null;
|
|
@@ -378,6 +478,21 @@ export async function runSessionColdBoot(
|
|
|
378
478
|
accessToken: bundle.accessToken,
|
|
379
479
|
expiresAt: bundle.expiresAt,
|
|
380
480
|
};
|
|
481
|
+
// Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
|
|
482
|
+
// but NOT a deviceId. Persist the secret and carry any prior deviceId
|
|
483
|
+
// (from a deviceId-bearing login lane) forward so the mint lane stays
|
|
484
|
+
// usable — this overwrite must not orphan it.
|
|
485
|
+
const prior = await store.load();
|
|
486
|
+
if (prior?.deviceId) {
|
|
487
|
+
next.deviceId = prior.deviceId;
|
|
488
|
+
}
|
|
489
|
+
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
490
|
+
// prior one when the bundle omits it — this lane also runs as the
|
|
491
|
+
// TRANSIENT-mint fallback, and must not orphan a still-valid secret.
|
|
492
|
+
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
493
|
+
if (carriedSecret) {
|
|
494
|
+
next.deviceSecret = carriedSecret;
|
|
495
|
+
}
|
|
381
496
|
await store.save(next);
|
|
382
497
|
oxy.setTokens(bundle.accessToken);
|
|
383
498
|
return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
|
|
@@ -178,6 +178,21 @@ export async function consumeDeviceBootReturn(
|
|
|
178
178
|
accessToken: bundle.accessToken,
|
|
179
179
|
expiresAt: bundle.expiresAt,
|
|
180
180
|
};
|
|
181
|
+
// Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
|
|
182
|
+
// but NOT a deviceId. Persist the secret, and carry any prior deviceId
|
|
183
|
+
// forward (from a deviceId-bearing login lane) so the pair stays usable by
|
|
184
|
+
// the zero-cookie mint — an overwrite here must not orphan the mint lane.
|
|
185
|
+
const prior = await deps.store.load();
|
|
186
|
+
if (prior?.deviceId) {
|
|
187
|
+
next.deviceId = prior.deviceId;
|
|
188
|
+
}
|
|
189
|
+
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
190
|
+
// prior one when the bundle omits it so a cookie-lane boot can never orphan
|
|
191
|
+
// a still-valid secret captured by an earlier login lane.
|
|
192
|
+
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
193
|
+
if (carriedSecret) {
|
|
194
|
+
next.deviceSecret = carriedSecret;
|
|
195
|
+
}
|
|
181
196
|
await deps.store.save(next);
|
|
182
197
|
deps.plantAccessToken(bundle.accessToken);
|
|
183
198
|
return {
|
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
authTokenBundleSchema,
|
|
20
20
|
tokenRefreshResponseSchema,
|
|
21
21
|
deviceTokenIssueResponseSchema,
|
|
22
|
+
deviceTokenMintResponseSchema,
|
|
22
23
|
webSessionResultSchema,
|
|
23
24
|
safeParseContract,
|
|
24
25
|
type AuthTokenBundle,
|
|
26
|
+
type DeviceTokenMintResponse,
|
|
25
27
|
type TokenRefreshResponse,
|
|
26
28
|
type WebSessionResult,
|
|
27
29
|
} from '@oxyhq/contracts';
|
|
@@ -132,6 +134,42 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
132
134
|
}
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
|
|
139
|
+
* `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
|
|
140
|
+
* possession of the secret IS the device-ownership proof. Returns a fresh
|
|
141
|
+
* short access token for the device's active account plus `nextDeviceSecret`
|
|
142
|
+
* (rotation-in-use) and the projected device-session `state`.
|
|
143
|
+
*
|
|
144
|
+
* `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
|
|
145
|
+
* a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
|
|
146
|
+
* retry dance (which would pointlessly rotate the refresh family). The cold
|
|
147
|
+
* boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
|
|
148
|
+
* decide whether to drop the secret and fall back or resolve signed-out.
|
|
149
|
+
*
|
|
150
|
+
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
151
|
+
*/
|
|
152
|
+
async mintFromDeviceSecret(
|
|
153
|
+
deviceId: string,
|
|
154
|
+
deviceSecret: string,
|
|
155
|
+
): Promise<DeviceTokenMintResponse> {
|
|
156
|
+
try {
|
|
157
|
+
const res = await this.makeRequest<unknown>(
|
|
158
|
+
'POST',
|
|
159
|
+
'/session/device/token',
|
|
160
|
+
{ deviceId, deviceSecret },
|
|
161
|
+
{ cache: false, skipAuth: true },
|
|
162
|
+
);
|
|
163
|
+
const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
|
|
164
|
+
if (!parsed) {
|
|
165
|
+
throw new Error('session/device/token returned an unexpected response shape');
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw this.handleError(error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
135
173
|
/**
|
|
136
174
|
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
137
175
|
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* and asserts each method's route/shape, contract validation, and the
|
|
4
4
|
* `skipAuth` flag on the refresh call.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
AuthTokenBundle,
|
|
8
|
+
DeviceTokenMintResponse,
|
|
9
|
+
TokenRefreshResponse,
|
|
10
|
+
WebSessionResult,
|
|
11
|
+
} from '@oxyhq/contracts';
|
|
7
12
|
import { OxyServices } from '../../OxyServices';
|
|
8
13
|
|
|
9
14
|
const BUNDLE: AuthTokenBundle = {
|
|
@@ -96,6 +101,44 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
96
101
|
});
|
|
97
102
|
});
|
|
98
103
|
|
|
104
|
+
describe('mintFromDeviceSecret', () => {
|
|
105
|
+
const MINT: DeviceTokenMintResponse = {
|
|
106
|
+
accessToken: 'access-minted',
|
|
107
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
108
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
109
|
+
state: {
|
|
110
|
+
deviceId: 'dev-1',
|
|
111
|
+
accounts: [{ accountId: 'user-1', sessionId: 'sess-1', authuser: 0 }],
|
|
112
|
+
activeAccountId: 'user-1',
|
|
113
|
+
revision: 3,
|
|
114
|
+
updatedAt: 1_700_000_000_000,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
it('POSTs deviceId + deviceSecret with skipAuth and returns the validated mint', async () => {
|
|
119
|
+
makeRequest.mockResolvedValueOnce(MINT);
|
|
120
|
+
const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
|
|
121
|
+
expect(result).toEqual(MINT);
|
|
122
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
123
|
+
'POST',
|
|
124
|
+
'/session/device/token',
|
|
125
|
+
{ deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
|
|
126
|
+
{ cache: false, skipAuth: true },
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws on an unexpected response shape', async () => {
|
|
131
|
+
makeRequest.mockResolvedValueOnce({ accessToken: 'a', expiresAt: 'b' });
|
|
132
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('propagates a rejected request (e.g. 401 invalid_device_secret)', async () => {
|
|
136
|
+
const err = Object.assign(new Error('invalid_device_secret'), { status: 401 });
|
|
137
|
+
makeRequest.mockRejectedValueOnce(err);
|
|
138
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('invalid_device_secret');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
99
142
|
describe('buildBootstrapUrl', () => {
|
|
100
143
|
it('builds the bootstrap URL with encoded params', () => {
|
|
101
144
|
const url = oxy.buildBootstrapUrl('https://accounts.oxy.so/home', 'st-1');
|