@oxyhq/core 8.1.0 → 9.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/boot/coldBootV2.js +22 -332
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +9 -89
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -71
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +23 -330
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +8 -88
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -71
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
- package/dist/types/session/SessionClient.d.ts +4 -38
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +32 -57
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +2 -2
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +140 -353
- package/src/boot/coldBootV2.ts +35 -391
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +19 -142
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/session/SessionClient.ts +29 -120
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +29 -42
- package/src/session/__tests__/refresh.test.ts +66 -52
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +32 -126
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -92
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -167
- package/dist/esm/boot/deviceBootReturn.js +0 -161
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
- package/src/boot/deviceBootReturn.ts +0 -210
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -2,121 +2,35 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OxyServicesDeviceBootMixin = OxyServicesDeviceBootMixin;
|
|
4
4
|
/**
|
|
5
|
-
* Device-first
|
|
5
|
+
* Device-first token mint mixin.
|
|
6
6
|
*
|
|
7
|
-
* The client half of the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* The client half of the zero-cookie device transport: the single network call
|
|
8
|
+
* the cold boot (`coldBootV2`) and the unified re-mint handler (`refresh.ts`)
|
|
9
|
+
* make to turn a first-party `deviceId` + `deviceSecret` into a fresh access
|
|
10
|
+
* token. The response is validated against the `@oxyhq/contracts`
|
|
11
|
+
* `deviceTokenMintResponseSchema`, so producer (oxy-api) and consumer cannot
|
|
12
12
|
* drift — an unexpected shape throws here rather than silently corrupting the
|
|
13
13
|
* persisted store.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* and `setTokens`, so the same network primitive can be reused from either
|
|
19
|
-
* without double-planting.
|
|
15
|
+
* This method carries NO persistence or token-planting side effects of its own;
|
|
16
|
+
* the cold boot / re-mint handler own persistence and `setTokens`, so the same
|
|
17
|
+
* primitive can be reused from either without double-planting.
|
|
20
18
|
*/
|
|
21
19
|
const contracts_1 = require("@oxyhq/contracts");
|
|
22
20
|
function OxyServicesDeviceBootMixin(Base) {
|
|
23
21
|
return class extends Base {
|
|
24
22
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* Zero-cookie mint. Present the first-party `deviceId` + `deviceSecret` to
|
|
24
|
+
* `POST /session/device/token` — NO bearer, NO cookies: possession of the
|
|
25
|
+
* secret IS the device-ownership proof. Returns a fresh short access token
|
|
26
|
+
* for the device's active account plus `nextDeviceSecret` (rotation-in-use)
|
|
27
|
+
* and the projected device-session `state`.
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const parsed = (0, contracts_1.safeParseContract)(contracts_1.authTokenBundleSchema, res);
|
|
34
|
-
if (!parsed) {
|
|
35
|
-
throw new Error('device/exchange returned an unexpected response shape');
|
|
36
|
-
}
|
|
37
|
-
return parsed;
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
throw this.handleError(error);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Same-site fast path (`*.oxy.so` apps). Reads the first-party `oxy_device`
|
|
45
|
-
* cookie via a credentialed same-origin fetch and either resolves a full
|
|
46
|
-
* session bundle or reports the device is known-but-signed-out. Never
|
|
47
|
-
* redirects.
|
|
48
|
-
*
|
|
49
|
-
* @throws if the response matches neither arm of {@link webSessionResultSchema}.
|
|
50
|
-
*/
|
|
51
|
-
async requestWebSession() {
|
|
52
|
-
try {
|
|
53
|
-
const res = await this.makeRequest('POST', '/auth/device/web-session', undefined, { cache: false });
|
|
54
|
-
const parsed = (0, contracts_1.safeParseContract)(contracts_1.webSessionResultSchema, res);
|
|
55
|
-
if (!parsed) {
|
|
56
|
-
throw new Error('device/web-session returned an unexpected response shape');
|
|
57
|
-
}
|
|
58
|
-
return parsed;
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
throw this.handleError(error);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Rotate the persisted refresh-token family (web + native, one call). The
|
|
66
|
-
* caller (refresh handler / cold boot) plants + persists the rotated pair.
|
|
67
|
-
*
|
|
68
|
-
* @throws if the response does not match {@link tokenRefreshResponseSchema}.
|
|
69
|
-
*/
|
|
70
|
-
async refreshWithToken(refreshToken) {
|
|
71
|
-
try {
|
|
72
|
-
const res = await this.makeRequest('POST', '/auth/refresh-token', { refreshToken },
|
|
73
|
-
// `skipAuth`: refresh-token is body-authenticated and is called from
|
|
74
|
-
// inside the refresh handler — sending the near-expired bearer through
|
|
75
|
-
// the preflight would deadlock. See RequestOptions.skipAuth.
|
|
76
|
-
{ cache: false, skipAuth: true });
|
|
77
|
-
const parsed = (0, contracts_1.safeParseContract)(contracts_1.tokenRefreshResponseSchema, res);
|
|
78
|
-
if (!parsed) {
|
|
79
|
-
throw new Error('auth/refresh-token returned an unexpected response shape');
|
|
80
|
-
}
|
|
81
|
-
return parsed;
|
|
82
|
-
}
|
|
83
|
-
catch (error) {
|
|
84
|
-
throw this.handleError(error);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Issue (or rotate) the native channel's opaque device token. Bearer-gated —
|
|
89
|
-
* the server derives the deviceId from the JWT. Native apps mirror the
|
|
90
|
-
* returned token into the shared keychain via
|
|
91
|
-
* `KeyManager.setSharedDeviceToken`.
|
|
92
|
-
*
|
|
93
|
-
* @throws if the response does not match {@link deviceTokenIssueResponseSchema}.
|
|
94
|
-
*/
|
|
95
|
-
async issueNativeDeviceToken() {
|
|
96
|
-
try {
|
|
97
|
-
const res = await this.makeRequest('POST', '/auth/device/token', undefined, { cache: false });
|
|
98
|
-
const parsed = (0, contracts_1.safeParseContract)(contracts_1.deviceTokenIssueResponseSchema, res);
|
|
99
|
-
if (!parsed) {
|
|
100
|
-
throw new Error('auth/device/token returned an unexpected response shape');
|
|
101
|
-
}
|
|
102
|
-
return parsed.deviceToken;
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
throw this.handleError(error);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
|
|
110
|
-
* `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
|
|
111
|
-
* possession of the secret IS the device-ownership proof. Returns a fresh
|
|
112
|
-
* short access token for the device's active account plus `nextDeviceSecret`
|
|
113
|
-
* (rotation-in-use) and the projected device-session `state`.
|
|
114
|
-
*
|
|
115
|
-
* `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
|
|
116
|
-
* a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
|
|
117
|
-
* retry dance (which would pointlessly rotate the refresh family). The cold
|
|
118
|
-
* boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
|
|
119
|
-
* decide whether to drop the secret and fall back or resolve signed-out.
|
|
29
|
+
* `skipAuth`: this call carries no bearer, so a 401 must surface DIRECTLY —
|
|
30
|
+
* never trigger `HttpService`'s 401→refresh→retry dance. The cold boot / re-
|
|
31
|
+
* mint handler read the 401 body (`invalid_device_secret` vs
|
|
32
|
+
* `no_active_session`) to decide whether to drop the secret and fall back or
|
|
33
|
+
* resolve signed-out.
|
|
120
34
|
*
|
|
121
35
|
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
122
36
|
*/
|
|
@@ -133,15 +47,5 @@ function OxyServicesDeviceBootMixin(Base) {
|
|
|
133
47
|
throw this.handleError(error);
|
|
134
48
|
}
|
|
135
49
|
}
|
|
136
|
-
/**
|
|
137
|
-
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
138
|
-
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
139
|
-
* echoes `state` back in the return fragment for CSRF verification.
|
|
140
|
-
*/
|
|
141
|
-
buildBootstrapUrl(returnTo, state) {
|
|
142
|
-
const base = this.getBaseURL().replace(/\/+$/, '');
|
|
143
|
-
const params = new URLSearchParams({ return_to: returnTo, state });
|
|
144
|
-
return `${base}/auth/device/bootstrap?${params.toString()}`;
|
|
145
|
-
}
|
|
146
50
|
};
|
|
147
51
|
}
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -79,9 +79,8 @@ const MIXIN_PIPELINE = [
|
|
|
79
79
|
// Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
|
|
80
80
|
// so apps stop scraping link metadata locally.
|
|
81
81
|
OxyServices_links_1.OxyServicesLinksMixin,
|
|
82
|
-
// Device-first
|
|
83
|
-
// /
|
|
84
|
-
// refresh, native device-token, bootstrap-url builder).
|
|
82
|
+
// Device-first token mint: the client half of the zero-cookie transport
|
|
83
|
+
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
85
84
|
OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
|
|
86
85
|
// Utility (last, can use all above)
|
|
87
86
|
OxyServices_utility_1.OxyServicesUtilityMixin,
|
|
@@ -5,11 +5,12 @@ const contracts_1 = require("@oxyhq/contracts");
|
|
|
5
5
|
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
6
6
|
const socketLoader_1 = require("./socketLoader");
|
|
7
7
|
/**
|
|
8
|
-
* Same-origin `BroadcastChannel` name for instant, network-free session
|
|
9
|
-
* across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Same-origin `BroadcastChannel` name for instant, network-free session-state
|
|
9
|
+
* propagation across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs):
|
|
10
|
+
* when one authenticated tab commits an account switch / sign-out, siblings
|
|
11
|
+
* re-sync their device state without waiting on the socket. Cross-origin
|
|
12
|
+
* same-apex propagation rides the authenticated device socket; cross-APEX
|
|
13
|
+
* (mention.earth) relies on a reload — the documented limitation.
|
|
13
14
|
*/
|
|
14
15
|
const SESSION_BROADCAST_CHANNEL = 'oxy.session';
|
|
15
16
|
class SessionClient {
|
|
@@ -21,11 +22,7 @@ class SessionClient {
|
|
|
21
22
|
this.socket = null;
|
|
22
23
|
this.tokenUnsub = null;
|
|
23
24
|
this.started = false;
|
|
24
|
-
/**
|
|
25
|
-
this.acquiring = false;
|
|
26
|
-
/** True while the live socket is an anonymous (signed-out) device connection. */
|
|
27
|
-
this.socketAnonymous = false;
|
|
28
|
-
/** Same-origin cross-tab wake channel; null on platforms without BroadcastChannel. */
|
|
25
|
+
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
29
26
|
this.channel = null;
|
|
30
27
|
}
|
|
31
28
|
getState() {
|
|
@@ -157,30 +154,20 @@ class SessionClient {
|
|
|
157
154
|
return;
|
|
158
155
|
this.started = true;
|
|
159
156
|
this.tokenUnsub = this.host.onTokensChanged((token) => {
|
|
160
|
-
|
|
157
|
+
// A rotated/fresh bearer landed — reconnect a dropped socket so its
|
|
158
|
+
// handshake re-runs with the current token. Sign-out (null token) is
|
|
159
|
+
// handled by the consumer calling `stop()`.
|
|
160
|
+
if (!token || !this.socket)
|
|
161
161
|
return;
|
|
162
|
-
|
|
163
|
-
// the guard so a later sign-out can re-acquire.
|
|
164
|
-
this.acquiring = false;
|
|
165
|
-
if (!this.socket)
|
|
166
|
-
return;
|
|
167
|
-
if (this.socketAnonymous) {
|
|
168
|
-
// The live socket was an anonymous (device-room-only) connection. Force a
|
|
169
|
-
// reconnect so the handshake re-runs authenticated and also joins the
|
|
170
|
-
// `user:<id>` notification room.
|
|
171
|
-
this.socketAnonymous = false;
|
|
172
|
-
this.socket.disconnect();
|
|
173
|
-
this.socket.connect();
|
|
174
|
-
}
|
|
175
|
-
else if (!this.socket.connected) {
|
|
162
|
+
if (!this.socket.connected) {
|
|
176
163
|
this.socket.connect();
|
|
177
164
|
}
|
|
178
165
|
});
|
|
179
166
|
this.openBroadcastChannel();
|
|
180
|
-
// `bootstrap` (`GET /session/device/state`) is bearer-authenticated
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
167
|
+
// `bootstrap` (`GET /session/device/state`) is bearer-authenticated. A
|
|
168
|
+
// signed-out client opens no socket and runs no bootstrap; a bootstrap
|
|
169
|
+
// failure is non-fatal — the socket still connects so realtime sync
|
|
170
|
+
// survives a transient state-fetch error.
|
|
184
171
|
if (this.host.getAccessToken()) {
|
|
185
172
|
try {
|
|
186
173
|
await this.bootstrap();
|
|
@@ -193,8 +180,6 @@ class SessionClient {
|
|
|
193
180
|
}
|
|
194
181
|
stop() {
|
|
195
182
|
this.started = false;
|
|
196
|
-
this.acquiring = false;
|
|
197
|
-
this.socketAnonymous = false;
|
|
198
183
|
if (this.tokenUnsub) {
|
|
199
184
|
this.tokenUnsub();
|
|
200
185
|
this.tokenUnsub = null;
|
|
@@ -225,52 +210,23 @@ class SessionClient {
|
|
|
225
210
|
}
|
|
226
211
|
if (!this.started)
|
|
227
212
|
return; // stopped while the dynamic import was in flight
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// and self-acquire when a sibling signs in.
|
|
233
|
-
let signedOutAuth = false;
|
|
234
|
-
if (!hasToken && this.options.signedOutSocketAuth) {
|
|
235
|
-
try {
|
|
236
|
-
signedOutAuth = await this.options.signedOutSocketAuth();
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
loggerUtils_1.logger.warn('[SessionClient] signedOutSocketAuth failed', { component: 'SessionClient' }, error);
|
|
240
|
-
signedOutAuth = false;
|
|
241
|
-
}
|
|
242
|
-
if (!this.started)
|
|
243
|
-
return; // stopped while the async gate was in flight
|
|
244
|
-
}
|
|
245
|
-
const signedOutConnect = signedOutAuth !== false && signedOutAuth != null;
|
|
246
|
-
const signedOutDeviceToken = typeof signedOutAuth === 'string' ? signedOutAuth : undefined;
|
|
247
|
-
this.socketAnonymous = !hasToken && signedOutConnect;
|
|
213
|
+
// Sockets are BEARER-ONLY: the server rejects any handshake without a valid
|
|
214
|
+
// bearer, so a signed-out client never opens a socket.
|
|
215
|
+
if (!this.host.getAccessToken())
|
|
216
|
+
return;
|
|
248
217
|
const socket = io(this.host.getBaseURL(), {
|
|
249
218
|
transports: ['websocket'],
|
|
250
|
-
|
|
251
|
-
// signed-out browser tab can be resolved to its device room server-side.
|
|
252
|
-
withCredentials: true,
|
|
253
|
-
autoConnect: hasToken || signedOutConnect,
|
|
219
|
+
autoConnect: true,
|
|
254
220
|
auth: (cb) => {
|
|
255
|
-
|
|
256
|
-
// Present the native device token only while signed out (no bearer). Once
|
|
257
|
-
// a token is held the bearer path wins and the deviceToken is redundant.
|
|
258
|
-
cb(token || !signedOutDeviceToken ? { token } : { token, deviceToken: signedOutDeviceToken });
|
|
221
|
+
cb({ token: this.host.getAccessToken() ?? '' });
|
|
259
222
|
},
|
|
260
223
|
});
|
|
261
224
|
socket.on('session_state', (payload) => {
|
|
262
225
|
const applied = this.applyState(payload);
|
|
263
226
|
if (!applied)
|
|
264
227
|
return;
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// reconnects the socket on the authenticated path.
|
|
268
|
-
if (!this.host.getAccessToken()) {
|
|
269
|
-
if ((this.state?.accounts.length ?? 0) > 0) {
|
|
270
|
-
this.requestAcquisition();
|
|
271
|
-
}
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
228
|
+
// A push changed the active account on another device/tab — re-fetch state
|
|
229
|
+
// to plant the access token for the newly-active account.
|
|
274
230
|
const active = this.state?.activeAccountId ?? null;
|
|
275
231
|
if (active && active !== this.host.getCurrentAccountId()) {
|
|
276
232
|
void this.bootstrap().catch((error) => {
|
|
@@ -280,35 +236,11 @@ class SessionClient {
|
|
|
280
236
|
});
|
|
281
237
|
this.socket = socket;
|
|
282
238
|
}
|
|
283
|
-
/**
|
|
284
|
-
* Run the consumer's session acquisition at most once at a time. A returned
|
|
285
|
-
* promise gates the next attempt (reset on settle), so a failed acquisition
|
|
286
|
-
* can retry on the NEXT push while a burst of identical pushes cannot pile up.
|
|
287
|
-
*/
|
|
288
|
-
requestAcquisition() {
|
|
289
|
-
if (this.acquiring || !this.options.onSessionAppeared)
|
|
290
|
-
return;
|
|
291
|
-
this.acquiring = true;
|
|
292
|
-
let result;
|
|
293
|
-
try {
|
|
294
|
-
result = this.options.onSessionAppeared();
|
|
295
|
-
}
|
|
296
|
-
catch (error) {
|
|
297
|
-
this.acquiring = false;
|
|
298
|
-
loggerUtils_1.logger.error('[SessionClient] onSessionAppeared threw', error);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
void Promise.resolve(result).catch((error) => {
|
|
302
|
-
loggerUtils_1.logger.warn('[SessionClient] onSessionAppeared rejected', { component: 'SessionClient' }, error);
|
|
303
|
-
}).finally(() => {
|
|
304
|
-
this.acquiring = false;
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
239
|
/**
|
|
308
240
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
309
|
-
* commits
|
|
310
|
-
*
|
|
311
|
-
*
|
|
241
|
+
* commits an account switch / sign-out posts a wake ping; on receipt an
|
|
242
|
+
* authenticated tab re-syncs its device state — instant + network-free for the
|
|
243
|
+
* common "two tabs of the same origin" case, with no state (and no tokens)
|
|
312
244
|
* ever crossing the channel. No-op on native (no BroadcastChannel).
|
|
313
245
|
*/
|
|
314
246
|
openBroadcastChannel() {
|
|
@@ -331,16 +263,13 @@ class SessionClient {
|
|
|
331
263
|
if (event.data.type !== 'commit')
|
|
332
264
|
return;
|
|
333
265
|
// BroadcastChannel never echoes to the posting context, so this is a
|
|
334
|
-
// sibling's commit.
|
|
335
|
-
//
|
|
266
|
+
// sibling's commit. An authenticated tab re-syncs its device state; the
|
|
267
|
+
// re-sync does not re-post, so there is no cross-tab ping loop.
|
|
336
268
|
if (this.host.getAccessToken()) {
|
|
337
269
|
void this.bootstrap().catch((error) => {
|
|
338
270
|
loggerUtils_1.logger.warn('[SessionClient] broadcast re-sync failed', { component: 'SessionClient' }, error);
|
|
339
271
|
});
|
|
340
272
|
}
|
|
341
|
-
else {
|
|
342
|
-
this.requestAcquisition();
|
|
343
|
-
}
|
|
344
273
|
};
|
|
345
274
|
this.channel = channel;
|
|
346
275
|
}
|
|
@@ -334,7 +334,6 @@ class AccountDialogController {
|
|
|
334
334
|
expiresAt: result.expiresAt,
|
|
335
335
|
user: result.user,
|
|
336
336
|
accessToken: result.accessToken,
|
|
337
|
-
...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
|
|
338
337
|
}, result.user);
|
|
339
338
|
}
|
|
340
339
|
// Re-project + refetch immediately; the subscription also fires.
|
|
@@ -510,7 +509,6 @@ class AccountDialogController {
|
|
|
510
509
|
name: claimed.user.name,
|
|
511
510
|
avatar: claimed.user.avatar ?? undefined,
|
|
512
511
|
};
|
|
513
|
-
const refreshToken = readRefreshToken(claimed);
|
|
514
512
|
try {
|
|
515
513
|
await this.completeSignIn({
|
|
516
514
|
sessionId: claimed.sessionId || sessionId,
|
|
@@ -518,7 +516,6 @@ class AccountDialogController {
|
|
|
518
516
|
expiresAt: claimed.expiresAt ?? '',
|
|
519
517
|
user: minimalUser,
|
|
520
518
|
accessToken: claimed.accessToken,
|
|
521
|
-
...(refreshToken ? { refreshToken } : {}),
|
|
522
519
|
}, minimalUser);
|
|
523
520
|
}
|
|
524
521
|
catch (error) {
|
|
@@ -609,16 +606,6 @@ function createAccountDialogController(options) {
|
|
|
609
606
|
// ---------------------------------------------------------------------------
|
|
610
607
|
// Local helpers
|
|
611
608
|
// ---------------------------------------------------------------------------
|
|
612
|
-
/**
|
|
613
|
-
* The rotating refresh-token family head is threaded on the runtime object by
|
|
614
|
-
* the trusted device-flow / switch lanes even though it is NOT on the typed
|
|
615
|
-
* return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
|
|
616
|
-
* the commit funnel can persist a durable session.
|
|
617
|
-
*/
|
|
618
|
-
function readRefreshToken(value) {
|
|
619
|
-
const token = value.refreshToken;
|
|
620
|
-
return typeof token === 'string' ? token : undefined;
|
|
621
|
-
}
|
|
622
609
|
/** Current document URL on web; empty string where `location` is absent (native/SSR). */
|
|
623
610
|
function currentLocationHref() {
|
|
624
611
|
const location = globalThis.location;
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Persisted auth state — ONE shape, web + native.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `load / save / clear` interface plus
|
|
9
|
-
*
|
|
10
|
-
* platform storage API directly.
|
|
5
|
+
* The zero-cookie device transport persists the device credential per ORIGIN so
|
|
6
|
+
* a reload restores the session locally without a redirect: `deviceId` +
|
|
7
|
+
* `deviceSecret` mint a fresh access token via `POST /session/device/token`.
|
|
8
|
+
* This module is the storage seam: a tiny `load / save / clear` interface plus
|
|
9
|
+
* platform factories, so the cold boot (`coldBootV2`) and the unified re-mint
|
|
10
|
+
* handler (`refresh.ts`) never touch a platform storage API directly.
|
|
11
11
|
*
|
|
12
12
|
* Platform-agnostic — the native factory takes an INJECTED key/value store
|
|
13
13
|
* (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* ESM-safe (no `require()`).
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.
|
|
21
|
+
exports.AUTH_STATE_STORAGE_KEY = void 0;
|
|
22
22
|
exports.createMemoryAuthStateStore = createMemoryAuthStateStore;
|
|
23
23
|
exports.createWebAuthStateStore = createWebAuthStateStore;
|
|
24
24
|
exports.createNativeAuthStateStore = createNativeAuthStateStore;
|
|
@@ -28,12 +28,6 @@ exports.createNativeAuthStateStore = createNativeAuthStateStore;
|
|
|
28
28
|
* `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
|
|
29
29
|
*/
|
|
30
30
|
exports.AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
31
|
-
/**
|
|
32
|
-
* Storage key for the long-lived device-attribution token. Separate from
|
|
33
|
-
* {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
|
|
34
|
-
* (sign-out) — the device is unchanged across sign-ins.
|
|
35
|
-
*/
|
|
36
|
-
exports.DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
|
|
37
31
|
/**
|
|
38
32
|
* Parse + shape-validate a stored blob. Returns `null` for anything that is
|
|
39
33
|
* not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
|
|
@@ -55,21 +49,15 @@ function deserialize(raw) {
|
|
|
55
49
|
}
|
|
56
50
|
const candidate = parsed;
|
|
57
51
|
if (typeof candidate.sessionId !== 'string' ||
|
|
58
|
-
typeof candidate.refreshToken !== 'string' ||
|
|
59
52
|
typeof candidate.userId !== 'string' ||
|
|
60
53
|
candidate.sessionId.length === 0 ||
|
|
61
|
-
candidate.refreshToken.length === 0 ||
|
|
62
54
|
candidate.userId.length === 0) {
|
|
63
55
|
return null;
|
|
64
56
|
}
|
|
65
57
|
const state = {
|
|
66
58
|
sessionId: candidate.sessionId,
|
|
67
|
-
refreshToken: candidate.refreshToken,
|
|
68
59
|
userId: candidate.userId,
|
|
69
60
|
};
|
|
70
|
-
if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
|
|
71
|
-
state.deviceToken = candidate.deviceToken;
|
|
72
|
-
}
|
|
73
61
|
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
74
62
|
state.deviceId = candidate.deviceId;
|
|
75
63
|
}
|
|
@@ -92,7 +80,6 @@ function deserialize(raw) {
|
|
|
92
80
|
*/
|
|
93
81
|
function createMemoryAuthStateStore() {
|
|
94
82
|
let current = null;
|
|
95
|
-
let deviceToken = null;
|
|
96
83
|
return {
|
|
97
84
|
load: async () => current,
|
|
98
85
|
save: async (state) => {
|
|
@@ -101,13 +88,6 @@ function createMemoryAuthStateStore() {
|
|
|
101
88
|
clear: async () => {
|
|
102
89
|
current = null;
|
|
103
90
|
},
|
|
104
|
-
loadDeviceToken: async () => deviceToken,
|
|
105
|
-
saveDeviceToken: async (token) => {
|
|
106
|
-
deviceToken = token;
|
|
107
|
-
},
|
|
108
|
-
clearDeviceToken: async () => {
|
|
109
|
-
deviceToken = null;
|
|
110
|
-
},
|
|
111
91
|
};
|
|
112
92
|
}
|
|
113
93
|
/**
|
|
@@ -138,8 +118,8 @@ function safeGetLocalStorage() {
|
|
|
138
118
|
* for this page's lifetime — never throws on construction.
|
|
139
119
|
* - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
|
|
140
120
|
* throws yields `null`; a write that throws (quota, private mode) is
|
|
141
|
-
* swallowed. The
|
|
142
|
-
*
|
|
121
|
+
* swallowed. The re-mint lane treats a failed persist as "no durable state"
|
|
122
|
+
* rather than crashing.
|
|
143
123
|
*/
|
|
144
124
|
function createWebAuthStateStore() {
|
|
145
125
|
const storage = safeGetLocalStorage();
|
|
@@ -151,7 +131,6 @@ function createWebAuthStateStore() {
|
|
|
151
131
|
// means "never written this session" → fall back to storage; any set value
|
|
152
132
|
// (including `null` after clear) is authoritative and preferred over storage.
|
|
153
133
|
let sessionMirror;
|
|
154
|
-
let deviceTokenMirror;
|
|
155
134
|
return {
|
|
156
135
|
load: async () => {
|
|
157
136
|
if (sessionMirror !== undefined) {
|
|
@@ -183,35 +162,6 @@ function createWebAuthStateStore() {
|
|
|
183
162
|
// Non-fatal — see save().
|
|
184
163
|
}
|
|
185
164
|
},
|
|
186
|
-
loadDeviceToken: async () => {
|
|
187
|
-
if (deviceTokenMirror !== undefined) {
|
|
188
|
-
return deviceTokenMirror;
|
|
189
|
-
}
|
|
190
|
-
try {
|
|
191
|
-
return storage.getItem(exports.DEVICE_TOKEN_STORAGE_KEY);
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
saveDeviceToken: async (token) => {
|
|
198
|
-
deviceTokenMirror = token;
|
|
199
|
-
try {
|
|
200
|
-
storage.setItem(exports.DEVICE_TOKEN_STORAGE_KEY, token);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
// Non-fatal — see save().
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
clearDeviceToken: async () => {
|
|
207
|
-
deviceTokenMirror = null;
|
|
208
|
-
try {
|
|
209
|
-
storage.removeItem(exports.DEVICE_TOKEN_STORAGE_KEY);
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
// Non-fatal — see save().
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
165
|
};
|
|
216
166
|
}
|
|
217
167
|
/**
|
|
@@ -226,7 +176,6 @@ function createNativeAuthStateStore(storage) {
|
|
|
226
176
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
227
177
|
// must not silently lose the session for the app's lifetime.
|
|
228
178
|
let sessionMirror;
|
|
229
|
-
let deviceTokenMirror;
|
|
230
179
|
return {
|
|
231
180
|
load: async () => {
|
|
232
181
|
if (sessionMirror !== undefined) {
|
|
@@ -257,34 +206,5 @@ function createNativeAuthStateStore(storage) {
|
|
|
257
206
|
// Non-fatal.
|
|
258
207
|
}
|
|
259
208
|
},
|
|
260
|
-
loadDeviceToken: async () => {
|
|
261
|
-
if (deviceTokenMirror !== undefined) {
|
|
262
|
-
return deviceTokenMirror;
|
|
263
|
-
}
|
|
264
|
-
try {
|
|
265
|
-
return await storage.getItem(exports.DEVICE_TOKEN_STORAGE_KEY);
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
},
|
|
271
|
-
saveDeviceToken: async (token) => {
|
|
272
|
-
deviceTokenMirror = token;
|
|
273
|
-
try {
|
|
274
|
-
await storage.setItem(exports.DEVICE_TOKEN_STORAGE_KEY, token);
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
// Non-fatal.
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
clearDeviceToken: async () => {
|
|
281
|
-
deviceTokenMirror = null;
|
|
282
|
-
try {
|
|
283
|
-
await storage.removeItem(exports.DEVICE_TOKEN_STORAGE_KEY);
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
// Non-fatal.
|
|
287
|
-
}
|
|
288
|
-
},
|
|
289
209
|
};
|
|
290
210
|
}
|
|
@@ -25,15 +25,8 @@ const sessionClientHost_1 = require("./sessionClientHost");
|
|
|
25
25
|
* specifier (bundler-fragile in Metro/Expo-web and Vite against the published
|
|
26
26
|
* dist). When omitted, the client falls back to the lazy loader.
|
|
27
27
|
*/
|
|
28
|
-
function createSessionClient(oxyServices, transport, socketFactory
|
|
29
|
-
/**
|
|
30
|
-
* Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
|
|
31
|
-
* while signed out so an idle tab receives its device pushes) and
|
|
32
|
-
* `onSessionAppeared` (self-acquire when a sibling signs in). See
|
|
33
|
-
* {@link SessionClientOptions}.
|
|
34
|
-
*/
|
|
35
|
-
extra) {
|
|
28
|
+
function createSessionClient(oxyServices, transport, socketFactory) {
|
|
36
29
|
const host = (0, sessionClientHost_1.createSessionClientHost)(oxyServices);
|
|
37
|
-
const client = new SessionClient_1.SessionClient(host, { transport, socketFactory
|
|
30
|
+
const client = new SessionClient_1.SessionClient(host, { transport, socketFactory });
|
|
38
31
|
return { client, host };
|
|
39
32
|
}
|