@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.
@@ -65,6 +65,33 @@ describe('createWebAuthStateStore', () => {
65
65
  expect(await store.load()).toEqual(SAMPLE);
66
66
  });
67
67
 
68
+ it('round-trips the optional phase-2c deviceId + deviceSecret', async () => {
69
+ installLocalStorage(makeFakeStorage());
70
+ const store = createWebAuthStateStore();
71
+ const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
72
+
73
+ await store.save(withCreds);
74
+ const loaded = await store.load();
75
+ expect(loaded?.deviceId).toBe('dev-abc');
76
+ expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
77
+ expect(loaded).toEqual(withCreds);
78
+ });
79
+
80
+ it('deserializes a legacy blob with no device credentials (additive — fields absent)', async () => {
81
+ const storage = makeFakeStorage();
82
+ installLocalStorage(storage);
83
+ const store = createWebAuthStateStore();
84
+
85
+ storage.setItem(
86
+ AUTH_STATE_STORAGE_KEY,
87
+ JSON.stringify({ sessionId: 's-1', refreshToken: 'r-abcdefghijklmnop', userId: 'u-1' }),
88
+ );
89
+ const loaded = await store.load();
90
+ expect(loaded).not.toBeNull();
91
+ expect(loaded && 'deviceId' in loaded).toBe(false);
92
+ expect(loaded && 'deviceSecret' in loaded).toBe(false);
93
+ });
94
+
68
95
  it('clear() wipes the session but the deviceToken survives', async () => {
69
96
  installLocalStorage(makeFakeStorage());
70
97
  const store = createWebAuthStateStore();
@@ -53,6 +53,20 @@ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
53
53
  });
54
54
  });
55
55
 
56
+ it('carries the persisted deviceId + deviceSecret (phase 2c) forward across a rotation', async () => {
57
+ const store = createMemoryAuthStateStore();
58
+ await store.save({ ...STORED, deviceId: 'dev-mint', deviceSecret: 'ds-secret-orig' });
59
+ const { oxy } = makeOxy();
60
+
61
+ await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
62
+
63
+ const persisted = await store.load();
64
+ expect(persisted?.deviceId).toBe('dev-mint');
65
+ expect(persisted?.deviceSecret).toBe('ds-secret-orig');
66
+ // The refresh family head still rotated.
67
+ expect(persisted?.refreshToken).toBe('refresh-new-abcdefghij');
68
+ });
69
+
56
70
  it('clears the store on a family-revoked (401) error', async () => {
57
71
  const store = createMemoryAuthStateStore();
58
72
  await store.save(STORED);
@@ -39,6 +39,26 @@ export interface PersistedAuthState {
39
39
  refreshToken: string;
40
40
  userId: string;
41
41
  deviceToken?: string;
42
+ /**
43
+ * The stable device identifier this session is bound to (phase 2c —
44
+ * zero-cookie transport). Persisted alongside {@link deviceSecret} because the
45
+ * `POST /session/device/token` mint presents BOTH — the secret is the proof,
46
+ * the deviceId selects the device doc. Sourced from the lanes that carry it
47
+ * (password login / 2FA / QR claim / challenge verify); the cookie-bootstrap
48
+ * lanes (`AuthTokenBundle`) omit it and preserve any prior value. Additive: a
49
+ * blob without it simply never takes the mint lane and falls back to the
50
+ * refresh family.
51
+ */
52
+ deviceId?: string;
53
+ /**
54
+ * The rotating device secret (phase 2c — zero-cookie transport). Possession of
55
+ * it mints a short access token for the device's active account via
56
+ * `POST /session/device/token`, replacing the cookie lane. Rotated in-use: the
57
+ * mint returns `nextDeviceSecret`, which the cold boot persists BEFORE planting
58
+ * the minted access token (multi-tab anti-loss). Additive and optional — same
59
+ * XSS risk profile as the already-persisted `refreshToken`.
60
+ */
61
+ deviceSecret?: string;
42
62
  /** Optional warm-boot access token (short-lived; see interface docs). */
43
63
  accessToken?: string;
44
64
  /** Optional warm-boot access-token expiry, ISO-8601. */
@@ -130,6 +150,12 @@ function deserialize(raw: string | null): PersistedAuthState | null {
130
150
  if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
131
151
  state.deviceToken = candidate.deviceToken;
132
152
  }
153
+ if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
154
+ state.deviceId = candidate.deviceId;
155
+ }
156
+ if (typeof candidate.deviceSecret === 'string' && candidate.deviceSecret.length > 0) {
157
+ state.deviceSecret = candidate.deviceSecret;
158
+ }
133
159
  if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
134
160
  state.accessToken = candidate.accessToken;
135
161
  }
@@ -134,6 +134,15 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
134
134
  if (persisted.deviceToken) {
135
135
  next.deviceToken = persisted.deviceToken;
136
136
  }
137
+ // The refresh response carries no device credentials — carry the persisted
138
+ // deviceId/deviceSecret (phase 2c) forward so a rotation never drops the
139
+ // zero-cookie mint lane (mirrors the deviceToken preservation above).
140
+ if (persisted.deviceId) {
141
+ next.deviceId = persisted.deviceId;
142
+ }
143
+ if (persisted.deviceSecret) {
144
+ next.deviceSecret = persisted.deviceSecret;
145
+ }
137
146
  await store.save(next);
138
147
  return rotated.accessToken;
139
148
  } catch (error) {