@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
|
@@ -7,159 +7,19 @@
|
|
|
7
7
|
* the app renders with a "Sign in with Oxy" button.
|
|
8
8
|
*
|
|
9
9
|
* Ordered steps (first to yield a session wins):
|
|
10
|
-
* 1. `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 3.
|
|
16
|
-
* issuing + mirroring a shared deviceToken the first time.
|
|
17
|
-
* 4. `bootstrap-hop` (web) — same-apex: an inline credentialed
|
|
18
|
-
* `/auth/device/web-session` fetch (no redirect); cross-apex: ONE top-level
|
|
19
|
-
* navigation to `/auth/device/bootstrap`, guarded once-ever per origin.
|
|
20
|
-
* 5. Signed out.
|
|
21
|
-
*
|
|
22
|
-
* All mutable guard state lives in STORAGE (localStorage `oxy.boot.attempted` +
|
|
23
|
-
* sessionStorage `oxy.boot.state`), never in module scope, so the guard holds
|
|
24
|
-
* under Metro/bundler re-evaluation.
|
|
10
|
+
* 1. `device-secret-mint` (web + native) — the zero-cookie transport: when the
|
|
11
|
+
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
12
|
+
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
13
|
+
* navigation) and rotate the secret in-use.
|
|
14
|
+
* 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
15
|
+
* 3. Signed out.
|
|
25
16
|
*
|
|
26
17
|
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
27
18
|
*/
|
|
28
|
-
import { resolveUserId } from '@oxyhq/contracts';
|
|
29
19
|
import { runColdBoot } from '../utils/coldBoot.js';
|
|
30
|
-
import {
|
|
20
|
+
import { isNative as detectNative } from '../utils/platform.js';
|
|
31
21
|
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
32
|
-
import { KeyManager } from '../crypto/keyManager.js';
|
|
33
22
|
import { logger } from '../utils/loggerUtils.js';
|
|
34
|
-
import { refreshPersistedSession } from '../session/refresh.js';
|
|
35
|
-
import { consumeDeviceBootReturn, hashHasBootFragment, BOOT_STATE_SESSION_KEY, } from './deviceBootReturn.js';
|
|
36
|
-
/**
|
|
37
|
-
* localStorage flag marking that the cross-apex bootstrap navigation has fired
|
|
38
|
-
* once for this origin. Persistent (not session) so the visible redirect
|
|
39
|
-
* happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
|
|
40
|
-
* bounced again; they sign in explicitly.
|
|
41
|
-
*/
|
|
42
|
-
export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
|
|
43
|
-
/**
|
|
44
|
-
* Do not warm-plant a stored access token with less than this remaining — it
|
|
45
|
-
* would need an immediate refresh anyway, so fall through to the rotate path.
|
|
46
|
-
* Matches the refresh lead window.
|
|
47
|
-
*/
|
|
48
|
-
const WARM_MIN_REMAINING_MS = 60000;
|
|
49
|
-
/**
|
|
50
|
-
* Generate a 128-bit hex CSRF state token. Prefers Web Crypto
|
|
51
|
-
* (`crypto.getRandomValues`, present in browsers and modern Node); falls back
|
|
52
|
-
* to a time+`Math.random` mix only when no CSPRNG is reachable (this token
|
|
53
|
-
* gates a single-use CSRF echo, not a long-lived secret).
|
|
54
|
-
*/
|
|
55
|
-
function generateStateToken() {
|
|
56
|
-
const cryptoObj = globalThis.crypto;
|
|
57
|
-
if (cryptoObj?.getRandomValues) {
|
|
58
|
-
const bytes = new Uint8Array(16);
|
|
59
|
-
cryptoObj.getRandomValues(bytes);
|
|
60
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
61
|
-
}
|
|
62
|
-
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
|
|
66
|
-
* missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
|
|
67
|
-
* or no-op, so the boot degrades to signed-out rather than crashing.
|
|
68
|
-
*/
|
|
69
|
-
export function createBrowserColdBootDom() {
|
|
70
|
-
const safe = (fn, fallback) => {
|
|
71
|
-
try {
|
|
72
|
-
return fn();
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return fallback;
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
return {
|
|
79
|
-
getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
|
|
80
|
-
stripFragment: () => safe(() => {
|
|
81
|
-
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
|
82
|
-
const { pathname, search } = window.location;
|
|
83
|
-
window.history.replaceState(null, '', `${pathname}${search}`);
|
|
84
|
-
}
|
|
85
|
-
}, undefined),
|
|
86
|
-
getSessionItem: (key) => safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
|
|
87
|
-
setSessionItem: (key, value) => safe(() => {
|
|
88
|
-
if (typeof sessionStorage !== 'undefined')
|
|
89
|
-
sessionStorage.setItem(key, value);
|
|
90
|
-
}, undefined),
|
|
91
|
-
removeSessionItem: (key) => safe(() => {
|
|
92
|
-
if (typeof sessionStorage !== 'undefined')
|
|
93
|
-
sessionStorage.removeItem(key);
|
|
94
|
-
}, undefined),
|
|
95
|
-
getLocalItem: (key) => safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
|
|
96
|
-
setLocalItem: (key, value) => safe(() => {
|
|
97
|
-
if (typeof localStorage !== 'undefined')
|
|
98
|
-
localStorage.setItem(key, value);
|
|
99
|
-
}, undefined),
|
|
100
|
-
getLocationHostname: () => safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
|
|
101
|
-
getReturnToHref: () => safe(() => {
|
|
102
|
-
if (typeof window === 'undefined')
|
|
103
|
-
return null;
|
|
104
|
-
const { origin, pathname, search } = window.location;
|
|
105
|
-
return `${origin}${pathname}${search}`;
|
|
106
|
-
}, null),
|
|
107
|
-
navigate: (url) => safe(() => {
|
|
108
|
-
if (typeof window !== 'undefined')
|
|
109
|
-
window.location.assign(url);
|
|
110
|
-
}, undefined),
|
|
111
|
-
randomState: generateStateToken,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Registrable domain = the last two labels of a host. A deliberately SMALL
|
|
116
|
-
* local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
|
|
117
|
-
* Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
|
|
118
|
-
* `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
|
|
119
|
-
* such as `co.uk` — never arises here because the compared API host is always
|
|
120
|
-
* `api.oxy.so`, so a page under a different registrable domain still classifies
|
|
121
|
-
* as cross-apex.
|
|
122
|
-
*/
|
|
123
|
-
function registrableDomain(host) {
|
|
124
|
-
const labels = host.toLowerCase().split('.').filter(Boolean);
|
|
125
|
-
return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
|
|
129
|
-
* hosts have NO registrable domain — the last-two-labels heuristic would
|
|
130
|
-
* mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
|
|
131
|
-
* `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
|
|
132
|
-
* classified same-apex as a different LAN API and skip the cross-apex hop.
|
|
133
|
-
*/
|
|
134
|
-
function isIpOrSingleLabel(host) {
|
|
135
|
-
if (host.includes(':')) {
|
|
136
|
-
return true; // IPv6 literal
|
|
137
|
-
}
|
|
138
|
-
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
139
|
-
return true; // IPv4 literal
|
|
140
|
-
}
|
|
141
|
-
return !host.includes('.'); // single-label (e.g. `localhost`)
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* True when both hosts are same-site / same-apex. For a normal multi-label host
|
|
145
|
-
* pair, that means sharing a registrable domain. For an IP literal or a
|
|
146
|
-
* single-label host (no registrable domain), same-apex requires the two hosts
|
|
147
|
-
* to be EXACTLY equal — never grouped by a spurious trailing-label match.
|
|
148
|
-
*/
|
|
149
|
-
export function isSameApex(pageHost, apiHost) {
|
|
150
|
-
const a = pageHost.toLowerCase();
|
|
151
|
-
const b = apiHost.toLowerCase();
|
|
152
|
-
if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
|
|
153
|
-
return a === b;
|
|
154
|
-
}
|
|
155
|
-
const ra = registrableDomain(a);
|
|
156
|
-
const rb = registrableDomain(b);
|
|
157
|
-
return ra !== '' && ra === rb;
|
|
158
|
-
}
|
|
159
|
-
/** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
|
|
160
|
-
function sessionFromPersisted(state, accessToken) {
|
|
161
|
-
return { sessionId: state.sessionId, userId: state.userId, accessToken };
|
|
162
|
-
}
|
|
163
23
|
function classifyMintFailure(error) {
|
|
164
24
|
if (extractErrorStatus(error) === 401) {
|
|
165
25
|
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
@@ -174,36 +34,21 @@ function classifyMintFailure(error) {
|
|
|
174
34
|
}
|
|
175
35
|
/**
|
|
176
36
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
177
|
-
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
178
|
-
*
|
|
179
|
-
* cross-apex hop, in which case neither fires).
|
|
37
|
+
* a side effect, invokes `onSession` (winning session, token already planted) or
|
|
38
|
+
* `onSignedOut` (no session).
|
|
180
39
|
*/
|
|
181
40
|
export async function runSessionColdBoot(opts) {
|
|
182
41
|
const { oxy, store } = opts;
|
|
183
|
-
const dom = opts.dom ?? createBrowserColdBootDom();
|
|
184
|
-
const isWeb = opts.platform?.isWeb ?? detectWeb();
|
|
185
42
|
const isNative = opts.platform?.isNative ?? detectNative();
|
|
186
|
-
//
|
|
187
|
-
//
|
|
43
|
+
// Boot-local (not module-level) so it cannot leak across boots or break under
|
|
44
|
+
// bundler re-evaluation.
|
|
188
45
|
let signedOutReason = 'no_session';
|
|
189
|
-
let navigating = false;
|
|
190
|
-
// Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
|
|
191
|
-
// device is authoritatively signed out, so the migratory fallback lanes below
|
|
192
|
-
// (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
|
|
193
|
-
// there is no session and must not bounce a known-signed-out device.
|
|
194
|
-
let deviceKnownSignedOut = false;
|
|
195
46
|
const steps = [];
|
|
196
|
-
//
|
|
47
|
+
// 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
|
|
197
48
|
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
198
|
-
// a single bearer-less POST (no cookie, no navigation).
|
|
199
|
-
// so it wins over the migratory cookie lanes below. Gated OFF while a
|
|
200
|
-
// #oxy_boot return fragment is present so `bootstrap-return` still consumes
|
|
201
|
-
// + strips it first (a device holding a secret never triggers that hop, so
|
|
202
|
-
// this only defers a rare stale/forged fragment). The rest of the chain
|
|
203
|
-
// stays as the additive migratory fallback for devices not yet on the secret.
|
|
49
|
+
// a single bearer-less POST (no cookie, no navigation).
|
|
204
50
|
steps.push({
|
|
205
51
|
id: 'device-secret-mint',
|
|
206
|
-
enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
|
|
207
52
|
run: async () => {
|
|
208
53
|
const persisted = await store.load();
|
|
209
54
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
@@ -234,106 +79,33 @@ export async function runSessionColdBoot(opts) {
|
|
|
234
79
|
catch (error) {
|
|
235
80
|
const failure = classifyMintFailure(error);
|
|
236
81
|
if (failure === 'invalid_secret') {
|
|
237
|
-
// Stale/diverged secret — drop it so the mint lane stops firing
|
|
238
|
-
//
|
|
239
|
-
// undefined drops the key on the store's JSON
|
|
240
|
-
// mint guard treats undefined as absent.
|
|
82
|
+
// Stale/diverged secret — drop it so the mint lane stops firing. On
|
|
83
|
+
// native the shared-key step below can still recover; on web this ends
|
|
84
|
+
// signed out. Setting it undefined drops the key on the store's JSON
|
|
85
|
+
// serialization, and the mint guard treats undefined as absent.
|
|
241
86
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
242
87
|
return { kind: 'skip' };
|
|
243
88
|
}
|
|
244
89
|
if (failure === 'no_active_session') {
|
|
245
|
-
// Device known, no live session — authoritative signed-out.
|
|
246
|
-
// secret and stop the chain (do not bounce a known-signed-out device).
|
|
247
|
-
deviceKnownSignedOut = true;
|
|
90
|
+
// Device known, no live session — authoritative signed-out.
|
|
248
91
|
signedOutReason = 'no_session';
|
|
249
92
|
return { kind: 'skip' };
|
|
250
93
|
}
|
|
251
|
-
// Transient (network / 5xx): keep the secret
|
|
252
|
-
logger.debug('device-secret mint failed (transient) — keeping secret
|
|
94
|
+
// Transient (network / 5xx): keep the secret; a later attempt can succeed.
|
|
95
|
+
logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
|
|
253
96
|
return { kind: 'skip' };
|
|
254
97
|
}
|
|
255
98
|
},
|
|
256
99
|
});
|
|
257
|
-
//
|
|
258
|
-
steps.push({
|
|
259
|
-
id: 'bootstrap-return',
|
|
260
|
-
enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
|
|
261
|
-
run: async () => {
|
|
262
|
-
const outcome = await consumeDeviceBootReturn({
|
|
263
|
-
hash: dom.getHash(),
|
|
264
|
-
stripFragment: () => dom.stripFragment(),
|
|
265
|
-
readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
|
|
266
|
-
clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
|
|
267
|
-
store,
|
|
268
|
-
exchangeBootCode: (code) => oxy.exchangeBootCode(code),
|
|
269
|
-
plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
|
|
270
|
-
});
|
|
271
|
-
if (outcome.kind === 'session') {
|
|
272
|
-
return { kind: 'session', session: outcome.session };
|
|
273
|
-
}
|
|
274
|
-
if (outcome.kind === 'state-mismatch') {
|
|
275
|
-
signedOutReason = 'state-mismatch';
|
|
276
|
-
}
|
|
277
|
-
else if (outcome.kind === 'no-session') {
|
|
278
|
-
// `DeviceBootReason` also carries 'session'; a no-session outcome with
|
|
279
|
-
// that reason (a `session` reason but no code) is still signed out.
|
|
280
|
-
signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
|
|
281
|
-
}
|
|
282
|
-
// Fall through: a stored refresh family (a prior same-origin session) may
|
|
283
|
-
// still recover below. The once-ever flag blocks a second hop.
|
|
284
|
-
return { kind: 'skip' };
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
// 2. stored-tokens — warm-plant or rotate the persisted refresh family.
|
|
288
|
-
steps.push({
|
|
289
|
-
id: 'stored-tokens',
|
|
290
|
-
enabled: () => !deviceKnownSignedOut,
|
|
291
|
-
run: async () => {
|
|
292
|
-
const persisted = await store.load();
|
|
293
|
-
if (!persisted) {
|
|
294
|
-
return { kind: 'skip' };
|
|
295
|
-
}
|
|
296
|
-
// Warm path: a still-valid access token → plant immediately (no network).
|
|
297
|
-
if (persisted.accessToken &&
|
|
298
|
-
persisted.expiresAt &&
|
|
299
|
-
Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS) {
|
|
300
|
-
oxy.setTokens(persisted.accessToken);
|
|
301
|
-
return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
|
|
302
|
-
}
|
|
303
|
-
// Rotate path: refreshPersistedSession plants + persists, and clears the
|
|
304
|
-
// store on a family-revoked error.
|
|
305
|
-
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
|
|
306
|
-
if (!token) {
|
|
307
|
-
return { kind: 'skip' };
|
|
308
|
-
}
|
|
309
|
-
const after = await store.load();
|
|
310
|
-
const base = after ?? persisted;
|
|
311
|
-
return { kind: 'session', session: sessionFromPersisted(base, token) };
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
100
|
+
// 2. shared-key-signin (native) — re-mint from the shared identity.
|
|
315
101
|
steps.push({
|
|
316
102
|
id: 'shared-key-signin',
|
|
317
|
-
enabled: () => isNative
|
|
103
|
+
enabled: () => isNative,
|
|
318
104
|
run: async () => {
|
|
319
105
|
const session = await oxy.signInWithSharedIdentity();
|
|
320
106
|
if (!session?.accessToken) {
|
|
321
107
|
return { kind: 'skip' };
|
|
322
108
|
}
|
|
323
|
-
// First shared-key sign-in on this device: issue + persist + mirror a
|
|
324
|
-
// shared deviceToken so every native Oxy app joins one DeviceSession.
|
|
325
|
-
// Best-effort — never fail the sign-in over device-token issuance.
|
|
326
|
-
try {
|
|
327
|
-
const existing = await KeyManager.getSharedDeviceToken();
|
|
328
|
-
if (!existing) {
|
|
329
|
-
const deviceToken = await oxy.issueNativeDeviceToken();
|
|
330
|
-
await store.saveDeviceToken(deviceToken);
|
|
331
|
-
await KeyManager.setSharedDeviceToken(deviceToken);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
catch (error) {
|
|
335
|
-
logger.debug('Native deviceToken issuance skipped', { component: 'coldBootV2', method: 'shared-key-signin' }, error);
|
|
336
|
-
}
|
|
337
109
|
return {
|
|
338
110
|
kind: 'session',
|
|
339
111
|
session: {
|
|
@@ -344,81 +116,6 @@ export async function runSessionColdBoot(opts) {
|
|
|
344
116
|
};
|
|
345
117
|
},
|
|
346
118
|
});
|
|
347
|
-
// 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
|
|
348
|
-
steps.push({
|
|
349
|
-
id: 'bootstrap-hop',
|
|
350
|
-
enabled: () => isWeb && !deviceKnownSignedOut,
|
|
351
|
-
run: async () => {
|
|
352
|
-
const pageHost = dom.getLocationHostname();
|
|
353
|
-
let apiHost = null;
|
|
354
|
-
try {
|
|
355
|
-
apiHost = new URL(oxy.getBaseURL()).hostname;
|
|
356
|
-
}
|
|
357
|
-
catch {
|
|
358
|
-
apiHost = null;
|
|
359
|
-
}
|
|
360
|
-
if (!pageHost || !apiHost) {
|
|
361
|
-
return { kind: 'skip' };
|
|
362
|
-
}
|
|
363
|
-
// Same-apex: inline credentialed fetch, no redirect, runs every boot.
|
|
364
|
-
if (isSameApex(pageHost, apiHost)) {
|
|
365
|
-
const result = await oxy.requestWebSession();
|
|
366
|
-
// The rotated deviceToken is on BOTH arms (it is device-level, not
|
|
367
|
-
// session-level) — persist it before branching on the session.
|
|
368
|
-
await store.saveDeviceToken(result.deviceToken);
|
|
369
|
-
if (result.reason === 'session') {
|
|
370
|
-
const bundle = result.session;
|
|
371
|
-
const userId = resolveUserId(bundle.user);
|
|
372
|
-
if (!userId) {
|
|
373
|
-
return { kind: 'skip' };
|
|
374
|
-
}
|
|
375
|
-
const next = {
|
|
376
|
-
sessionId: bundle.sessionId,
|
|
377
|
-
refreshToken: bundle.refreshToken,
|
|
378
|
-
userId,
|
|
379
|
-
deviceToken: result.deviceToken,
|
|
380
|
-
accessToken: bundle.accessToken,
|
|
381
|
-
expiresAt: bundle.expiresAt,
|
|
382
|
-
};
|
|
383
|
-
// Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
|
|
384
|
-
// but NOT a deviceId. Persist the secret and carry any prior deviceId
|
|
385
|
-
// (from a deviceId-bearing login lane) forward so the mint lane stays
|
|
386
|
-
// usable — this overwrite must not orphan it.
|
|
387
|
-
const prior = await store.load();
|
|
388
|
-
if (prior?.deviceId) {
|
|
389
|
-
next.deviceId = prior.deviceId;
|
|
390
|
-
}
|
|
391
|
-
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
392
|
-
// prior one when the bundle omits it — this lane also runs as the
|
|
393
|
-
// TRANSIENT-mint fallback, and must not orphan a still-valid secret.
|
|
394
|
-
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
395
|
-
if (carriedSecret) {
|
|
396
|
-
next.deviceSecret = carriedSecret;
|
|
397
|
-
}
|
|
398
|
-
await store.save(next);
|
|
399
|
-
oxy.setTokens(bundle.accessToken);
|
|
400
|
-
return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
|
|
401
|
-
}
|
|
402
|
-
// Known device, signed out.
|
|
403
|
-
signedOutReason = result.reason;
|
|
404
|
-
return { kind: 'skip' };
|
|
405
|
-
}
|
|
406
|
-
// Cross-apex: ONE visible top-level navigation, once-ever per origin.
|
|
407
|
-
if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
|
|
408
|
-
return { kind: 'skip' };
|
|
409
|
-
}
|
|
410
|
-
const returnTo = opts.returnTo ?? dom.getReturnToHref();
|
|
411
|
-
if (!returnTo) {
|
|
412
|
-
return { kind: 'skip' };
|
|
413
|
-
}
|
|
414
|
-
const state = dom.randomState();
|
|
415
|
-
dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
|
|
416
|
-
dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
|
|
417
|
-
navigating = true;
|
|
418
|
-
dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
|
|
419
|
-
return { kind: 'skip' };
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
119
|
const outcome = await runColdBoot({
|
|
423
120
|
steps,
|
|
424
121
|
onStepError: (id, error) => {
|
|
@@ -430,10 +127,6 @@ export async function runSessionColdBoot(opts) {
|
|
|
430
127
|
await opts.onSession?.({ ...outcome.session, via: outcome.via });
|
|
431
128
|
return outcome;
|
|
432
129
|
}
|
|
433
|
-
|
|
434
|
-
// flash a signed-out state.
|
|
435
|
-
if (!navigating) {
|
|
436
|
-
await opts.onSignedOut?.(signedOutReason);
|
|
437
|
-
}
|
|
130
|
+
await opts.onSignedOut?.(signedOutReason);
|
|
438
131
|
return outcome;
|
|
439
132
|
}
|
|
@@ -53,11 +53,6 @@ const STORAGE_KEYS = {
|
|
|
53
53
|
SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
|
|
54
54
|
SHARED_SESSION_TOKEN: 'oxy_shared_session_token',
|
|
55
55
|
SHARED_SESSION_ID: 'oxy_shared_session_id',
|
|
56
|
-
// Opaque device-attribution token, shared across all Oxy apps on one device
|
|
57
|
-
// so they all resolve to a SINGLE server-side DeviceSession (device-first
|
|
58
|
-
// session model). Add-only: it never carries session state, only attributes
|
|
59
|
-
// a newly-credentialed session to the shared device set.
|
|
60
|
-
SHARED_DEVICE_TOKEN: 'oxy_shared_device_token',
|
|
61
56
|
};
|
|
62
57
|
/**
|
|
63
58
|
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
@@ -545,96 +540,6 @@ export class KeyManager {
|
|
|
545
540
|
return false;
|
|
546
541
|
}
|
|
547
542
|
}
|
|
548
|
-
/**
|
|
549
|
-
* Store the opaque device-attribution token in the SHARED keychain so every
|
|
550
|
-
* Oxy app on this device reads the SAME token and therefore resolves to one
|
|
551
|
-
* server-side DeviceSession.
|
|
552
|
-
*
|
|
553
|
-
* Mirrors the shared-session storage options exactly (iOS keychain access
|
|
554
|
-
* group `group.so.oxy.shared`; Android shared secure store). Native-only —
|
|
555
|
-
* a no-op on web (the shared keychain does not exist there; web persists its
|
|
556
|
-
* deviceToken in the per-origin {@link AuthStateStore} instead).
|
|
557
|
-
*/
|
|
558
|
-
static async setSharedDeviceToken(deviceToken) {
|
|
559
|
-
if (isWebPlatform()) {
|
|
560
|
-
return; // Not supported on web
|
|
561
|
-
}
|
|
562
|
-
try {
|
|
563
|
-
const store = await initSecureStore();
|
|
564
|
-
if (isIOS()) {
|
|
565
|
-
const opts = {
|
|
566
|
-
keychainAccessible: store.WHEN_UNLOCKED,
|
|
567
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
568
|
-
};
|
|
569
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken, opts);
|
|
570
|
-
}
|
|
571
|
-
else if (isAndroid()) {
|
|
572
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken);
|
|
573
|
-
}
|
|
574
|
-
if (isDev()) {
|
|
575
|
-
logger.debug('Shared device token stored successfully', { component: 'KeyManager' });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
catch (error) {
|
|
579
|
-
if (isDev()) {
|
|
580
|
-
logger.error('Failed to store shared device token', error, { component: 'KeyManager' });
|
|
581
|
-
}
|
|
582
|
-
throw error;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
/**
|
|
586
|
-
* Read the shared device-attribution token, or `null` when none is set (or
|
|
587
|
-
* on web). Mirrors {@link getSharedSession}'s keychain-access-group read.
|
|
588
|
-
*/
|
|
589
|
-
static async getSharedDeviceToken() {
|
|
590
|
-
if (isWebPlatform()) {
|
|
591
|
-
return null;
|
|
592
|
-
}
|
|
593
|
-
try {
|
|
594
|
-
const store = await initSecureStore();
|
|
595
|
-
if (isIOS()) {
|
|
596
|
-
const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
597
|
-
return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
|
|
598
|
-
}
|
|
599
|
-
if (isAndroid()) {
|
|
600
|
-
return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
|
|
601
|
-
}
|
|
602
|
-
return null;
|
|
603
|
-
}
|
|
604
|
-
catch (error) {
|
|
605
|
-
if (isDev()) {
|
|
606
|
-
logger.warn('Failed to get shared device token', { component: 'KeyManager' }, error);
|
|
607
|
-
}
|
|
608
|
-
return null;
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
/**
|
|
612
|
-
* Delete the shared device-attribution token (device signout-all). Best-effort:
|
|
613
|
-
* a keychain failure is logged, not thrown, so it cannot block a sign-out.
|
|
614
|
-
*/
|
|
615
|
-
static async clearSharedDeviceToken() {
|
|
616
|
-
if (isWebPlatform()) {
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
const store = await initSecureStore();
|
|
621
|
-
if (isIOS()) {
|
|
622
|
-
const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
623
|
-
await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
|
|
624
|
-
}
|
|
625
|
-
else if (isAndroid()) {
|
|
626
|
-
await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
|
|
627
|
-
}
|
|
628
|
-
if (isDev()) {
|
|
629
|
-
logger.debug('Shared device token cleared successfully', { component: 'KeyManager' });
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
catch (error) {
|
|
633
|
-
if (isDev()) {
|
|
634
|
-
logger.error('Failed to clear shared device token', error, { component: 'KeyManager' });
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
543
|
// ==================== END SHARED IDENTITY METHODS ====================
|
|
639
544
|
/**
|
|
640
545
|
* Atomically persist a key pair to secure storage with verification + backup.
|
package/dist/esm/index.js
CHANGED
|
@@ -156,19 +156,15 @@ export { projectSwitchableAccounts, switchableAccountIds, } from './session/acco
|
|
|
156
156
|
// for the uniform switch and the existing device-flow methods for sign-in.
|
|
157
157
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController.js';
|
|
158
158
|
// ---------------------------------------------------------------------------
|
|
159
|
-
// Device-first session machinery (
|
|
160
|
-
// Persisted auth-state store, the unified
|
|
161
|
-
// cold-boot
|
|
162
|
-
//
|
|
163
|
-
//
|
|
159
|
+
// Device-first session machinery (zero-cookie transport).
|
|
160
|
+
// Persisted auth-state store, the unified re-mint handler + scheduler, and the
|
|
161
|
+
// cold-boot runner. Built ON the `runColdBoot` primitive + `SessionClient`. The
|
|
162
|
+
// device credential is `deviceId` + `deviceSecret`; the access token is re-minted
|
|
163
|
+
// via `POST /session/device/token`.
|
|
164
164
|
// ---------------------------------------------------------------------------
|
|
165
|
-
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY,
|
|
165
|
+
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore.js';
|
|
166
166
|
export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
|
|
167
|
-
export { runSessionColdBoot
|
|
168
|
-
export { consumeDeviceBootReturn, parseDeviceBootFragment, hashHasBootFragment, BOOT_FRAGMENT_PARAM, BOOT_STATE_SESSION_KEY, } from './boot/deviceBootReturn.js';
|
|
169
|
-
// The web-session result contract (`WebSessionResult`) is owned by
|
|
170
|
-
// `@oxyhq/contracts` — import it directly from there; `@oxyhq/core` does not
|
|
171
|
-
// re-export contract types.
|
|
167
|
+
export { runSessionColdBoot } from './boot/coldBootV2.js';
|
|
172
168
|
// API response contracts (request/response Zod schemas + inferred types) live in
|
|
173
169
|
// `@oxyhq/contracts` — the single source of truth shared by the backend and every
|
|
174
170
|
// client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
|
|
@@ -791,11 +791,10 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
791
791
|
* (`{ twoFactorRequired, loginToken }`) to complete via
|
|
792
792
|
* {@link completeTwoFactorSignIn}, or a session arm.
|
|
793
793
|
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
* caller has an authenticated client without a second round-trip.
|
|
794
|
+
* On the session arm, a returned access token is planted immediately
|
|
795
|
+
* (mirroring {@link verifyChallenge}), so the caller has an authenticated
|
|
796
|
+
* client without a second round-trip. The response's `deviceId` +
|
|
797
|
+
* `deviceSecret` are the zero-cookie restore credential the caller persists.
|
|
799
798
|
*/
|
|
800
799
|
async passwordSignIn(identifier, password, options = {}) {
|
|
801
800
|
try {
|
|
@@ -804,7 +803,6 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
804
803
|
password,
|
|
805
804
|
deviceName: options.deviceName,
|
|
806
805
|
deviceFingerprint: options.deviceFingerprint,
|
|
807
|
-
deviceToken: options.deviceToken,
|
|
808
806
|
}, { cache: false });
|
|
809
807
|
const parsed = safeParseContract(loginResultSchema, res);
|
|
810
808
|
if (!parsed) {
|
|
@@ -832,7 +830,6 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
832
830
|
loginToken: params.loginToken,
|
|
833
831
|
token: params.token,
|
|
834
832
|
backupCode: params.backupCode,
|
|
835
|
-
deviceToken: params.deviceToken,
|
|
836
833
|
deviceName: params.deviceName,
|
|
837
834
|
}, { cache: false });
|
|
838
835
|
const parsed = safeParseContract(loginResultSchema, res);
|