@oxyhq/core 12.10.6 → 12.11.1

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.
Files changed (139) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  4. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  5. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  6. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  7. package/dist/cjs/i18n/locales/en-US.json +13 -4
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  9. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  10. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  11. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  12. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  16. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  24. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  25. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  26. package/dist/cjs/index.js +31 -5
  27. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  28. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  29. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  30. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  32. package/dist/cjs/mixins/index.js +5 -0
  33. package/dist/cjs/session/SessionClient.js +40 -4
  34. package/dist/cjs/session/accountDialogController.js +288 -41
  35. package/dist/cjs/session/identityPin.js +256 -0
  36. package/dist/cjs/session/identitySession.js +135 -0
  37. package/dist/cjs/session/projectSessionState.js +54 -15
  38. package/dist/cjs/session/refresh.js +73 -8
  39. package/dist/cjs/utils/commonsDelivery.js +59 -0
  40. package/dist/cjs/utils/oauthPkce.js +33 -3
  41. package/dist/esm/.tsbuildinfo +1 -1
  42. package/dist/esm/HttpService.js +66 -6
  43. package/dist/esm/boot/sessionColdBoot.js +142 -37
  44. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  45. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  46. package/dist/esm/i18n/locales/de-DE.json +13 -1
  47. package/dist/esm/i18n/locales/en-US.json +13 -4
  48. package/dist/esm/i18n/locales/es-ES.json +13 -4
  49. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  50. package/dist/esm/i18n/locales/it-IT.json +13 -1
  51. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  52. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  53. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  54. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  55. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  56. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  57. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  58. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  61. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  62. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  63. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  64. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  65. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  66. package/dist/esm/index.js +14 -1
  67. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  68. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  69. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  70. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  71. package/dist/esm/mixins/OxyServices.user.js +1 -0
  72. package/dist/esm/mixins/index.js +5 -0
  73. package/dist/esm/session/SessionClient.js +40 -4
  74. package/dist/esm/session/accountDialogController.js +288 -41
  75. package/dist/esm/session/identityPin.js +249 -0
  76. package/dist/esm/session/identitySession.js +131 -0
  77. package/dist/esm/session/projectSessionState.js +54 -15
  78. package/dist/esm/session/refresh.js +73 -8
  79. package/dist/esm/utils/commonsDelivery.js +54 -0
  80. package/dist/esm/utils/oauthPkce.js +31 -2
  81. package/dist/types/.tsbuildinfo +1 -1
  82. package/dist/types/HttpService.d.ts +18 -1
  83. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  84. package/dist/types/index.d.ts +12 -4
  85. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  86. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  87. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  89. package/dist/types/mixins/index.d.ts +2 -1
  90. package/dist/types/session/SessionClient.d.ts +28 -0
  91. package/dist/types/session/accountDialogController.d.ts +187 -9
  92. package/dist/types/session/identityPin.d.ts +87 -0
  93. package/dist/types/session/identitySession.d.ts +105 -0
  94. package/dist/types/session/projectSessionState.d.ts +21 -19
  95. package/dist/types/session/refresh.d.ts +32 -1
  96. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  97. package/dist/types/utils/oauthPkce.d.ts +21 -1
  98. package/package.json +3 -3
  99. package/src/HttpService.ts +67 -6
  100. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  101. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  102. package/src/boot/sessionColdBoot.ts +183 -37
  103. package/src/i18n/locales/ar-SA.json +13 -1
  104. package/src/i18n/locales/ca-ES.json +13 -1
  105. package/src/i18n/locales/de-DE.json +13 -1
  106. package/src/i18n/locales/en-US.json +13 -4
  107. package/src/i18n/locales/es-ES.json +13 -4
  108. package/src/i18n/locales/fr-FR.json +13 -1
  109. package/src/i18n/locales/it-IT.json +13 -1
  110. package/src/i18n/locales/ja-JP.json +13 -1
  111. package/src/i18n/locales/ko-KR.json +13 -1
  112. package/src/i18n/locales/pt-PT.json +13 -1
  113. package/src/i18n/locales/zh-CN.json +13 -1
  114. package/src/index.ts +49 -0
  115. package/src/mixins/OxyServices.auth.ts +464 -24
  116. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  117. package/src/mixins/OxyServices.notifications.ts +142 -0
  118. package/src/mixins/OxyServices.privacy.ts +6 -0
  119. package/src/mixins/OxyServices.user.ts +3 -0
  120. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  121. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  122. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  123. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  124. package/src/mixins/index.ts +6 -0
  125. package/src/session/SessionClient.ts +57 -4
  126. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  127. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  128. package/src/session/__tests__/identityPin.test.ts +131 -0
  129. package/src/session/__tests__/identitySession.test.ts +194 -0
  130. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  131. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  132. package/src/session/accountDialogController.ts +435 -44
  133. package/src/session/identityPin.ts +310 -0
  134. package/src/session/identitySession.ts +206 -0
  135. package/src/session/projectSessionState.ts +61 -13
  136. package/src/session/refresh.ts +103 -9
  137. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  138. package/src/utils/commonsDelivery.ts +132 -0
  139. package/src/utils/oauthPkce.ts +56 -3
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Cold boot in IDENTITY mode — the identity vault's boot.
3
+ *
4
+ * Invariants under test:
5
+ * - a persisted warm token that belongs to ANOTHER account is never planted;
6
+ * the boot falls through to the PINNED mint instead;
7
+ * - the mint carries the pinned `accountId`, and the resolved session is the
8
+ * pinned account even while the device is switched elsewhere;
9
+ * - `identity-key-signin` (the PRIMARY local key) replaces `shared-key-signin`
10
+ * (the CROSS-APP shared slot) — and the reverse in account mode;
11
+ * - with no verified pin the (unpinned) mint lane is skipped entirely rather
12
+ * than adopting whichever account the device is currently switched to.
13
+ */
14
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
15
+ import type { OxyServices } from '../../OxyServices';
16
+ import type { SessionLoginResponse } from '../../models/session';
17
+ import type { AuthChallenge } from '../../crypto/signatureService';
18
+ import { runSessionColdBoot } from '../sessionColdBoot';
19
+ import type { DeviceSecretMintOutcome } from '../../session/refresh';
20
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
21
+ import { createMemoryIdentityPinStore, type IdentityPin } from '../../session/identityPin';
22
+ import type { IdentityBinding } from '../../session/identitySession';
23
+
24
+ const PUBLIC_KEY = `02${'a'.repeat(64)}`;
25
+ const OTHER_PUBLIC_KEY = `03${'b'.repeat(64)}`;
26
+ const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'vault-user' };
27
+
28
+ const NATIVE = { isWeb: false, isNative: true };
29
+
30
+ /** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
31
+ function jwtFor(accountId: string): string {
32
+ const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
33
+ return `h.${payload}.s`;
34
+ }
35
+
36
+ /** Comfortably beyond the 60s refresh lead window. */
37
+ const farFuture = (): string => new Date(Date.now() + 3_600_000).toISOString();
38
+
39
+ function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
40
+ let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
41
+ return (mint) => {
42
+ if (!inFlight) {
43
+ inFlight = mint().finally(() => {
44
+ inFlight = null;
45
+ });
46
+ }
47
+ return inFlight;
48
+ };
49
+ }
50
+
51
+ /** The device is switched to `other-user`; the pinned account is still a member. */
52
+ const MINT_WHILE_SWITCHED: DeviceTokenMintResponse = {
53
+ accessToken: jwtFor('vault-user'),
54
+ expiresAt: '2030-01-01T00:00:00.000Z',
55
+ nextDeviceSecret: 'ds-next-secret',
56
+ state: {
57
+ deviceId: 'dev-1',
58
+ accounts: [
59
+ { accountId: 'vault-user', sessionId: 'sess-vault-new', authuser: 0 },
60
+ { accountId: 'other-user', sessionId: 'sess-other', authuser: 1 },
61
+ ],
62
+ activeAccountId: 'other-user',
63
+ revision: 9,
64
+ updatedAt: 1_700_000_000_000,
65
+ },
66
+ };
67
+
68
+ const IDENTITY_SESSION: SessionLoginResponse = {
69
+ sessionId: 'sess-identity',
70
+ deviceId: 'dev-1',
71
+ expiresAt: '2030-01-01T00:00:00.000Z',
72
+ accessToken: 'access-identity',
73
+ deviceSecret: 'ds-identity',
74
+ user: { id: 'vault-user', username: 'vault', name: {} },
75
+ };
76
+
77
+ const SHARED_SESSION: SessionLoginResponse = {
78
+ sessionId: 'sess-shared',
79
+ deviceId: 'dev-1',
80
+ expiresAt: '2030-01-01T00:00:00.000Z',
81
+ accessToken: 'access-shared',
82
+ user: { id: 'shared-user', username: 'shared', name: {} },
83
+ };
84
+
85
+ interface OxyOverrides {
86
+ mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
87
+ signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
88
+ requestChallenge?: OxyServices['requestChallenge'];
89
+ verifyChallenge?: OxyServices['verifyChallenge'];
90
+ }
91
+
92
+ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
93
+ const setTokens = jest.fn();
94
+ const oxy = {
95
+ getBaseURL: () => 'https://api.oxy.so',
96
+ setTokens,
97
+ mintFromDeviceSecret:
98
+ overrides.mintFromDeviceSecret
99
+ ?? (async () => {
100
+ throw new Error('mintFromDeviceSecret not stubbed');
101
+ }),
102
+ signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
103
+ requestChallenge:
104
+ overrides.requestChallenge
105
+ ?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
106
+ verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
107
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
108
+ } as unknown as OxyServices;
109
+ return { oxy, setTokens };
110
+ }
111
+
112
+ function signWith(publicKey: string): (challenge: string) => Promise<AuthChallenge> {
113
+ return async (challenge) => ({
114
+ challenge: `sig(${challenge})`,
115
+ publicKey,
116
+ timestamp: 1_700_000_000_000,
117
+ });
118
+ }
119
+
120
+ /** An identity binding, optionally pre-seeded with a pin. */
121
+ async function makeBinding(options: { pin?: IdentityPin; publicKey?: string } = {}): Promise<IdentityBinding> {
122
+ const publicKey = options.publicKey ?? PUBLIC_KEY;
123
+ const pinStore = createMemoryIdentityPinStore();
124
+ if (options.pin) {
125
+ await pinStore.save(options.pin);
126
+ }
127
+ return { pinStore, readPublicKey: async () => publicKey, signChallenge: signWith(publicKey) };
128
+ }
129
+
130
+ async function seedStore(extra: Partial<PersistedAuthState> = {}) {
131
+ const store = createMemoryAuthStateStore();
132
+ await store.save({
133
+ sessionId: 'sess-vault',
134
+ userId: 'vault-user',
135
+ deviceId: 'dev-1',
136
+ deviceSecret: 'ds-secret-orig',
137
+ ...extra,
138
+ });
139
+ return store;
140
+ }
141
+
142
+ describe('runSessionColdBoot — identity mode: warm-token-plant', () => {
143
+ it('plants a warm token that belongs to the pinned account', async () => {
144
+ const store = await seedStore({ accessToken: jwtFor('vault-user'), expiresAt: farFuture() });
145
+ const { oxy, setTokens } = makeOxy();
146
+
147
+ const outcome = await runSessionColdBoot({
148
+ oxy,
149
+ store,
150
+ platform: NATIVE,
151
+ sessionMode: 'identity',
152
+ identity: await makeBinding({ pin: PIN }),
153
+ });
154
+
155
+ expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
156
+ expect(setTokens).toHaveBeenCalledWith(jwtFor('vault-user'));
157
+ });
158
+
159
+ it('does NOT plant a warm token minted for ANOTHER account — falls through to the pinned mint', async () => {
160
+ // The durable drift: an account-mode re-mint rewrote the persisted session to
161
+ // `other-user` while the device was switched. Identity mode must reject it.
162
+ const store = await seedStore({
163
+ sessionId: 'sess-other',
164
+ userId: 'other-user',
165
+ accessToken: jwtFor('other-user'),
166
+ expiresAt: farFuture(),
167
+ });
168
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
169
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
170
+
171
+ const outcome = await runSessionColdBoot({
172
+ oxy,
173
+ store,
174
+ platform: NATIVE,
175
+ sessionMode: 'identity',
176
+ identity: await makeBinding({ pin: PIN }),
177
+ });
178
+
179
+ expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
180
+ expect(outcome).toMatchObject({
181
+ kind: 'session',
182
+ via: 'device-secret-mint',
183
+ session: { sessionId: 'sess-vault-new', userId: 'vault-user' },
184
+ });
185
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-1', 'ds-secret-orig', {
186
+ accountId: 'vault-user',
187
+ });
188
+ });
189
+
190
+ it('does NOT plant a warm token whose persisted identity matches but whose CLAIM does not', async () => {
191
+ // Belt-and-braces: `userId` says the pinned account, the token itself does not.
192
+ const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
193
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
194
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
195
+
196
+ const outcome = await runSessionColdBoot({
197
+ oxy,
198
+ store,
199
+ platform: NATIVE,
200
+ sessionMode: 'identity',
201
+ identity: await makeBinding({ pin: PIN }),
202
+ });
203
+
204
+ expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
205
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
206
+ });
207
+
208
+ it('plants a warm token in ACCOUNT mode regardless of which account it belongs to (unchanged)', async () => {
209
+ const store = await seedStore({
210
+ sessionId: 'sess-other',
211
+ userId: 'other-user',
212
+ accessToken: jwtFor('other-user'),
213
+ expiresAt: farFuture(),
214
+ });
215
+ const { oxy, setTokens } = makeOxy();
216
+
217
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
218
+
219
+ expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
220
+ expect(setTokens).toHaveBeenCalledWith(jwtFor('other-user'));
221
+ });
222
+ });
223
+
224
+ describe('runSessionColdBoot — identity mode: device-secret-mint', () => {
225
+ it('skips the mint entirely when there is no verified pin', async () => {
226
+ const store = await seedStore();
227
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
228
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
229
+
230
+ const outcome = await runSessionColdBoot({
231
+ oxy,
232
+ store,
233
+ platform: NATIVE,
234
+ sessionMode: 'identity',
235
+ identity: await makeBinding(), // no pin seeded
236
+ });
237
+
238
+ // An unpinned mint would have resolved `other-user` — never acceptable here.
239
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
240
+ expect(outcome).toMatchObject({
241
+ kind: 'session',
242
+ via: 'identity-key-signin',
243
+ session: { userId: 'vault-user' },
244
+ });
245
+ });
246
+
247
+ it('keeps the device secret and re-establishes when the pinned account left the device', async () => {
248
+ const store = await seedStore();
249
+ const mintFromDeviceSecret = jest.fn(async () => {
250
+ throw Object.assign(new Error('account_not_on_device'), { status: 401 });
251
+ });
252
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
253
+
254
+ const outcome = await runSessionColdBoot({
255
+ oxy,
256
+ store,
257
+ platform: NATIVE,
258
+ sessionMode: 'identity',
259
+ identity: await makeBinding({ pin: PIN }),
260
+ });
261
+
262
+ expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
263
+ // The healthy credential was never dropped over a stale identity binding
264
+ // (the identity sign-in then overwrote it with the freshly minted one).
265
+ expect(await store.load()).toMatchObject({ deviceSecret: 'ds-identity' });
266
+ });
267
+
268
+ it('clears a pin whose key no longer matches, then re-establishes from the current key', async () => {
269
+ const store = await seedStore();
270
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
271
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
272
+ const pinStore = createMemoryIdentityPinStore();
273
+ await pinStore.save(PIN);
274
+
275
+ const outcome = await runSessionColdBoot({
276
+ oxy,
277
+ store,
278
+ platform: NATIVE,
279
+ sessionMode: 'identity',
280
+ identity: {
281
+ pinStore,
282
+ readPublicKey: async () => OTHER_PUBLIC_KEY,
283
+ signChallenge: signWith(OTHER_PUBLIC_KEY),
284
+ },
285
+ });
286
+
287
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
288
+ expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
289
+ expect(await pinStore.load()).toEqual({ publicKey: OTHER_PUBLIC_KEY, accountId: 'vault-user' });
290
+ });
291
+ });
292
+
293
+ describe('runSessionColdBoot — identity-key-signin vs shared-key-signin', () => {
294
+ it('identity mode runs identity-key-signin and NEVER the shared-keychain lane', async () => {
295
+ const store = createMemoryAuthStateStore(); // no mint credential
296
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
297
+ const requestChallenge = jest.fn(async () => ({
298
+ challenge: 'chal-1',
299
+ expiresAt: '2030-01-01T00:00:00.000Z',
300
+ }));
301
+ const verifyChallenge = jest.fn(async () => IDENTITY_SESSION);
302
+ const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge, verifyChallenge });
303
+ const binding = await makeBinding();
304
+ const onSession = jest.fn();
305
+
306
+ const outcome = await runSessionColdBoot({
307
+ oxy,
308
+ store,
309
+ platform: NATIVE,
310
+ sessionMode: 'identity',
311
+ identity: binding,
312
+ onSession,
313
+ });
314
+
315
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
316
+ expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
317
+ expect(onSession).toHaveBeenCalledWith(
318
+ expect.objectContaining({
319
+ sessionId: 'sess-identity',
320
+ userId: 'vault-user',
321
+ via: 'identity-key-signin',
322
+ }),
323
+ );
324
+ // Boot-path network calls stay single-attempt.
325
+ expect(requestChallenge).toHaveBeenCalledWith(PUBLIC_KEY, { retry: false });
326
+ expect(verifyChallenge).toHaveBeenCalledWith(
327
+ PUBLIC_KEY,
328
+ 'chal-1',
329
+ 'sig(chal-1)',
330
+ 1_700_000_000_000,
331
+ undefined,
332
+ undefined,
333
+ { retry: false },
334
+ );
335
+ // The pin and the fast-lane credential are both written for the next boot.
336
+ expect(await binding.pinStore.load()).toEqual(PIN);
337
+ expect(await store.load()).toMatchObject({ userId: 'vault-user', deviceSecret: 'ds-identity' });
338
+ });
339
+
340
+ it('account mode runs shared-key-signin and NEVER the identity lane (unchanged)', async () => {
341
+ const store = createMemoryAuthStateStore();
342
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
343
+ const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
344
+ const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge });
345
+
346
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
347
+
348
+ expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
349
+ expect(signInWithSharedIdentity).toHaveBeenCalledWith({ requestOptions: { retry: false } });
350
+ expect(requestChallenge).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it('is offline-gated like every other network step', async () => {
354
+ const store = createMemoryAuthStateStore();
355
+ const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
356
+ const { oxy } = makeOxy({ requestChallenge });
357
+ const onSignedOut = jest.fn();
358
+
359
+ const outcome = await runSessionColdBoot({
360
+ oxy,
361
+ store,
362
+ platform: NATIVE,
363
+ sessionMode: 'identity',
364
+ identity: await makeBinding(),
365
+ isOffline: () => true,
366
+ onSignedOut,
367
+ });
368
+
369
+ expect(requestChallenge).not.toHaveBeenCalled();
370
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
371
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
372
+ });
373
+
374
+ it('ends signed out (never account-mode) when identity mode is requested without a binding', async () => {
375
+ const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
376
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
377
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
378
+ const onSignedOut = jest.fn();
379
+
380
+ const outcome = await runSessionColdBoot({
381
+ oxy,
382
+ store,
383
+ platform: NATIVE,
384
+ sessionMode: 'identity',
385
+ onSignedOut,
386
+ });
387
+
388
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
389
+ expect(onSignedOut).toHaveBeenCalledWith('error');
390
+ expect(setTokens).not.toHaveBeenCalled();
391
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
392
+ });
393
+ });
@@ -15,18 +15,45 @@
15
15
  * origin persisted a `deviceId` + `deviceSecret`, mint a short access token
16
16
  * with a single bearer-less POST to `/session/device/token` (no cookie, no
17
17
  * navigation) and rotate the secret in-use.
18
- * 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
18
+ * 3. `shared-key-signin` (native, ACCOUNT mode) — re-mint from the
19
+ * shared-keychain identity — OR `identity-key-signin` (IDENTITY mode) —
20
+ * re-mint from THIS device's primary identity key.
19
21
  * 4. Signed out.
20
22
  *
23
+ * Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
24
+ * - `account` (default) — the device's ACTIVE account owns the session. Every
25
+ * Oxy app but the identity vault boots this way; behaviour is unchanged.
26
+ * - `identity` — the owner of the local PRIMARY identity key owns the session,
27
+ * permanently, regardless of which account the device is switched to. Each
28
+ * step above is bound to the persisted identity pin, and the shared-keychain
29
+ * lane is replaced by the primary-key one (the shared slot is a CROSS-APP
30
+ * slot that may hold a different identity).
31
+ *
21
32
  * ESM-safe (no `require()`); no react/react-native/expo imports.
22
33
  */
23
34
  import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
24
35
  import { isNative as detectNative } from '../utils/platform';
25
36
  import { logger } from '../logger';
37
+ import { computeIdentityTag } from '../utils/cacheKey';
26
38
  import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh';
39
+ import {
40
+ establishIdentitySession,
41
+ resolveIdentityPin,
42
+ type IdentityBinding,
43
+ } from '../session/identitySession';
44
+ import type { IdentityPin } from '../session/identityPin';
27
45
  import type { OxyServices } from '../OxyServices';
28
46
  import type { AuthStateStore } from '../session/authStateStore';
29
47
 
48
+ /**
49
+ * Who owns the session this boot resolves.
50
+ *
51
+ * - `account` — the device's active account (every ordinary Oxy app).
52
+ * - `identity` — the owner of the device's primary identity key (the identity
53
+ * vault). Requires {@link RunSessionColdBootOptions.identity}.
54
+ */
55
+ export type SessionMode = 'account' | 'identity';
56
+
30
57
  /** The winning session shape a cold-boot step reports. */
31
58
  export interface DeviceBootSession {
32
59
  sessionId: string;
@@ -72,6 +99,23 @@ export interface RunSessionColdBootOptions {
72
99
  * so a flaky probe can never falsely skip a real sign-in.
73
100
  */
74
101
  isOffline?: () => boolean;
102
+ /**
103
+ * Who owns the resolved session. Defaults to `'account'` — the device's active
104
+ * account — which is the behaviour every ordinary Oxy app has today and which
105
+ * this option leaves byte-for-byte unchanged.
106
+ *
107
+ * `'identity'` binds the boot to the owner of this device's PRIMARY identity
108
+ * key and REQUIRES {@link identity}. When it is missing the boot refuses to
109
+ * run any lane (an identity-bound client silently falling back to the device's
110
+ * active account is precisely the bug this mode exists to prevent) and
111
+ * resolves signed out.
112
+ */
113
+ sessionMode?: SessionMode;
114
+ /**
115
+ * The identity binding (pin store + key/signature access) used by
116
+ * `sessionMode: 'identity'`. Ignored in `'account'` mode.
117
+ */
118
+ identity?: IdentityBinding;
75
119
  }
76
120
 
77
121
  /**
@@ -85,6 +129,19 @@ export async function runSessionColdBoot(
85
129
  const { oxy, store } = opts;
86
130
  const isNative = opts.platform?.isNative ?? detectNative();
87
131
 
132
+ // Non-null ONLY in identity mode; every `!== null` test below therefore reads
133
+ // as "is this boot identity-bound?" while also narrowing the binding.
134
+ const identityBinding = opts.sessionMode === 'identity' ? opts.identity ?? null : null;
135
+ if (opts.sessionMode === 'identity' && identityBinding === null) {
136
+ logger.error(
137
+ 'runSessionColdBoot: sessionMode "identity" requires an `identity` binding — refusing to run any lane (an identity-bound client must never adopt the device active account)',
138
+ undefined,
139
+ { component: 'sessionColdBoot', method: 'runSessionColdBoot' },
140
+ );
141
+ await opts.onSignedOut?.('error');
142
+ return { kind: 'unauthenticated' };
143
+ }
144
+
88
145
  // Best-effort connectivity gate for the NETWORK steps only. A missing hint or
89
146
  // any non-`true` verdict means "assume online" — never falsely skip a real
90
147
  // sign-in on an ambiguous probe.
@@ -94,6 +151,23 @@ export async function runSessionColdBoot(
94
151
  // bundler re-evaluation.
95
152
  let signedOutReason: SignedOutReason = 'no_session';
96
153
 
154
+ // Boot-local memo for the identity pin. Resolved lazily INSIDE a step so the
155
+ // (local, but storage-backed) read is covered by `overallDeadlineMs`, and
156
+ // resolved at most once per boot so two steps cannot disagree. Always `null`
157
+ // in account mode.
158
+ let pinResolved = false;
159
+ let cachedPin: IdentityPin | null = null;
160
+ const getIdentityPin = async (): Promise<IdentityPin | null> => {
161
+ if (identityBinding === null) {
162
+ return null;
163
+ }
164
+ if (!pinResolved) {
165
+ cachedPin = await resolveIdentityPin(identityBinding);
166
+ pinResolved = true;
167
+ }
168
+ return cachedPin;
169
+ };
170
+
97
171
  const steps: Array<ColdBootStep<DeviceBootSession>> = [];
98
172
 
99
173
  // 1. warm-token-plant (web + native) — the fastest path. When the persisted
@@ -122,6 +196,21 @@ export async function runSessionColdBoot(
122
196
  if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + TOKEN_REFRESH_LEAD_MS) {
123
197
  return { kind: 'skip' };
124
198
  }
199
+ if (identityBinding !== null) {
200
+ // Identity-bound: a persisted token is only acceptable when it belongs to
201
+ // the PINNED account. Both the token's own claim (`computeIdentityTag`,
202
+ // the same derivation `SessionClient.applyState` uses) and the owning
203
+ // session identity must match — a token planted for another account by an
204
+ // earlier account-mode write is exactly the durable drift this gate stops.
205
+ const pin = await getIdentityPin();
206
+ if (
207
+ pin === null ||
208
+ persisted.userId !== pin.accountId ||
209
+ computeIdentityTag(persisted.accessToken) !== pin.accountId
210
+ ) {
211
+ return { kind: 'skip' };
212
+ }
213
+ }
125
214
  oxy.setTokens(persisted.accessToken);
126
215
  return {
127
216
  kind: 'session',
@@ -148,7 +237,16 @@ export async function runSessionColdBoot(
148
237
  // a doomed mint cannot burn the overall deadline before routing settles.
149
238
  enabled: () => !isOffline(),
150
239
  run: async () => {
151
- const result = await refreshDeviceSecretArm({ oxy, store });
240
+ const pin = await getIdentityPin();
241
+ if (identityBinding !== null && pin === null) {
242
+ // Identity-bound with no verified pin (never written, or cleared because
243
+ // the local key changed/vanished). An UNPINNED mint would adopt whichever
244
+ // account the device is currently switched to — the exact drift this mode
245
+ // exists to prevent. Fall through to `identity-key-signin`, which
246
+ // re-derives the account from the local key and rewrites the pin.
247
+ return { kind: 'skip' };
248
+ }
249
+ const result = await refreshDeviceSecretArm({ oxy, store, pin });
152
250
  switch (result.status) {
153
251
  case 'ok':
154
252
  // The arm persisted the rotated secret and planted the token.
@@ -186,6 +284,15 @@ export async function runSessionColdBoot(
186
284
  { component: 'sessionColdBoot', method: 'device-secret-mint' },
187
285
  );
188
286
  return { kind: 'skip' };
287
+ case 'account-not-on-device':
288
+ // Pinned mint rejected: the pinned account is no longer a live account
289
+ // of this device session. The secret is healthy — keep it untouched and
290
+ // let `identity-key-signin` re-establish from the local key.
291
+ logger.debug(
292
+ 'pinned device-secret mint rejected (account_not_on_device) — keeping secret, re-establishing from the identity key',
293
+ { component: 'sessionColdBoot', method: 'device-secret-mint' },
294
+ );
295
+ return { kind: 'skip' };
189
296
  case 'transient':
190
297
  // Network / 5xx: keep the secret; a later attempt can succeed.
191
298
  logger.debug(
@@ -199,42 +306,81 @@ export async function runSessionColdBoot(
199
306
  },
200
307
  });
201
308
 
202
- // 3. shared-key-signin (native) — re-mint from the shared identity. Native
203
- // AND online: it is a network step (challenge + verify round-trips), so it
204
- // is gated by the same offline hint as the mint lane. `{ retry: false }`
205
- // keeps the two round-trips as single attempts the refresh scheduler /
206
- // 401 lane own later retries so this step cannot multiply boot latency.
207
- steps.push({
208
- id: 'shared-key-signin',
209
- enabled: () => isNative && !isOffline(),
210
- run: async () => {
211
- const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
212
- if (!session?.accessToken) {
213
- return { kind: 'skip' };
214
- }
215
- // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
216
- // boot can use the faster device-secret-mint lane (sockets + tab-focus
217
- // re-mint depend on the credential being in the store).
218
- if (session.deviceId && session.deviceSecret) {
219
- await store.save({
220
- sessionId: session.sessionId,
221
- userId: session.user.id,
222
- deviceId: session.deviceId,
223
- deviceSecret: session.deviceSecret,
224
- accessToken: session.accessToken,
225
- expiresAt: session.expiresAt,
309
+ if (identityBinding !== null) {
310
+ // 3-identity. identity-key-signin re-mint from THIS device's PRIMARY
311
+ // identity key (`getPublicKey` challenge sign verify). It REPLACES
312
+ // `shared-key-signin`, which reads the CROSS-APP shared keychain slot and
313
+ // may therefore hold a different identity than the device's primary the
314
+ // one thing an identity-bound client must never adopt. The server resolves
315
+ // the account from the verified signer, so the winning session is
316
+ // identity-authoritative and the pin is (re)written from it.
317
+ //
318
+ // Online-gated like every network step; `{ retry: false }` keeps the two
319
+ // round-trips single attempts (the refresh scheduler / 401 lane own later
320
+ // retries) so this step cannot multiply boot latency. On web
321
+ // `KeyManager.getPublicKey()` resolves to `null`, so it skips.
322
+ steps.push({
323
+ id: 'identity-key-signin',
324
+ enabled: () => !isOffline(),
325
+ run: async () => {
326
+ const established = await establishIdentitySession({
327
+ oxy,
328
+ store,
329
+ binding: identityBinding,
330
+ requestOptions: { retry: false },
226
331
  });
227
- }
228
- return {
229
- kind: 'session',
230
- session: {
231
- sessionId: session.sessionId,
232
- userId: session.user.id,
233
- accessToken: session.accessToken,
234
- },
235
- };
236
- },
237
- });
332
+ const accessToken = established?.session.accessToken;
333
+ if (!established || !accessToken) {
334
+ return { kind: 'skip' };
335
+ }
336
+ return {
337
+ kind: 'session',
338
+ session: {
339
+ sessionId: established.session.sessionId,
340
+ userId: established.session.user.id,
341
+ accessToken,
342
+ },
343
+ };
344
+ },
345
+ });
346
+ } else {
347
+ // 3. shared-key-signin (native) — re-mint from the shared identity. Native
348
+ // AND online: it is a network step (challenge + verify round-trips), so it
349
+ // is gated by the same offline hint as the mint lane. `{ retry: false }`
350
+ // keeps the two round-trips as single attempts — the refresh scheduler /
351
+ // 401 lane own later retries — so this step cannot multiply boot latency.
352
+ steps.push({
353
+ id: 'shared-key-signin',
354
+ enabled: () => isNative && !isOffline(),
355
+ run: async () => {
356
+ const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
357
+ if (!session?.accessToken) {
358
+ return { kind: 'skip' };
359
+ }
360
+ // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
361
+ // boot can use the faster device-secret-mint lane (sockets + tab-focus
362
+ // re-mint depend on the credential being in the store).
363
+ if (session.deviceId && session.deviceSecret) {
364
+ await store.save({
365
+ sessionId: session.sessionId,
366
+ userId: session.user.id,
367
+ deviceId: session.deviceId,
368
+ deviceSecret: session.deviceSecret,
369
+ accessToken: session.accessToken,
370
+ expiresAt: session.expiresAt,
371
+ });
372
+ }
373
+ return {
374
+ kind: 'session',
375
+ session: {
376
+ sessionId: session.sessionId,
377
+ userId: session.user.id,
378
+ accessToken: session.accessToken,
379
+ },
380
+ };
381
+ },
382
+ });
383
+ }
238
384
 
239
385
  const outcome = await runColdBoot<DeviceBootSession>({
240
386
  steps,