@oxyhq/core 5.5.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +7 -4
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +54 -45
- package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +8 -17
- package/dist/cjs/server/index.js +2 -2
- package/dist/cjs/session/SessionClient.js +40 -1
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/shared/utils/debugUtils.js +3 -3
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +10 -39
- package/dist/cjs/utils/coldBoot.js +10 -8
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/registrableApex.js +49 -0
- package/dist/cjs/utils/ssoBounce.js +15 -362
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +7 -4
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +34 -17
- package/dist/esm/mixins/OxyServices.accounts.js +13 -26
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +8 -17
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/session/SessionClient.js +40 -1
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/shared/utils/debugUtils.js +3 -3
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +9 -37
- package/dist/esm/utils/coldBoot.js +10 -8
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/registrableApex.js +46 -0
- package/dist/esm/utils/ssoBounce.js +14 -345
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +14 -1
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +14 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +5 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/models/session.d.ts +4 -5
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/session/SessionClient.d.ts +26 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/shared/utils/debugUtils.d.ts +3 -3
- package/dist/types/utils/accountUtils.d.ts +2 -17
- package/dist/types/utils/authWebUrl.d.ts +9 -35
- package/dist/types/utils/coldBoot.d.ts +17 -14
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/registrableApex.d.ts +31 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -280
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +20 -4
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +75 -65
- package/src/mixins/OxyServices.accounts.ts +27 -39
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +10 -23
- package/src/models/interfaces.ts +0 -79
- package/src/models/session.ts +4 -5
- package/src/server/index.ts +1 -1
- package/src/session/SessionClient.ts +53 -2
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/refresh.ts +334 -0
- package/src/shared/utils/debugUtils.ts +3 -3
- package/src/utils/__tests__/authWebUrl.test.ts +5 -40
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +2 -65
- package/src/utils/authWebUrl.ts +9 -39
- package/src/utils/coldBoot.ts +17 -14
- package/src/utils/platform.ts +21 -0
- package/src/utils/registrableApex.ts +45 -0
- package/src/utils/ssoBounce.ts +14 -393
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/fapiAutoDetect.js +0 -99
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -275
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/fapiAutoDetect.js +0 -95
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -271
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -156
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -276
- package/src/utils/fapiAutoDetect.ts +0 -91
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
|
@@ -176,27 +176,22 @@ describe('OxyServices.accounts', () => {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
describe('switchToAccount', () => {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// Device-first: the switch route registers the switched session in the
|
|
180
|
+
// server-side DeviceSession set directly and returns the fresh access token
|
|
181
|
+
// + the resolved `authuser`. There is NO follow-up POST /auth/session.
|
|
182
182
|
const switchResponse: SwitchAccountResult = {
|
|
183
183
|
sessionId: 'sess_switch',
|
|
184
184
|
deviceId: 'dev_switch',
|
|
185
185
|
expiresAt: '2026-06-30T01:00:00.000Z',
|
|
186
186
|
accessToken: 'access_switch',
|
|
187
|
+
authuser: 1,
|
|
187
188
|
user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
|
|
188
189
|
};
|
|
189
|
-
// POST /auth/session establishes the cookie in a correctly-allocated NEW slot
|
|
190
|
-
// and returns that slot + a fresh access token off the same session.
|
|
191
|
-
const sessionResponse = { accessToken: 'access_session', authuser: 1 };
|
|
192
190
|
|
|
193
|
-
// Route makeRequest by path: the switch call vs the /auth/session establishment.
|
|
194
191
|
const routeByPath = (response = switchResponse) =>
|
|
195
|
-
makeRequestSpy.
|
|
196
|
-
Promise.resolve(path === '/auth/session' ? sessionResponse : response),
|
|
197
|
-
);
|
|
192
|
+
makeRequestSpy.mockResolvedValue(response);
|
|
198
193
|
|
|
199
|
-
it('posts to /:id/switch
|
|
194
|
+
it('posts to /:id/switch, plants the token, carries the response authuser, and sweeps the cache', async () => {
|
|
200
195
|
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
201
196
|
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
202
197
|
routeByPath();
|
|
@@ -210,29 +205,20 @@ describe('OxyServices.accounts', () => {
|
|
|
210
205
|
undefined,
|
|
211
206
|
expect.objectContaining({ cache: false }),
|
|
212
207
|
);
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
expect(makeRequestSpy).
|
|
216
|
-
'POST',
|
|
217
|
-
'/auth/session',
|
|
218
|
-
undefined,
|
|
219
|
-
expect.objectContaining({ cache: false }),
|
|
220
|
-
);
|
|
208
|
+
// No secondary /auth/session establishment — the switch registers the
|
|
209
|
+
// session server-side on its own.
|
|
210
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
221
211
|
|
|
222
|
-
// The switch token is planted
|
|
223
|
-
expect(setTokensSpy).
|
|
224
|
-
expect(
|
|
225
|
-
expect(oxy.getAccessToken()).toBe('access_session');
|
|
212
|
+
// The switch token is planted; the cache is swept AFTER planting.
|
|
213
|
+
expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
|
|
214
|
+
expect(oxy.getAccessToken()).toBe('access_switch');
|
|
226
215
|
expect(oxy.hasValidToken()).toBe(true);
|
|
227
|
-
|
|
228
|
-
// Cache swept once, AFTER both tokens are planted.
|
|
229
216
|
expect(clearCacheSpy).toHaveBeenCalledTimes(1);
|
|
230
|
-
expect(setTokensSpy.mock.invocationCallOrder[
|
|
217
|
+
expect(setTokensSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
|
231
218
|
clearCacheSpy.mock.invocationCallOrder[0],
|
|
232
219
|
);
|
|
233
220
|
|
|
234
|
-
// The returned session carries the target account (id-normalised)
|
|
235
|
-
// slot resolved by /auth/session — NEVER the clobbering slot 0.
|
|
221
|
+
// The returned session carries the target account (id-normalised) + slot.
|
|
236
222
|
expect(result.sessionId).toBe('sess_switch');
|
|
237
223
|
expect(result.user).toEqual({ id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } });
|
|
238
224
|
expect(result.authuser).toBe(1);
|
|
@@ -247,27 +233,14 @@ describe('OxyServices.accounts', () => {
|
|
|
247
233
|
expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
|
|
248
234
|
});
|
|
249
235
|
|
|
250
|
-
it('
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
makeRequestSpy.mockImplementation((_method: string, path: string) =>
|
|
254
|
-
path === '/auth/session'
|
|
255
|
-
? Promise.reject(Object.assign(new Error('origin'), { response: { status: 403 } }))
|
|
256
|
-
: Promise.resolve(switchResponse),
|
|
257
|
-
);
|
|
236
|
+
it('omits authuser from the result when the switch response has none', async () => {
|
|
237
|
+
const { authuser, ...noAuthuser } = switchResponse;
|
|
238
|
+
routeByPath(noAuthuser as SwitchAccountResult);
|
|
258
239
|
|
|
259
240
|
const result = await oxy.switchToAccount('acc1');
|
|
260
241
|
|
|
261
|
-
// The in-session switch survives: the switch token stays planted and the
|
|
262
|
-
// cache is still swept. The switched account just won't survive a reload
|
|
263
|
-
// until the cookie is next established (no authuser resolved).
|
|
264
|
-
expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
|
|
265
242
|
expect(oxy.getAccessToken()).toBe('access_switch');
|
|
266
|
-
expect(clearCacheSpy).toHaveBeenCalledTimes(1);
|
|
267
243
|
expect(result.authuser).toBeUndefined();
|
|
268
|
-
|
|
269
|
-
setTokensSpy.mockRestore();
|
|
270
|
-
clearCacheSpy.mockRestore();
|
|
271
244
|
});
|
|
272
245
|
|
|
273
246
|
it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-first password sign-in (`passwordSignIn` + `completeTwoFactorSignIn`).
|
|
3
|
+
* Stubs `makeRequest` and asserts the login-result contract handling: the 2FA
|
|
4
|
+
* arm passes through un-planted, the session arm plants its access token, the
|
|
5
|
+
* deviceToken is threaded into the request, and a 2FA arm from verify-login is
|
|
6
|
+
* a protocol error.
|
|
7
|
+
*/
|
|
8
|
+
import type { LoginResult } from '@oxyhq/contracts';
|
|
9
|
+
import { OxyServices } from '../../OxyServices';
|
|
10
|
+
|
|
11
|
+
const SESSION_ARM: LoginResult = {
|
|
12
|
+
sessionId: 'sess-1',
|
|
13
|
+
deviceId: 'dev-1',
|
|
14
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
15
|
+
accessToken: 'access-1',
|
|
16
|
+
refreshToken: 'refresh-1',
|
|
17
|
+
user: { id: 'user-1', username: 'u' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const TWO_FACTOR_ARM: LoginResult = { twoFactorRequired: true, loginToken: 'login-token-1' };
|
|
21
|
+
|
|
22
|
+
describe('passwordSignIn', () => {
|
|
23
|
+
let oxy: OxyServices;
|
|
24
|
+
let makeRequest: jest.SpyInstance;
|
|
25
|
+
let setTokens: jest.SpyInstance;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
29
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
30
|
+
setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => jest.restoreAllMocks());
|
|
34
|
+
|
|
35
|
+
it('returns the 2FA arm without planting a token', async () => {
|
|
36
|
+
makeRequest.mockResolvedValueOnce(TWO_FACTOR_ARM);
|
|
37
|
+
const result = await oxy.passwordSignIn('alice', 'pw');
|
|
38
|
+
expect(result).toEqual(TWO_FACTOR_ARM);
|
|
39
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('plants the access token on the session arm and threads the deviceToken', async () => {
|
|
43
|
+
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
44
|
+
const result = await oxy.passwordSignIn('alice', 'pw', { deviceToken: 'dt-1', deviceName: 'Phone' });
|
|
45
|
+
expect(result).toEqual(SESSION_ARM);
|
|
46
|
+
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
47
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
48
|
+
'POST',
|
|
49
|
+
'/auth/login',
|
|
50
|
+
{ identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint: undefined, deviceToken: 'dt-1' },
|
|
51
|
+
{ cache: false },
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws on an unexpected response shape', async () => {
|
|
56
|
+
makeRequest.mockResolvedValueOnce({ nope: true });
|
|
57
|
+
await expect(oxy.passwordSignIn('alice', 'pw')).rejects.toThrow();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('completeTwoFactorSignIn', () => {
|
|
62
|
+
let oxy: OxyServices;
|
|
63
|
+
let makeRequest: jest.SpyInstance;
|
|
64
|
+
let setTokens: jest.SpyInstance;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
68
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
69
|
+
setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => jest.restoreAllMocks());
|
|
73
|
+
|
|
74
|
+
it('verifies the login token, plants the session, and POSTs to /security/2fa/verify-login', async () => {
|
|
75
|
+
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
76
|
+
const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456', deviceToken: 'dt-1' });
|
|
77
|
+
expect(result).toEqual(SESSION_ARM);
|
|
78
|
+
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
79
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
80
|
+
'POST',
|
|
81
|
+
'/security/2fa/verify-login',
|
|
82
|
+
{ loginToken: 'lt', token: '123456', backupCode: undefined, deviceToken: 'dt-1', deviceName: undefined },
|
|
83
|
+
{ cache: false },
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws when verify-login unexpectedly returns another 2FA challenge', async () => {
|
|
88
|
+
makeRequest.mockResolvedValueOnce(TWO_FACTOR_ARM);
|
|
89
|
+
await expect(oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456' })).rejects.toThrow();
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -7,10 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
import { OxyServicesBase } from '../OxyServices.base';
|
|
9
9
|
import { OxyServicesAuthMixin } from './OxyServices.auth';
|
|
10
|
-
import { OxyServicesFedCMMixin } from './OxyServices.fedcm';
|
|
11
|
-
import { OxyServicesSilentAuthMixin } from './OxyServices.silent';
|
|
12
|
-
import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
|
|
13
|
-
import { OxyServicesSsoMixin } from './OxyServices.sso';
|
|
14
10
|
import { OxyServicesUserMixin } from './OxyServices.user';
|
|
15
11
|
import { OxyServicesIdentityMixin } from './OxyServices.identity';
|
|
16
12
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
@@ -32,6 +28,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
|
32
28
|
import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
33
29
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
34
30
|
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
31
|
+
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
35
32
|
|
|
36
33
|
/**
|
|
37
34
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -44,10 +41,6 @@ import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
|
44
41
|
*/
|
|
45
42
|
type AllMixinInstances =
|
|
46
43
|
& InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>>
|
|
47
|
-
& InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>>
|
|
48
|
-
& InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>>
|
|
49
|
-
& InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>>
|
|
50
|
-
& InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>>
|
|
51
44
|
& InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
|
|
52
45
|
& InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
|
|
53
46
|
& InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
|
|
@@ -68,6 +61,7 @@ type AllMixinInstances =
|
|
|
68
61
|
& InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
|
|
69
62
|
& InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
|
|
70
63
|
& InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
|
|
64
|
+
& InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
|
|
71
65
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
72
66
|
|
|
73
67
|
/**
|
|
@@ -90,10 +84,9 @@ type MixinFunction = (Base: new (...args: unknown[]) => OxyServicesBase) => new
|
|
|
90
84
|
*
|
|
91
85
|
* Order matters for dependencies:
|
|
92
86
|
* 1. Base auth mixin first (required by all others)
|
|
93
|
-
* 2.
|
|
94
|
-
* 3.
|
|
95
|
-
* 4.
|
|
96
|
-
* 5. Utility mixin last (augments all)
|
|
87
|
+
* 2. User mixin (requires auth)
|
|
88
|
+
* 3. Feature mixins (can depend on user)
|
|
89
|
+
* 4. Utility mixin last (augments all)
|
|
97
90
|
*
|
|
98
91
|
* To add a new mixin: insert it at the appropriate position in this array.
|
|
99
92
|
*/
|
|
@@ -101,17 +94,6 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
101
94
|
// Base authentication
|
|
102
95
|
OxyServicesAuthMixin,
|
|
103
96
|
|
|
104
|
-
// Cross-domain authentication (web-only)
|
|
105
|
-
// - FedCM: Modern browser-native identity federation (Google-style)
|
|
106
|
-
// - Silent: iframe-based restore for first-party IdP hosts
|
|
107
|
-
// - Redirect: Traditional redirect-based authentication
|
|
108
|
-
OxyServicesFedCMMixin,
|
|
109
|
-
OxyServicesSilentAuthMixin,
|
|
110
|
-
OxyServicesRedirectAuthMixin,
|
|
111
|
-
|
|
112
|
-
// Central cross-domain SSO (opaque-code exchange).
|
|
113
|
-
OxyServicesSsoMixin,
|
|
114
|
-
|
|
115
97
|
// User management (requires auth)
|
|
116
98
|
OxyServicesUserMixin,
|
|
117
99
|
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
|
|
@@ -147,6 +129,11 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
147
129
|
// so apps stop scraping link metadata locally.
|
|
148
130
|
OxyServicesLinksMixin,
|
|
149
131
|
|
|
132
|
+
// Device-first session bootstrap: the client half of the new
|
|
133
|
+
// /auth/device/* + /auth/refresh-token surface (exchange, web-session,
|
|
134
|
+
// refresh, native device-token, bootstrap-url builder).
|
|
135
|
+
OxyServicesDeviceBootMixin,
|
|
136
|
+
|
|
150
137
|
// Utility (last, can use all above)
|
|
151
138
|
OxyServicesUtilityMixin,
|
|
152
139
|
];
|
package/src/models/interfaces.ts
CHANGED
|
@@ -3,20 +3,6 @@ import type { UserNameResponse } from '@oxyhq/contracts';
|
|
|
3
3
|
export interface OxyConfig {
|
|
4
4
|
baseURL: string;
|
|
5
5
|
cloudURL?: string;
|
|
6
|
-
/**
|
|
7
|
-
* Base URL the SDK's first-party session/refresh calls target.
|
|
8
|
-
*
|
|
9
|
-
* Per the 2026 session architecture (docs/SESSION-ARCHITECTURE.md), every app
|
|
10
|
-
* keeps its OWN first-party session on its OWN domain. For non-`oxy.so` apps
|
|
11
|
-
* this is the app's own same-site backend (e.g. `https://api.mention.earth`),
|
|
12
|
-
* whose session bridge forwards the user's refresh credential to
|
|
13
|
-
* `api.oxy.so`. For `*.oxy.so` apps this is omitted and falls back to
|
|
14
|
-
* `baseURL` (`https://api.oxy.so`), so their behavior is unchanged.
|
|
15
|
-
*
|
|
16
|
-
* Resolve via {@link OxyServices.getSessionBaseUrl}; when unset it returns
|
|
17
|
-
* `baseURL`. This is purely additive — no refresh/auth logic reads it yet.
|
|
18
|
-
*/
|
|
19
|
-
sessionBaseUrl?: string;
|
|
20
6
|
authWebUrl?: string;
|
|
21
7
|
authRedirectUri?: string;
|
|
22
8
|
/**
|
|
@@ -120,7 +106,6 @@ export interface User {
|
|
|
120
106
|
phone?: string;
|
|
121
107
|
address?: string;
|
|
122
108
|
birthday?: string;
|
|
123
|
-
location?: string;
|
|
124
109
|
website?: string;
|
|
125
110
|
createdAt?: string;
|
|
126
111
|
updatedAt?: string;
|
|
@@ -689,67 +674,3 @@ export interface UpdateDeviceNameResponse {
|
|
|
689
674
|
deviceName: string;
|
|
690
675
|
}
|
|
691
676
|
|
|
692
|
-
// ---------------------------------------------------------------------------
|
|
693
|
-
// Multi-account "refresh-all" (Google-style)
|
|
694
|
-
// ---------------------------------------------------------------------------
|
|
695
|
-
// Wire shape of `POST /auth/refresh-all`. The server rotates every device-local
|
|
696
|
-
// `oxy_rt_${authuser}` cookie in parallel and returns one entry per VALID
|
|
697
|
-
// account, sorted by `authuser` ascending. Slot-level errors are silently
|
|
698
|
-
// omitted; the response is `{ accounts: [] }` in the worst case (no signed-in
|
|
699
|
-
// accounts, all cookies expired, or origin not allowlisted).
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* Minimal user shape included in a `RefreshAllAccount` entry. The server
|
|
703
|
-
* projects a small whitelist (`username name avatar email color`) so the
|
|
704
|
-
* client can render the account chooser without an extra `/users/me` round
|
|
705
|
-
* trip per account.
|
|
706
|
-
*
|
|
707
|
-
* `avatar` and `color` are `string | null` because they are stored as nullable
|
|
708
|
-
* fields in the user document.
|
|
709
|
-
*/
|
|
710
|
-
export interface RefreshAllAccountUser {
|
|
711
|
-
id: string;
|
|
712
|
-
username: string;
|
|
713
|
-
/**
|
|
714
|
-
* Structured human name as emitted by `formatUserResponse` (the canonical
|
|
715
|
-
* {@link UserNameResponse} `{ first?, last?, full? }` subdocument), NOT a bare
|
|
716
|
-
* string. The server projects `name` verbatim from the user document. The
|
|
717
|
-
* single source of truth is `@oxyhq/contracts`.
|
|
718
|
-
*/
|
|
719
|
-
name: UserNameResponse;
|
|
720
|
-
avatar?: string | null;
|
|
721
|
-
email?: string;
|
|
722
|
-
color?: string | null;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* One rotated account entry returned by `POST /auth/refresh-all`. `authuser` is
|
|
727
|
-
* the device-local slot index (0..N-1) the cookie was bound to.
|
|
728
|
-
*/
|
|
729
|
-
export interface RefreshAllAccount {
|
|
730
|
-
authuser: number;
|
|
731
|
-
accessToken: string;
|
|
732
|
-
expiresAt: string;
|
|
733
|
-
sessionId: string;
|
|
734
|
-
user: RefreshAllAccountUser | null;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Wire shape of `POST /auth/refresh-all`. Always 200 with a (possibly empty)
|
|
739
|
-
* accounts array — 401 means "no accounts signed in on this device" and is
|
|
740
|
-
* normalised to `{ accounts: [] }` at the SDK layer.
|
|
741
|
-
*/
|
|
742
|
-
export interface RefreshAllResponse {
|
|
743
|
-
accounts: RefreshAllAccount[];
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Wire shape of `POST /auth/refresh` (single-slot refresh, optionally targeting
|
|
748
|
-
* a specific `?authuser=N` slot). The server always includes the numeric slot in
|
|
749
|
-
* the response.
|
|
750
|
-
*/
|
|
751
|
-
export interface RefreshCookieResponse {
|
|
752
|
-
accessToken: string;
|
|
753
|
-
expiresAt: string;
|
|
754
|
-
authuser: number;
|
|
755
|
-
}
|
package/src/models/session.ts
CHANGED
|
@@ -8,11 +8,10 @@ export interface ClientSession {
|
|
|
8
8
|
userId?: string;
|
|
9
9
|
isCurrent?: boolean;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Absent on native (RN uses the bearer-protected session id directly).
|
|
11
|
+
* The account's ordinal slot (0..N) within the device's account set
|
|
12
|
+
* (`SessionAccount.authuser` in `@oxyhq/contracts`), projected from the
|
|
13
|
+
* device-first `DeviceSessionState` — used purely for stable Google-style
|
|
14
|
+
* account-chooser ordering, not for any token-refresh mechanism.
|
|
16
15
|
*/
|
|
17
16
|
authuser?: number;
|
|
18
17
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export { verifySecret } from './verifySecret';
|
|
|
68
68
|
// SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
|
|
69
69
|
// Pure host handling (no browser deps), so it is safe on the server subpath and
|
|
70
70
|
// lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
|
|
71
|
-
export { registrableApex } from '../utils/
|
|
71
|
+
export { registrableApex } from '../utils/registrableApex';
|
|
72
72
|
|
|
73
73
|
// The single RP callback path the IdP redirects back to. A pure wire-contract
|
|
74
74
|
// constant (no browser deps at module top level), re-used server-side so the
|
|
@@ -9,7 +9,7 @@ import { getSocketIO } from './socketLoader';
|
|
|
9
9
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
10
10
|
|
|
11
11
|
export interface TokenTransport {
|
|
12
|
-
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via
|
|
12
|
+
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
|
|
13
13
|
ensureActiveToken(state: DeviceSessionState): Promise<void>;
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -24,6 +24,16 @@ export interface SessionClientHost {
|
|
|
24
24
|
|
|
25
25
|
export interface SessionClientOptions {
|
|
26
26
|
transport?: TokenTransport;
|
|
27
|
+
/**
|
|
28
|
+
* Invoked when an APPLIED state has zero accounts — i.e. a device
|
|
29
|
+
* signout-all removed the last account from this device set. Providers use
|
|
30
|
+
* this to clear the persisted {@link AuthStateStore} so a reload does not
|
|
31
|
+
* try to restore a session that no longer exists on the device.
|
|
32
|
+
*
|
|
33
|
+
* Only fires when a state is actually applied (revision advanced), never on
|
|
34
|
+
* a stale/rejected push. Exceptions thrown by the callback are isolated.
|
|
35
|
+
*/
|
|
36
|
+
onUnauthenticated?: () => void;
|
|
27
37
|
/**
|
|
28
38
|
* Statically-injected `socket.io-client` factory (its `io` export).
|
|
29
39
|
* `@oxyhq/services` and `@oxyhq/auth` list `socket.io-client` as a real
|
|
@@ -80,7 +90,17 @@ export class SessionClient {
|
|
|
80
90
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
81
91
|
return false;
|
|
82
92
|
}
|
|
83
|
-
|
|
93
|
+
// The revision is monotone ONLY within a single deviceId. When the incoming
|
|
94
|
+
// state belongs to a DIFFERENT device than the currently-applied one, reset
|
|
95
|
+
// the baseline and accept it regardless of revision: a freshly-converged
|
|
96
|
+
// device (low revision) must not lose last-writer-wins to a stale, higher-
|
|
97
|
+
// revision state from a retired device. Only when the deviceIds MATCH is the
|
|
98
|
+
// `revision <= current` guard a valid staleness check.
|
|
99
|
+
if (
|
|
100
|
+
this.state &&
|
|
101
|
+
next.deviceId === this.state.deviceId &&
|
|
102
|
+
next.revision <= this.state.revision
|
|
103
|
+
) {
|
|
84
104
|
return false;
|
|
85
105
|
}
|
|
86
106
|
this.state = next;
|
|
@@ -90,6 +110,15 @@ export class SessionClient {
|
|
|
90
110
|
logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
91
111
|
});
|
|
92
112
|
}
|
|
113
|
+
// A device signout-all leaves zero accounts — tell the provider to clear
|
|
114
|
+
// the persisted store so a reload does not try to restore a dead session.
|
|
115
|
+
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
116
|
+
try {
|
|
117
|
+
this.options.onUnauthenticated();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
93
122
|
return true;
|
|
94
123
|
}
|
|
95
124
|
|
|
@@ -144,6 +173,28 @@ export class SessionClient {
|
|
|
144
173
|
this.applySync(res);
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Register the just-signed-in account into the device set AND make it the
|
|
178
|
+
* ACTIVE account — the explicit user-intent activation a sign-in UI performs.
|
|
179
|
+
*
|
|
180
|
+
* `addCurrentAccount` alone honors the server's `activate:'if-empty'` policy
|
|
181
|
+
* (a new account does NOT steal focus from an already-active one), which is
|
|
182
|
+
* correct for a background/silent add but wrong right after a deliberate
|
|
183
|
+
* sign-in. This adds, then switches to the target so the UI lands on the
|
|
184
|
+
* account the user just authenticated.
|
|
185
|
+
*
|
|
186
|
+
* @param accountId - The signed-in account id (e.g. `session.user.id`). When
|
|
187
|
+
* omitted, falls back to the host's current-account ref. If neither
|
|
188
|
+
* resolves, the add still applies and no switch is performed.
|
|
189
|
+
*/
|
|
190
|
+
async registerAndActivate(accountId?: string): Promise<void> {
|
|
191
|
+
await this.addCurrentAccount();
|
|
192
|
+
const target = accountId ?? this.host.getCurrentAccountId();
|
|
193
|
+
if (target && this.state?.activeAccountId !== target) {
|
|
194
|
+
await this.switchAccount(target);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
147
198
|
async start(): Promise<void> {
|
|
148
199
|
if (this.started) return;
|
|
149
200
|
this.started = true;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
3
|
+
|
|
4
|
+
const stateWith = (rev: number, active: string | null, accountIds: string[]): DeviceSessionState => ({
|
|
5
|
+
deviceId: 'd1',
|
|
6
|
+
accounts: accountIds.map((id, i) => ({ accountId: id, sessionId: `s-${id}`, authuser: i })),
|
|
7
|
+
activeAccountId: active,
|
|
8
|
+
revision: rev,
|
|
9
|
+
updatedAt: 1720000000000,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const sync = (state: DeviceSessionState) => ({ state, activeToken: { accessToken: `jwt-${state.revision}`, expiresAt: 'x' } });
|
|
13
|
+
|
|
14
|
+
function makeHost(makeRequest: jest.Mock, currentAccountId: string | null = null): SessionClientHost {
|
|
15
|
+
return {
|
|
16
|
+
makeRequest,
|
|
17
|
+
getBaseURL: () => 'http://test.invalid',
|
|
18
|
+
getAccessToken: () => 't',
|
|
19
|
+
onTokensChanged: () => () => undefined,
|
|
20
|
+
setTokens: jest.fn(),
|
|
21
|
+
getCurrentAccountId: () => currentAccountId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class TestClient extends SessionClient {
|
|
26
|
+
public apply(raw: unknown): boolean {
|
|
27
|
+
return this.applyState(raw);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('SessionClient.registerAndActivate', () => {
|
|
32
|
+
it('adds then switches to the explicit target when it is not already active', async () => {
|
|
33
|
+
const makeRequest = jest
|
|
34
|
+
.fn()
|
|
35
|
+
// add → device set has a1 active, a2 present but not active
|
|
36
|
+
.mockResolvedValueOnce(sync(stateWith(1, 'a1', ['a1', 'a2'])))
|
|
37
|
+
// switch → a2 becomes active
|
|
38
|
+
.mockResolvedValueOnce(sync(stateWith(2, 'a2', ['a1', 'a2'])));
|
|
39
|
+
const c = new SessionClient(makeHost(makeRequest));
|
|
40
|
+
|
|
41
|
+
await c.registerAndActivate('a2');
|
|
42
|
+
|
|
43
|
+
expect(makeRequest).toHaveBeenNthCalledWith(1, 'POST', '/session/device/add', undefined, { cache: false });
|
|
44
|
+
expect(makeRequest).toHaveBeenNthCalledWith(2, 'POST', '/session/device/switch', { accountId: 'a2' }, { cache: false });
|
|
45
|
+
expect(c.getState()?.activeAccountId).toBe('a2');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('falls back to the host current-account ref when no target is passed', async () => {
|
|
49
|
+
const makeRequest = jest
|
|
50
|
+
.fn()
|
|
51
|
+
.mockResolvedValueOnce(sync(stateWith(1, 'a1', ['a1', 'a2'])))
|
|
52
|
+
.mockResolvedValueOnce(sync(stateWith(2, 'a2', ['a1', 'a2'])));
|
|
53
|
+
const c = new SessionClient(makeHost(makeRequest, 'a2'));
|
|
54
|
+
|
|
55
|
+
await c.registerAndActivate();
|
|
56
|
+
|
|
57
|
+
expect(makeRequest).toHaveBeenNthCalledWith(2, 'POST', '/session/device/switch', { accountId: 'a2' }, { cache: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does NOT switch when the added account is already active', async () => {
|
|
61
|
+
const makeRequest = jest.fn().mockResolvedValueOnce(sync(stateWith(1, 'a1', ['a1'])));
|
|
62
|
+
const c = new SessionClient(makeHost(makeRequest));
|
|
63
|
+
|
|
64
|
+
await c.registerAndActivate('a1');
|
|
65
|
+
|
|
66
|
+
expect(makeRequest).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/add', undefined, { cache: false });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('SessionClient onUnauthenticated', () => {
|
|
72
|
+
it('fires when an applied state has zero accounts (device signout-all)', () => {
|
|
73
|
+
const onUnauthenticated = jest.fn();
|
|
74
|
+
const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
|
|
75
|
+
|
|
76
|
+
c.apply(stateWith(1, 'a1', ['a1']));
|
|
77
|
+
expect(onUnauthenticated).not.toHaveBeenCalled();
|
|
78
|
+
|
|
79
|
+
c.apply(stateWith(2, null, []));
|
|
80
|
+
expect(onUnauthenticated).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not fire for a stale (non-applied) empty state', () => {
|
|
84
|
+
const onUnauthenticated = jest.fn();
|
|
85
|
+
const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
|
|
86
|
+
|
|
87
|
+
c.apply(stateWith(5, 'a1', ['a1']));
|
|
88
|
+
// revision 4 <= 5 → rejected, so onUnauthenticated must NOT fire.
|
|
89
|
+
c.apply(stateWith(4, null, []));
|
|
90
|
+
expect(onUnauthenticated).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -77,4 +77,29 @@ describe('SessionClient REST', () => {
|
|
|
77
77
|
expect(c.getState()?.revision).toBe(7);
|
|
78
78
|
expect(host.setTokens).not.toHaveBeenCalled();
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('accepts a NEW device\'s lower-revision state and plants its token (cross-device revision reset)', async () => {
|
|
82
|
+
const deviceA: DeviceSessionState = {
|
|
83
|
+
deviceId: 'A', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: 10, updatedAt: 1720000000000,
|
|
84
|
+
};
|
|
85
|
+
const deviceB: DeviceSessionState = {
|
|
86
|
+
deviceId: 'B', accounts: [{ accountId: 'a2', sessionId: 's2', authuser: 0 }], activeAccountId: 'a2', revision: 1, updatedAt: 1720000001000,
|
|
87
|
+
};
|
|
88
|
+
const makeRequest = jest.fn()
|
|
89
|
+
.mockResolvedValueOnce({ state: deviceA, activeToken: { accessToken: 'jwt-A', expiresAt: 'x' } })
|
|
90
|
+
.mockResolvedValueOnce({ state: deviceB, activeToken: { accessToken: 'jwt-B', expiresAt: 'x' } });
|
|
91
|
+
const host = makeHost(makeRequest);
|
|
92
|
+
const c = new SessionClient(host);
|
|
93
|
+
|
|
94
|
+
await c.bootstrap();
|
|
95
|
+
expect(c.getState()?.deviceId).toBe('A');
|
|
96
|
+
expect(host.setTokens).toHaveBeenLastCalledWith('jwt-A');
|
|
97
|
+
|
|
98
|
+
// Device B is freshly converged (revision 1) — it must win over device A's
|
|
99
|
+
// stale-but-higher revision, and its token must be planted.
|
|
100
|
+
await c.bootstrap();
|
|
101
|
+
expect(c.getState()?.deviceId).toBe('B');
|
|
102
|
+
expect(c.getState()?.revision).toBe(1);
|
|
103
|
+
expect(host.setTokens).toHaveBeenLastCalledWith('jwt-B');
|
|
104
|
+
});
|
|
80
105
|
});
|
|
@@ -32,12 +32,25 @@ describe('SessionClient state', () => {
|
|
|
32
32
|
expect(seen.at(-1)?.revision).toBe(1);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it('ignores a stale or equal revision (last-writer-wins)', () => {
|
|
35
|
+
it('ignores a stale or equal revision (last-writer-wins) WITHIN the same device', () => {
|
|
36
36
|
const c = new TestClient(makeHost());
|
|
37
|
-
c.apply(STATE(5));
|
|
38
|
-
expect(c.apply(STATE(5))).toBe(false);
|
|
39
|
-
expect(c.apply(STATE(4))).toBe(false);
|
|
40
|
-
expect(c.
|
|
37
|
+
c.apply({ ...STATE(5), deviceId: 'A' });
|
|
38
|
+
expect(c.apply({ ...STATE(5), deviceId: 'A' })).toBe(false);
|
|
39
|
+
expect(c.apply({ ...STATE(4), deviceId: 'A' })).toBe(false);
|
|
40
|
+
expect(c.apply({ ...STATE(6), deviceId: 'A' })).toBe(true);
|
|
41
|
+
expect(c.getState()?.revision).toBe(6);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accepts a LOWER-revision state from a DIFFERENT device (revision baseline resets cross-device)', () => {
|
|
45
|
+
const c = new TestClient(makeHost());
|
|
46
|
+
// Device A at a high revision.
|
|
47
|
+
expect(c.apply({ ...STATE(10), deviceId: 'A' })).toBe(true);
|
|
48
|
+
expect(c.getState()?.deviceId).toBe('A');
|
|
49
|
+
// Device B, freshly converged (revision 1), must win over device A's
|
|
50
|
+
// stale-but-higher revision — the cross-device comparison is not monotone.
|
|
51
|
+
expect(c.apply({ ...STATE(1), deviceId: 'B' })).toBe(true);
|
|
52
|
+
expect(c.getState()?.deviceId).toBe('B');
|
|
53
|
+
expect(c.getState()?.revision).toBe(1);
|
|
41
54
|
});
|
|
42
55
|
|
|
43
56
|
it('rejects an invalid (unvalidated) state without applying', () => {
|