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