@oxyhq/core 12.10.6 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mintFromDeviceSecret` — the pinned (identity-bound) mint.
|
|
3
|
+
*
|
|
4
|
+
* The pinned form adds `accountId` to the zero-cookie mint body so an identity
|
|
5
|
+
* vault gets ITS account's token without the device's `activeAccountId` being
|
|
6
|
+
* touched. Its one new failure mode — `401 account_not_on_device` — must be
|
|
7
|
+
* distinguishable from a bad device secret, because the remedies are opposite:
|
|
8
|
+
* a bad secret is dropped, a stale identity binding is re-established while the
|
|
9
|
+
* (healthy) secret is kept.
|
|
10
|
+
*/
|
|
11
|
+
import { OxyServices } from '../../OxyServices';
|
|
12
|
+
import { AccountNotOnDeviceError } from '../OxyServices.deviceBoot';
|
|
13
|
+
|
|
14
|
+
const MINT_RESPONSE = {
|
|
15
|
+
accessToken: 'access-1',
|
|
16
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
17
|
+
nextDeviceSecret: 'ds-next',
|
|
18
|
+
state: {
|
|
19
|
+
deviceId: 'dev-1',
|
|
20
|
+
accounts: [{ accountId: 'vault-user', sessionId: 'sess-vault', authuser: 0 }],
|
|
21
|
+
activeAccountId: 'other-user',
|
|
22
|
+
revision: 3,
|
|
23
|
+
updatedAt: 1_700_000_000_000,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('OxyServices.mintFromDeviceSecret', () => {
|
|
28
|
+
let oxy: OxyServices;
|
|
29
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
33
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('omits accountId from the body when unpinned', async () => {
|
|
41
|
+
makeRequestSpy.mockResolvedValue(MINT_RESPONSE);
|
|
42
|
+
|
|
43
|
+
await oxy.mintFromDeviceSecret('dev-1', 'ds-1');
|
|
44
|
+
|
|
45
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
46
|
+
'POST',
|
|
47
|
+
'/session/device/token',
|
|
48
|
+
{ deviceId: 'dev-1', deviceSecret: 'ds-1' },
|
|
49
|
+
expect.objectContaining({ cache: false, skipAuth: true, retry: false, bypassQueue: true }),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sends accountId when pinned', async () => {
|
|
54
|
+
makeRequestSpy.mockResolvedValue(MINT_RESPONSE);
|
|
55
|
+
|
|
56
|
+
const mint = await oxy.mintFromDeviceSecret('dev-1', 'ds-1', { accountId: 'vault-user' });
|
|
57
|
+
|
|
58
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
59
|
+
'POST',
|
|
60
|
+
'/session/device/token',
|
|
61
|
+
{ deviceId: 'dev-1', deviceSecret: 'ds-1', accountId: 'vault-user' },
|
|
62
|
+
expect.anything(),
|
|
63
|
+
);
|
|
64
|
+
// The response still reports the device's TRUE active account — a pinned
|
|
65
|
+
// mint never mutates it.
|
|
66
|
+
expect(mint.state.activeAccountId).toBe('other-user');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('maps a 401 account_not_on_device to the typed AccountNotOnDeviceError', async () => {
|
|
70
|
+
makeRequestSpy.mockRejectedValue(
|
|
71
|
+
Object.assign(new Error('account_not_on_device'), { status: 401, code: 'UNAUTHORIZED' }),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const error = await oxy
|
|
75
|
+
.mintFromDeviceSecret('dev-1', 'ds-1', { accountId: 'vault-user' })
|
|
76
|
+
.then(
|
|
77
|
+
() => null,
|
|
78
|
+
(thrown: unknown) => thrown,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(error).toBeInstanceOf(AccountNotOnDeviceError);
|
|
82
|
+
expect(error).toMatchObject({ name: 'AccountNotOnDeviceError', accountId: 'vault-user', status: 401 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('leaves every OTHER 401 as an ordinary error (a bad secret is not an identity problem)', async () => {
|
|
86
|
+
makeRequestSpy.mockRejectedValue(
|
|
87
|
+
Object.assign(new Error('invalid_device_secret'), { status: 401, code: 'UNAUTHORIZED' }),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const error = await oxy
|
|
91
|
+
.mintFromDeviceSecret('dev-1', 'ds-1', { accountId: 'vault-user' })
|
|
92
|
+
.then(
|
|
93
|
+
() => null,
|
|
94
|
+
(thrown: unknown) => thrown,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(error).not.toBeInstanceOf(AccountNotOnDeviceError);
|
|
98
|
+
expect(error).toMatchObject({ message: 'invalid_device_secret', status: 401 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not classify an UNPINNED mint failure as account_not_on_device', async () => {
|
|
102
|
+
makeRequestSpy.mockRejectedValue(
|
|
103
|
+
Object.assign(new Error('account_not_on_device'), { status: 401, code: 'UNAUTHORIZED' }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const error = await oxy.mintFromDeviceSecret('dev-1', 'ds-1').then(
|
|
107
|
+
() => null,
|
|
108
|
+
(thrown: unknown) => thrown,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(error).not.toBeInstanceOf(AccountNotOnDeviceError);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -128,6 +128,7 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
128
128
|
makeRequest.mockResolvedValueOnce({
|
|
129
129
|
sessionId: 's1',
|
|
130
130
|
deviceId: 'd1',
|
|
131
|
+
deviceSecret: 'ds_secret',
|
|
131
132
|
accessToken: 'tok',
|
|
132
133
|
user: { id: 'u1' },
|
|
133
134
|
});
|
|
@@ -149,4 +150,21 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
149
150
|
expect.objectContaining({ skipAuth: true }),
|
|
150
151
|
);
|
|
151
152
|
});
|
|
153
|
+
|
|
154
|
+
it('exchangeOAuthCode rejects a response without deviceSecret', async () => {
|
|
155
|
+
makeRequest.mockResolvedValueOnce({
|
|
156
|
+
sessionId: 's1',
|
|
157
|
+
deviceId: 'd1',
|
|
158
|
+
accessToken: 'tok',
|
|
159
|
+
user: { id: 'u1' },
|
|
160
|
+
});
|
|
161
|
+
await expect(
|
|
162
|
+
oxy.exchangeOAuthCode({
|
|
163
|
+
code: 'code-1',
|
|
164
|
+
clientId: 'oxy_dk_test',
|
|
165
|
+
redirectUri: 'https://app.example/callback',
|
|
166
|
+
codeVerifier: 'verifier',
|
|
167
|
+
}),
|
|
168
|
+
).rejects.toThrow(/incomplete session payload/i);
|
|
169
|
+
});
|
|
152
170
|
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility';
|
|
|
25
25
|
import { OxyServicesFeaturesMixin } from './OxyServices.features';
|
|
26
26
|
import { OxyServicesTopicsMixin } from './OxyServices.topics';
|
|
27
27
|
import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
28
|
+
import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
|
|
28
29
|
import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
29
30
|
import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
30
31
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
@@ -60,6 +61,7 @@ type AllMixinInstances =
|
|
|
60
61
|
& InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>>
|
|
61
62
|
& InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>>
|
|
62
63
|
& InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
|
|
64
|
+
& InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>>
|
|
63
65
|
& InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
|
|
64
66
|
& InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
|
|
65
67
|
& InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
|
|
@@ -126,6 +128,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
126
128
|
OxyServicesFeaturesMixin,
|
|
127
129
|
OxyServicesTopicsMixin,
|
|
128
130
|
OxyServicesContactsMixin,
|
|
131
|
+
// Push-token registration: the one SDK-owned register/unregister pair every
|
|
132
|
+
// Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
|
|
133
|
+
// known Commons installation instead of falling back to a QR.
|
|
134
|
+
OxyServicesNotificationsMixin,
|
|
129
135
|
OxyServicesAppDataMixin,
|
|
130
136
|
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
131
137
|
OxyServicesCivicMixin,
|
|
@@ -79,6 +79,22 @@ export interface SessionClientOptions {
|
|
|
79
79
|
* absent it falls back to `getSocketIO()`.
|
|
80
80
|
*/
|
|
81
81
|
socketFactory?: SocketIOFactory;
|
|
82
|
+
/**
|
|
83
|
+
* The PINNED account id for an IDENTITY-BOUND client (the identity vault),
|
|
84
|
+
* or `null` for every ordinary account-mode client. Read as a function because
|
|
85
|
+
* the pin is resolved asynchronously at boot and can move when the identity
|
|
86
|
+
* session is re-established.
|
|
87
|
+
*
|
|
88
|
+
* While pinned this client tracks device state TRUTHFULLY — other apps'
|
|
89
|
+
* accounts and the device's real `activeAccountId` stay visible in `getState()`
|
|
90
|
+
* — but it NEVER re-binds its bearer to that active account: no `activeToken`
|
|
91
|
+
* is planted for a non-pinned account (from a sync response or a pushed
|
|
92
|
+
* `session_state`), the `TokenTransport` (whose entire job is converging on
|
|
93
|
+
* `activeAccountId`) is bypassed, and a push that switches the device does not
|
|
94
|
+
* trigger a token re-fetch. The pinned token's lifecycle belongs solely to the
|
|
95
|
+
* cold boot / re-mint lane, which mints it with an explicit `accountId`.
|
|
96
|
+
*/
|
|
97
|
+
getPinnedAccountId?: () => string | null;
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
@@ -122,6 +138,16 @@ export class SessionClient {
|
|
|
122
138
|
return this.state;
|
|
123
139
|
}
|
|
124
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The account this client's bearer is pinned to, or `null` when it follows the
|
|
143
|
+
* device's active account (the default). Resolvers are expected to be a plain
|
|
144
|
+
* synchronous read of already-resolved state (see
|
|
145
|
+
* {@link SessionClientOptions.getPinnedAccountId}).
|
|
146
|
+
*/
|
|
147
|
+
private pinnedAccountId(): string | null {
|
|
148
|
+
return this.options.getPinnedAccountId?.() ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
125
151
|
subscribe(listener: StateListener): () => void {
|
|
126
152
|
this.listeners.add(listener);
|
|
127
153
|
return () => {
|
|
@@ -212,15 +238,28 @@ export class SessionClient {
|
|
|
212
238
|
}
|
|
213
239
|
const previousState = this.state;
|
|
214
240
|
this.state = next;
|
|
241
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
215
242
|
// Plant the sync-supplied active token (it is for `next.activeAccountId`)
|
|
216
243
|
// now — before the notify below — so the bearer matches the new active
|
|
217
244
|
// account when subscribers observe it. Guarded on difference to avoid a
|
|
218
245
|
// redundant token-change notification on an unchanged token (bootstrap
|
|
219
|
-
// restate).
|
|
220
|
-
|
|
246
|
+
// restate). An identity-bound client only accepts it when the active account
|
|
247
|
+
// IS its pinned account — otherwise the token belongs to somebody else's
|
|
248
|
+
// switch and must never displace the pinned bearer.
|
|
249
|
+
if (
|
|
250
|
+
activeToken &&
|
|
251
|
+
next.activeAccountId !== null &&
|
|
252
|
+
(pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
|
|
253
|
+
activeToken !== this.host.getAccessToken()
|
|
254
|
+
) {
|
|
221
255
|
this.host.setTokens(activeToken);
|
|
222
256
|
}
|
|
223
|
-
|
|
257
|
+
// The transport's entire job is converging the bearer on `activeAccountId`,
|
|
258
|
+
// which is precisely what an identity-bound client must not do: its token is
|
|
259
|
+
// minted for the pinned account by the cold boot / re-mint lane. Bypass it
|
|
260
|
+
// while pinned (also removing the mint-before-notify gate, which exists only
|
|
261
|
+
// to keep the bearer and the ACTIVE account in step).
|
|
262
|
+
const transport = pinnedAccountId === null ? this.options.transport : null;
|
|
224
263
|
const activeAccountId = next.activeAccountId;
|
|
225
264
|
// Mint before notifying when the bearer does not already belong to the new
|
|
226
265
|
// active account: no bearer at all, an opaque bearer, OR a bearer for a
|
|
@@ -296,13 +335,16 @@ export class SessionClient {
|
|
|
296
335
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
297
336
|
// needs planting. Guard on the sync's active account STILL being the current
|
|
298
337
|
// active account so a stale response cannot adopt a token for an account a
|
|
299
|
-
// newer state already switched away from
|
|
338
|
+
// newer state already switched away from — and, when pinned, on that account
|
|
339
|
+
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
340
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
300
341
|
if (
|
|
301
342
|
!applied &&
|
|
302
343
|
sync.activeToken &&
|
|
303
344
|
this.state &&
|
|
304
345
|
sync.state.activeAccountId !== null &&
|
|
305
346
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
347
|
+
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
306
348
|
sync.activeToken.accessToken !== this.host.getAccessToken()
|
|
307
349
|
) {
|
|
308
350
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
@@ -342,12 +384,18 @@ export class SessionClient {
|
|
|
342
384
|
* sign-in. This adds, then switches to the target so the UI lands on the
|
|
343
385
|
* account the user just authenticated.
|
|
344
386
|
*
|
|
387
|
+
* An identity-bound (pinned) client only ADDS: its own session is minted for
|
|
388
|
+
* the pinned account explicitly, so switching the device would gratuitously
|
|
389
|
+
* re-elect the active account under every OTHER app on this device — a
|
|
390
|
+
* mutation a pinned client must never make.
|
|
391
|
+
*
|
|
345
392
|
* @param accountId - The signed-in account id (e.g. `session.user.id`). When
|
|
346
393
|
* omitted, falls back to the host's current-account ref. If neither
|
|
347
394
|
* resolves, the add still applies and no switch is performed.
|
|
348
395
|
*/
|
|
349
396
|
async registerAndActivate(accountId?: string): Promise<void> {
|
|
350
397
|
await this.addCurrentAccount();
|
|
398
|
+
if (this.pinnedAccountId() !== null) return;
|
|
351
399
|
const target = accountId ?? this.host.getCurrentAccountId();
|
|
352
400
|
if (target && this.state?.activeAccountId !== target) {
|
|
353
401
|
await this.switchAccount(target);
|
|
@@ -449,6 +497,11 @@ export class SessionClient {
|
|
|
449
497
|
// empty state here must not erase the durable device credential.
|
|
450
498
|
const applied = this.applyState(payload, 'push');
|
|
451
499
|
if (!applied) return;
|
|
500
|
+
// An identity-bound client tracks the pushed state but NEVER chases the
|
|
501
|
+
// device's active account: re-fetching here would only pull an
|
|
502
|
+
// `activeToken` for somebody else's switch, which the plant guards would
|
|
503
|
+
// then discard. Skip the round-trip entirely.
|
|
504
|
+
if (this.pinnedAccountId() !== null) return;
|
|
452
505
|
// A push changed the active account on another device/tab — re-fetch state
|
|
453
506
|
// to plant the access token for the newly-active account. When this tab is
|
|
454
507
|
// still signed out, applyState mints via ensureActiveToken first; bootstrap
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SessionClient` under an IDENTITY pin.
|
|
3
|
+
*
|
|
4
|
+
* The bug this guards: an account switch performed by ANOTHER app on the same
|
|
5
|
+
* device arrives as a `session_state` push (or as an `activeToken` on a sync
|
|
6
|
+
* response) and silently re-bound the identity vault's bearer to that account.
|
|
7
|
+
*
|
|
8
|
+
* The contract: device state is still tracked TRUTHFULLY (other apps' accounts
|
|
9
|
+
* and the real `activeAccountId` remain visible in `getState()`), but the bearer
|
|
10
|
+
* NEVER follows it. The pinned token's lifecycle belongs to the cold boot /
|
|
11
|
+
* re-mint lane, which mints it with an explicit `accountId`.
|
|
12
|
+
*/
|
|
13
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
14
|
+
import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
|
|
15
|
+
|
|
16
|
+
const PINNED = 'vault-user';
|
|
17
|
+
const OTHER = 'other-user';
|
|
18
|
+
|
|
19
|
+
/** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
|
|
20
|
+
function jwtFor(accountId: string): string {
|
|
21
|
+
const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
|
|
22
|
+
return `h.${payload}.s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stateWith = (revision: number, active: string): DeviceSessionState => ({
|
|
26
|
+
deviceId: 'dev-1',
|
|
27
|
+
accounts: [
|
|
28
|
+
{ accountId: PINNED, sessionId: 'sess-vault', authuser: 0 },
|
|
29
|
+
{ accountId: OTHER, sessionId: 'sess-other', authuser: 1 },
|
|
30
|
+
],
|
|
31
|
+
activeAccountId: active,
|
|
32
|
+
revision,
|
|
33
|
+
updatedAt: 1_720_000_000_000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function makeHost(initialToken: string | null): SessionClientHost & { planted: () => string | null } {
|
|
37
|
+
let planted = initialToken;
|
|
38
|
+
return {
|
|
39
|
+
makeRequest: jest.fn(),
|
|
40
|
+
getBaseURL: () => 'http://test.invalid',
|
|
41
|
+
getAccessToken: () => planted,
|
|
42
|
+
getDeviceCredential: () => null,
|
|
43
|
+
onTokensChanged: () => () => undefined,
|
|
44
|
+
setTokens: (token: string) => {
|
|
45
|
+
planted = token;
|
|
46
|
+
},
|
|
47
|
+
getCurrentAccountId: () => PINNED,
|
|
48
|
+
planted: () => planted,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `applyState` is protected; a tiny subclass exposes it for the unit tests. */
|
|
53
|
+
class TestClient extends SessionClient {
|
|
54
|
+
public apply(raw: unknown, origin?: 'request' | 'push', activeToken?: string): boolean {
|
|
55
|
+
return this.applyState(raw, origin, activeToken);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('SessionClient — pinned bearer', () => {
|
|
60
|
+
// Node's real `BroadcastChannel` (which `postCommitPing` opens) is ref'd and
|
|
61
|
+
// would keep the Jest event loop alive forever. Feature-detect it away — the
|
|
62
|
+
// cross-tab wake has its own suite.
|
|
63
|
+
const realBroadcastChannel = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel;
|
|
64
|
+
beforeAll(() => {
|
|
65
|
+
(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
|
|
66
|
+
});
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = realBroadcastChannel;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('tracks a pushed switch in state but does NOT change the planted token', () => {
|
|
72
|
+
const host = makeHost(jwtFor(PINNED));
|
|
73
|
+
const client = new TestClient(host, { getPinnedAccountId: () => PINNED });
|
|
74
|
+
|
|
75
|
+
expect(client.apply(stateWith(1, PINNED), 'push')).toBe(true);
|
|
76
|
+
// Another app switched the device to `other-user`.
|
|
77
|
+
expect(client.apply(stateWith(2, OTHER), 'push')).toBe(true);
|
|
78
|
+
|
|
79
|
+
// The state is truthful...
|
|
80
|
+
expect(client.getState()?.activeAccountId).toBe(OTHER);
|
|
81
|
+
// ...and the bearer is untouched.
|
|
82
|
+
expect(host.planted()).toBe(jwtFor(PINNED));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does NOT plant a sync-supplied activeToken for a non-pinned account', () => {
|
|
86
|
+
const host = makeHost(jwtFor(PINNED));
|
|
87
|
+
const client = new TestClient(host, { getPinnedAccountId: () => PINNED });
|
|
88
|
+
|
|
89
|
+
client.apply(stateWith(3, OTHER), 'request', jwtFor(OTHER));
|
|
90
|
+
|
|
91
|
+
expect(host.planted()).toBe(jwtFor(PINNED));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('DOES plant a sync-supplied activeToken when the active account IS the pinned one', () => {
|
|
95
|
+
const host = makeHost(null);
|
|
96
|
+
const client = new TestClient(host, { getPinnedAccountId: () => PINNED });
|
|
97
|
+
|
|
98
|
+
client.apply(stateWith(4, PINNED), 'request', jwtFor(PINNED));
|
|
99
|
+
|
|
100
|
+
expect(host.planted()).toBe(jwtFor(PINNED));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('never invokes the TokenTransport (whose job is converging on activeAccountId)', () => {
|
|
104
|
+
const host = makeHost(jwtFor(PINNED));
|
|
105
|
+
const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
|
|
106
|
+
const client = new TestClient(host, { transport, getPinnedAccountId: () => PINNED });
|
|
107
|
+
|
|
108
|
+
client.apply(stateWith(5, OTHER), 'push');
|
|
109
|
+
|
|
110
|
+
expect(transport.ensureActiveToken).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('notifies subscribers synchronously on a foreign switch (no mint-before-notify gate)', () => {
|
|
114
|
+
const host = makeHost(jwtFor(PINNED));
|
|
115
|
+
const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
|
|
116
|
+
const client = new TestClient(host, { transport, getPinnedAccountId: () => PINNED });
|
|
117
|
+
|
|
118
|
+
const seen: Array<string | null> = [];
|
|
119
|
+
client.subscribe((state) => seen.push(state?.activeAccountId ?? null));
|
|
120
|
+
|
|
121
|
+
client.apply(stateWith(6, OTHER), 'push');
|
|
122
|
+
|
|
123
|
+
expect(seen).toEqual([OTHER]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('account mode is unchanged: an activeToken for the new active account IS planted', () => {
|
|
127
|
+
const host = makeHost(jwtFor(PINNED));
|
|
128
|
+
const client = new TestClient(host);
|
|
129
|
+
|
|
130
|
+
client.apply(stateWith(7, OTHER), 'request', jwtFor(OTHER));
|
|
131
|
+
|
|
132
|
+
expect(host.planted()).toBe(jwtFor(OTHER));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('registerAndActivate only ADDS while pinned (never re-elects the device active account)', async () => {
|
|
136
|
+
const host = makeHost(jwtFor(PINNED));
|
|
137
|
+
const makeRequest = jest.fn().mockResolvedValue({
|
|
138
|
+
state: stateWith(8, OTHER),
|
|
139
|
+
activeToken: null,
|
|
140
|
+
});
|
|
141
|
+
const client = new SessionClient(
|
|
142
|
+
{ ...host, makeRequest },
|
|
143
|
+
{ getPinnedAccountId: () => PINNED },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await client.registerAndActivate(PINNED);
|
|
147
|
+
|
|
148
|
+
expect(makeRequest).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/add', undefined, { cache: false });
|
|
150
|
+
expect(makeRequest).not.toHaveBeenCalledWith(
|
|
151
|
+
'POST',
|
|
152
|
+
'/session/device/switch',
|
|
153
|
+
expect.anything(),
|
|
154
|
+
expect.anything(),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('account mode is unchanged: registerAndActivate still switches to the target', async () => {
|
|
159
|
+
const host = makeHost(jwtFor(PINNED));
|
|
160
|
+
const makeRequest = jest.fn().mockResolvedValue({
|
|
161
|
+
state: stateWith(9, OTHER),
|
|
162
|
+
activeToken: null,
|
|
163
|
+
});
|
|
164
|
+
const client = new SessionClient({ ...host, makeRequest });
|
|
165
|
+
|
|
166
|
+
await client.registerAndActivate(PINNED);
|
|
167
|
+
|
|
168
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
169
|
+
'POST',
|
|
170
|
+
'/session/device/switch',
|
|
171
|
+
{ accountId: PINNED },
|
|
172
|
+
{ cache: false },
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|