@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.
@@ -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.0.0",
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.11.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 { AuthTokenBundle, TokenRefreshResponse, WebSessionResult } from '@oxyhq/contracts';
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' });
@@ -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 { AuthTokenBundle, TokenRefreshResponse, WebSessionResult } from '@oxyhq/contracts';
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');