@oxyhq/core 12.10.5 → 12.11.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.
Files changed (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  3. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  4. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  5. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  6. package/dist/cjs/i18n/locales/en-US.json +13 -4
  7. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  8. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  9. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  10. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  11. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  23. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  24. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  25. package/dist/cjs/index.js +31 -5
  26. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  28. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  29. package/dist/cjs/mixins/index.js +5 -0
  30. package/dist/cjs/session/SessionClient.js +40 -4
  31. package/dist/cjs/session/accountDialogController.js +288 -41
  32. package/dist/cjs/session/hubSync.js +1 -1
  33. package/dist/cjs/session/identityPin.js +256 -0
  34. package/dist/cjs/session/identitySession.js +135 -0
  35. package/dist/cjs/session/projectSessionState.js +54 -15
  36. package/dist/cjs/session/refresh.js +73 -8
  37. package/dist/cjs/utils/commonsDelivery.js +59 -0
  38. package/dist/cjs/utils/oauthPkce.js +33 -3
  39. package/dist/esm/.tsbuildinfo +1 -1
  40. package/dist/esm/boot/sessionColdBoot.js +142 -37
  41. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  42. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  43. package/dist/esm/i18n/locales/de-DE.json +13 -1
  44. package/dist/esm/i18n/locales/en-US.json +13 -4
  45. package/dist/esm/i18n/locales/es-ES.json +13 -4
  46. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  47. package/dist/esm/i18n/locales/it-IT.json +13 -1
  48. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  49. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  51. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  52. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  53. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  54. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  55. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  56. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  58. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  61. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  62. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  63. package/dist/esm/index.js +14 -1
  64. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  65. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  66. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  67. package/dist/esm/mixins/index.js +5 -0
  68. package/dist/esm/session/SessionClient.js +40 -4
  69. package/dist/esm/session/accountDialogController.js +288 -41
  70. package/dist/esm/session/hubSync.js +1 -1
  71. package/dist/esm/session/identityPin.js +249 -0
  72. package/dist/esm/session/identitySession.js +131 -0
  73. package/dist/esm/session/projectSessionState.js +54 -15
  74. package/dist/esm/session/refresh.js +73 -8
  75. package/dist/esm/utils/commonsDelivery.js +54 -0
  76. package/dist/esm/utils/oauthPkce.js +31 -2
  77. package/dist/types/.tsbuildinfo +1 -1
  78. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  79. package/dist/types/index.d.ts +12 -4
  80. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  81. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  82. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  83. package/dist/types/mixins/index.d.ts +2 -1
  84. package/dist/types/session/SessionClient.d.ts +28 -0
  85. package/dist/types/session/accountDialogController.d.ts +187 -9
  86. package/dist/types/session/identityPin.d.ts +87 -0
  87. package/dist/types/session/identitySession.d.ts +105 -0
  88. package/dist/types/session/projectSessionState.d.ts +21 -19
  89. package/dist/types/session/refresh.d.ts +32 -1
  90. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  91. package/dist/types/utils/oauthPkce.d.ts +21 -1
  92. package/package.json +2 -2
  93. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  94. package/src/boot/sessionColdBoot.ts +183 -37
  95. package/src/i18n/locales/ar-SA.json +13 -1
  96. package/src/i18n/locales/ca-ES.json +13 -1
  97. package/src/i18n/locales/de-DE.json +13 -1
  98. package/src/i18n/locales/en-US.json +13 -4
  99. package/src/i18n/locales/es-ES.json +13 -4
  100. package/src/i18n/locales/fr-FR.json +13 -1
  101. package/src/i18n/locales/it-IT.json +13 -1
  102. package/src/i18n/locales/ja-JP.json +13 -1
  103. package/src/i18n/locales/ko-KR.json +13 -1
  104. package/src/i18n/locales/pt-PT.json +13 -1
  105. package/src/i18n/locales/zh-CN.json +13 -1
  106. package/src/index.ts +49 -0
  107. package/src/mixins/OxyServices.auth.ts +464 -24
  108. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  109. package/src/mixins/OxyServices.notifications.ts +142 -0
  110. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  111. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  112. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  113. package/src/mixins/index.ts +6 -0
  114. package/src/session/SessionClient.ts +57 -4
  115. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  116. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  117. package/src/session/__tests__/hubSync.test.ts +51 -0
  118. package/src/session/__tests__/identityPin.test.ts +131 -0
  119. package/src/session/__tests__/identitySession.test.ts +194 -0
  120. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  121. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  122. package/src/session/accountDialogController.ts +435 -44
  123. package/src/session/hubSync.ts +1 -1
  124. package/src/session/identityPin.ts +310 -0
  125. package/src/session/identitySession.ts +206 -0
  126. package/src/session/projectSessionState.ts +61 -13
  127. package/src/session/refresh.ts +103 -9
  128. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  129. package/src/utils/commonsDelivery.ts +132 -0
  130. package/src/utils/oauthPkce.ts +56 -3
@@ -0,0 +1,306 @@
1
+ /**
2
+ * The re-mint lane under an IDENTITY pin.
3
+ *
4
+ * The durable drift this covers: every background re-mint used to rewrite
5
+ * `PersistedAuthState.userId`/`sessionId` from `state.activeAccountId`, so an
6
+ * account switch made by ANOTHER app on the same device survived a reload
7
+ * through the `warm-token-plant` step. A pinned re-mint must target the pinned
8
+ * account and persist the PINNED account's identity, whatever the device's
9
+ * active account happens to be.
10
+ */
11
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
12
+ import type { OxyServices } from '../../OxyServices';
13
+ import type { SessionLoginResponse } from '../../models/session';
14
+ import type { AuthChallenge } from '../../crypto/signatureService';
15
+ import {
16
+ refreshDeviceSecretArm,
17
+ refreshPersistedSession,
18
+ type DeviceSecretMintOutcome,
19
+ } from '../refresh';
20
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
21
+ import { createMemoryIdentityPinStore, type IdentityPin } from '../identityPin';
22
+ import type { IdentityBinding } from '../identitySession';
23
+
24
+ const PUBLIC_KEY = `02${'a'.repeat(64)}`;
25
+ const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'vault-user' };
26
+
27
+ const STORED: PersistedAuthState = {
28
+ sessionId: 'sess-vault',
29
+ userId: 'vault-user',
30
+ deviceId: 'dev-1',
31
+ deviceSecret: 'ds-secret-orig',
32
+ };
33
+
34
+ /**
35
+ * A mint whose device has SWITCHED to another account: `activeAccountId` is
36
+ * `other-user`, while the pinned `vault-user` is still a member. The server
37
+ * minted the token for the pinned account (that is what `accountId` asks for)
38
+ * and reports the device's true active account in `state`.
39
+ */
40
+ const MINT_WHILE_SWITCHED: DeviceTokenMintResponse = {
41
+ accessToken: 'access-pinned',
42
+ expiresAt: '2030-01-01T00:00:00.000Z',
43
+ nextDeviceSecret: 'ds-next-secret',
44
+ state: {
45
+ deviceId: 'dev-1',
46
+ accounts: [
47
+ { accountId: 'vault-user', sessionId: 'sess-vault-new', authuser: 0 },
48
+ { accountId: 'other-user', sessionId: 'sess-other', authuser: 1 },
49
+ ],
50
+ activeAccountId: 'other-user',
51
+ revision: 9,
52
+ updatedAt: 1_700_000_000_000,
53
+ },
54
+ };
55
+
56
+ function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
57
+ let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
58
+ return (mint) => {
59
+ if (!inFlight) {
60
+ inFlight = mint().finally(() => {
61
+ inFlight = null;
62
+ });
63
+ }
64
+ return inFlight;
65
+ };
66
+ }
67
+
68
+ interface OxyOverrides {
69
+ mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
70
+ signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
71
+ requestChallenge?: OxyServices['requestChallenge'];
72
+ verifyChallenge?: OxyServices['verifyChallenge'];
73
+ }
74
+
75
+ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
76
+ const setTokens = jest.fn();
77
+ const oxy = {
78
+ setTokens,
79
+ mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT_WHILE_SWITCHED),
80
+ signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
81
+ requestChallenge:
82
+ overrides.requestChallenge
83
+ ?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
84
+ verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
85
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
86
+ } as unknown as OxyServices;
87
+ return { oxy, setTokens };
88
+ }
89
+
90
+ const IDENTITY_SESSION: SessionLoginResponse = {
91
+ sessionId: 'sess-reestablished',
92
+ deviceId: 'dev-1',
93
+ expiresAt: '2030-01-01T00:00:00.000Z',
94
+ accessToken: 'access-reestablished',
95
+ deviceSecret: 'ds-reestablished',
96
+ user: { id: 'vault-user', username: 'vault', name: {} },
97
+ };
98
+
99
+ const OTHER_PUBLIC_KEY = `03${'b'.repeat(64)}`;
100
+
101
+ function signWith(publicKey: string): (challenge: string) => Promise<AuthChallenge> {
102
+ return async (challenge) => ({
103
+ challenge: `sig(${challenge})`,
104
+ publicKey,
105
+ timestamp: 1_700_000_000_000,
106
+ });
107
+ }
108
+
109
+ const signChallenge = signWith(PUBLIC_KEY);
110
+
111
+ /** A binding whose pin store is pre-seeded with {@link PIN}. */
112
+ async function makeBinding(): Promise<IdentityBinding> {
113
+ const pinStore = createMemoryIdentityPinStore();
114
+ await pinStore.save(PIN);
115
+ return { pinStore, readPublicKey: async () => PUBLIC_KEY, signChallenge };
116
+ }
117
+
118
+ /** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
119
+ function mint401(body: string): Error & { status: number } {
120
+ return Object.assign(new Error(body), { status: 401 });
121
+ }
122
+
123
+ describe('refreshDeviceSecretArm — pinned', () => {
124
+ it('sends the pinned accountId to the mint', async () => {
125
+ const store = createMemoryAuthStateStore();
126
+ await store.save(STORED);
127
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
128
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
129
+
130
+ await refreshDeviceSecretArm({ oxy, store, pin: PIN });
131
+
132
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-1', 'ds-secret-orig', {
133
+ accountId: 'vault-user',
134
+ });
135
+ });
136
+
137
+ it('persists the PINNED account identity even though the device is switched elsewhere', async () => {
138
+ const store = createMemoryAuthStateStore();
139
+ await store.save(STORED);
140
+ const { oxy, setTokens } = makeOxy();
141
+
142
+ const outcome = await refreshDeviceSecretArm({ oxy, store, pin: PIN });
143
+
144
+ expect(outcome).toEqual({
145
+ status: 'ok',
146
+ token: 'access-pinned',
147
+ sessionId: 'sess-vault-new',
148
+ userId: 'vault-user',
149
+ });
150
+ expect(setTokens).toHaveBeenCalledWith('access-pinned');
151
+ expect(await store.load()).toMatchObject({
152
+ // NOT `other-user` / `sess-other`, which is what `activeAccountId` says.
153
+ userId: 'vault-user',
154
+ sessionId: 'sess-vault-new',
155
+ deviceSecret: 'ds-next-secret',
156
+ });
157
+ });
158
+
159
+ it('follows activeAccountId when NOT pinned (account mode is unchanged)', async () => {
160
+ const store = createMemoryAuthStateStore();
161
+ await store.save(STORED);
162
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
163
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
164
+
165
+ await refreshDeviceSecretArm({ oxy, store });
166
+
167
+ // No third argument at all: the account-mode request body is untouched.
168
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-1', 'ds-secret-orig');
169
+ expect(await store.load()).toMatchObject({ userId: 'other-user', sessionId: 'sess-other' });
170
+ });
171
+
172
+ it('classifies a 401 account_not_on_device as its own outcome (never a bad secret)', async () => {
173
+ const store = createMemoryAuthStateStore();
174
+ await store.save(STORED);
175
+ const { oxy } = makeOxy({
176
+ mintFromDeviceSecret: async () => {
177
+ throw mint401('account_not_on_device: vault-user is not a live account of this device session');
178
+ },
179
+ });
180
+
181
+ expect(await refreshDeviceSecretArm({ oxy, store, pin: PIN })).toEqual({
182
+ status: 'account-not-on-device',
183
+ });
184
+ // The healthy device credential is untouched.
185
+ expect(await store.load()).toMatchObject({ deviceSecret: 'ds-secret-orig' });
186
+ });
187
+ });
188
+
189
+ describe('refreshPersistedSession — pinned', () => {
190
+ it('never runs the shared-keychain arm; re-establishes from the primary key instead', async () => {
191
+ const store = createMemoryAuthStateStore();
192
+ await store.save(STORED);
193
+ const signInWithSharedIdentity = jest.fn(async () => null);
194
+ const { oxy } = makeOxy({
195
+ signInWithSharedIdentity,
196
+ mintFromDeviceSecret: async () => {
197
+ throw mint401('invalid_device_secret');
198
+ },
199
+ });
200
+
201
+ const token = await refreshPersistedSession({
202
+ oxy,
203
+ store,
204
+ allowSharedKeyFallback: true, // explicitly requested — and explicitly ignored when pinned
205
+ identity: await makeBinding(),
206
+ });
207
+
208
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
209
+ expect(token).toBe('access-reestablished');
210
+ // The identity sign-in repopulated the fast lane for the pinned account.
211
+ expect(await store.load()).toMatchObject({
212
+ userId: 'vault-user',
213
+ sessionId: 'sess-reestablished',
214
+ deviceSecret: 'ds-reestablished',
215
+ });
216
+ });
217
+
218
+ it('re-establishes from the primary key when the pinned account left the device', async () => {
219
+ const store = createMemoryAuthStateStore();
220
+ await store.save(STORED);
221
+ const mintFromDeviceSecret = jest.fn(async () => {
222
+ throw mint401('account_not_on_device');
223
+ });
224
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
225
+
226
+ const token = await refreshPersistedSession({ oxy, store, identity: await makeBinding() });
227
+
228
+ expect(mintFromDeviceSecret).toHaveBeenCalled();
229
+ expect(token).toBe('access-reestablished');
230
+ });
231
+
232
+ it('skips the mint entirely and re-establishes when the local key no longer matches the pin', async () => {
233
+ const store = createMemoryAuthStateStore();
234
+ await store.save(STORED);
235
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
236
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
237
+ const pinStore = createMemoryIdentityPinStore();
238
+ await pinStore.save(PIN);
239
+
240
+ const token = await refreshPersistedSession({
241
+ oxy,
242
+ store,
243
+ // The device's identity was replaced — the stale pin must not survive it.
244
+ identity: {
245
+ pinStore,
246
+ readPublicKey: async () => OTHER_PUBLIC_KEY,
247
+ signChallenge: signWith(OTHER_PUBLIC_KEY),
248
+ },
249
+ });
250
+
251
+ // An UNPINNED mint here would have adopted `other-user` — the drift the pin exists to stop.
252
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
253
+ expect(token).toBe('access-reestablished');
254
+ // The pin now binds the CURRENT key to the account the server resolved for it.
255
+ expect(await pinStore.load()).toEqual({
256
+ publicKey: OTHER_PUBLIC_KEY,
257
+ accountId: 'vault-user',
258
+ });
259
+ });
260
+
261
+ it('returns null (signed out) when the identity cannot be read — never falls back to the active account', async () => {
262
+ const store = createMemoryAuthStateStore();
263
+ await store.save(STORED);
264
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
265
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
266
+ const pinStore = createMemoryIdentityPinStore();
267
+ await pinStore.save(PIN);
268
+
269
+ const token = await refreshPersistedSession({
270
+ oxy,
271
+ store,
272
+ identity: {
273
+ pinStore,
274
+ readPublicKey: async () => {
275
+ throw new Error('keychain locked');
276
+ },
277
+ signChallenge,
278
+ },
279
+ });
280
+
281
+ expect(token).toBeNull();
282
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
283
+ // A locked keychain is not evidence of an identity change — the pin survives.
284
+ expect(await pinStore.load()).toEqual(PIN);
285
+ });
286
+
287
+ it('keeps the deviceId (drops only the secret) on a 401 so the identity arm can recover', async () => {
288
+ const store = createMemoryAuthStateStore();
289
+ await store.save(STORED);
290
+ const { oxy } = makeOxy({
291
+ mintFromDeviceSecret: async () => {
292
+ throw mint401('invalid_device_secret');
293
+ },
294
+ // The identity arm yields nothing this time (e.g. offline), so the store
295
+ // state after the 401 is observable.
296
+ verifyChallenge: async () => ({ ...IDENTITY_SESSION, accessToken: undefined }),
297
+ });
298
+
299
+ const token = await refreshPersistedSession({ oxy, store, identity: await makeBinding() });
300
+
301
+ expect(token).toBeNull();
302
+ const persisted = await store.load();
303
+ expect(persisted?.deviceId).toBe('dev-1');
304
+ expect(persisted?.deviceSecret).toBeUndefined();
305
+ });
306
+ });