@oxyhq/core 8.0.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 +66 -281
- 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 +24 -92
- 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 +14 -88
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -62
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +67 -279
- 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 +25 -93
- 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 +13 -87
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -62
- 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 +22 -46
- 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 +38 -43
- 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 +237 -236
- package/src/boot/coldBootV2.ts +92 -333
- 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 +30 -115
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
- 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 +47 -33
- package/src/session/__tests__/refresh.test.ts +72 -44
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +43 -111
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -83
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -152
- package/dist/esm/boot/deviceBootReturn.js +0 -146
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
- package/src/boot/deviceBootReturn.ts +0 -195
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BOOT_ATTEMPTED_KEY = void 0;
|
|
4
|
-
exports.createBrowserColdBootDom = createBrowserColdBootDom;
|
|
5
|
-
exports.isSameApex = isSameApex;
|
|
6
3
|
exports.runSessionColdBoot = runSessionColdBoot;
|
|
7
4
|
/**
|
|
8
5
|
* coldBootV2 — one device-first cold boot for every consumer.
|
|
@@ -13,231 +10,97 @@ exports.runSessionColdBoot = runSessionColdBoot;
|
|
|
13
10
|
* the app renders with a "Sign in with Oxy" button.
|
|
14
11
|
*
|
|
15
12
|
* Ordered steps (first to yield a session wins):
|
|
16
|
-
* 1. `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* 3.
|
|
22
|
-
* issuing + mirroring a shared deviceToken the first time.
|
|
23
|
-
* 4. `bootstrap-hop` (web) — same-apex: an inline credentialed
|
|
24
|
-
* `/auth/device/web-session` fetch (no redirect); cross-apex: ONE top-level
|
|
25
|
-
* navigation to `/auth/device/bootstrap`, guarded once-ever per origin.
|
|
26
|
-
* 5. Signed out.
|
|
27
|
-
*
|
|
28
|
-
* All mutable guard state lives in STORAGE (localStorage `oxy.boot.attempted` +
|
|
29
|
-
* sessionStorage `oxy.boot.state`), never in module scope, so the guard holds
|
|
30
|
-
* under Metro/bundler re-evaluation.
|
|
13
|
+
* 1. `device-secret-mint` (web + native) — the zero-cookie transport: when the
|
|
14
|
+
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
15
|
+
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
16
|
+
* navigation) and rotate the secret in-use.
|
|
17
|
+
* 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
18
|
+
* 3. Signed out.
|
|
31
19
|
*
|
|
32
20
|
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
33
21
|
*/
|
|
34
|
-
const contracts_1 = require("@oxyhq/contracts");
|
|
35
22
|
const coldBoot_1 = require("../utils/coldBoot");
|
|
36
23
|
const platform_1 = require("../utils/platform");
|
|
37
|
-
const
|
|
24
|
+
const errorUtils_1 = require("../utils/errorUtils");
|
|
38
25
|
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Do not warm-plant a stored access token with less than this remaining — it
|
|
50
|
-
* would need an immediate refresh anyway, so fall through to the rotate path.
|
|
51
|
-
* Matches the refresh lead window.
|
|
52
|
-
*/
|
|
53
|
-
const WARM_MIN_REMAINING_MS = 60000;
|
|
54
|
-
/**
|
|
55
|
-
* Generate a 128-bit hex CSRF state token. Prefers Web Crypto
|
|
56
|
-
* (`crypto.getRandomValues`, present in browsers and modern Node); falls back
|
|
57
|
-
* to a time+`Math.random` mix only when no CSPRNG is reachable (this token
|
|
58
|
-
* gates a single-use CSRF echo, not a long-lived secret).
|
|
59
|
-
*/
|
|
60
|
-
function generateStateToken() {
|
|
61
|
-
const cryptoObj = globalThis.crypto;
|
|
62
|
-
if (cryptoObj?.getRandomValues) {
|
|
63
|
-
const bytes = new Uint8Array(16);
|
|
64
|
-
cryptoObj.getRandomValues(bytes);
|
|
65
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
function classifyMintFailure(error) {
|
|
27
|
+
if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
|
|
28
|
+
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
29
|
+
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
30
|
+
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
31
|
+
const message = error?.message;
|
|
32
|
+
return typeof message === 'string' && message.includes('no_active_session')
|
|
33
|
+
? 'no_active_session'
|
|
34
|
+
: 'invalid_secret';
|
|
66
35
|
}
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
|
|
71
|
-
* missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
|
|
72
|
-
* or no-op, so the boot degrades to signed-out rather than crashing.
|
|
73
|
-
*/
|
|
74
|
-
function createBrowserColdBootDom() {
|
|
75
|
-
const safe = (fn, fallback) => {
|
|
76
|
-
try {
|
|
77
|
-
return fn();
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return fallback;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
return {
|
|
84
|
-
getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
|
|
85
|
-
stripFragment: () => safe(() => {
|
|
86
|
-
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
|
87
|
-
const { pathname, search } = window.location;
|
|
88
|
-
window.history.replaceState(null, '', `${pathname}${search}`);
|
|
89
|
-
}
|
|
90
|
-
}, undefined),
|
|
91
|
-
getSessionItem: (key) => safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
|
|
92
|
-
setSessionItem: (key, value) => safe(() => {
|
|
93
|
-
if (typeof sessionStorage !== 'undefined')
|
|
94
|
-
sessionStorage.setItem(key, value);
|
|
95
|
-
}, undefined),
|
|
96
|
-
removeSessionItem: (key) => safe(() => {
|
|
97
|
-
if (typeof sessionStorage !== 'undefined')
|
|
98
|
-
sessionStorage.removeItem(key);
|
|
99
|
-
}, undefined),
|
|
100
|
-
getLocalItem: (key) => safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
|
|
101
|
-
setLocalItem: (key, value) => safe(() => {
|
|
102
|
-
if (typeof localStorage !== 'undefined')
|
|
103
|
-
localStorage.setItem(key, value);
|
|
104
|
-
}, undefined),
|
|
105
|
-
getLocationHostname: () => safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
|
|
106
|
-
getReturnToHref: () => safe(() => {
|
|
107
|
-
if (typeof window === 'undefined')
|
|
108
|
-
return null;
|
|
109
|
-
const { origin, pathname, search } = window.location;
|
|
110
|
-
return `${origin}${pathname}${search}`;
|
|
111
|
-
}, null),
|
|
112
|
-
navigate: (url) => safe(() => {
|
|
113
|
-
if (typeof window !== 'undefined')
|
|
114
|
-
window.location.assign(url);
|
|
115
|
-
}, undefined),
|
|
116
|
-
randomState: generateStateToken,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Registrable domain = the last two labels of a host. A deliberately SMALL
|
|
121
|
-
* local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
|
|
122
|
-
* Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
|
|
123
|
-
* `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
|
|
124
|
-
* such as `co.uk` — never arises here because the compared API host is always
|
|
125
|
-
* `api.oxy.so`, so a page under a different registrable domain still classifies
|
|
126
|
-
* as cross-apex.
|
|
127
|
-
*/
|
|
128
|
-
function registrableDomain(host) {
|
|
129
|
-
const labels = host.toLowerCase().split('.').filter(Boolean);
|
|
130
|
-
return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
|
|
134
|
-
* hosts have NO registrable domain — the last-two-labels heuristic would
|
|
135
|
-
* mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
|
|
136
|
-
* `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
|
|
137
|
-
* classified same-apex as a different LAN API and skip the cross-apex hop.
|
|
138
|
-
*/
|
|
139
|
-
function isIpOrSingleLabel(host) {
|
|
140
|
-
if (host.includes(':')) {
|
|
141
|
-
return true; // IPv6 literal
|
|
142
|
-
}
|
|
143
|
-
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
144
|
-
return true; // IPv4 literal
|
|
145
|
-
}
|
|
146
|
-
return !host.includes('.'); // single-label (e.g. `localhost`)
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* True when both hosts are same-site / same-apex. For a normal multi-label host
|
|
150
|
-
* pair, that means sharing a registrable domain. For an IP literal or a
|
|
151
|
-
* single-label host (no registrable domain), same-apex requires the two hosts
|
|
152
|
-
* to be EXACTLY equal — never grouped by a spurious trailing-label match.
|
|
153
|
-
*/
|
|
154
|
-
function isSameApex(pageHost, apiHost) {
|
|
155
|
-
const a = pageHost.toLowerCase();
|
|
156
|
-
const b = apiHost.toLowerCase();
|
|
157
|
-
if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
|
|
158
|
-
return a === b;
|
|
159
|
-
}
|
|
160
|
-
const ra = registrableDomain(a);
|
|
161
|
-
const rb = registrableDomain(b);
|
|
162
|
-
return ra !== '' && ra === rb;
|
|
163
|
-
}
|
|
164
|
-
/** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
|
|
165
|
-
function sessionFromPersisted(state, accessToken) {
|
|
166
|
-
return { sessionId: state.sessionId, userId: state.userId, accessToken };
|
|
36
|
+
return 'transient';
|
|
167
37
|
}
|
|
168
38
|
/**
|
|
169
39
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
170
|
-
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
171
|
-
*
|
|
172
|
-
* cross-apex hop, in which case neither fires).
|
|
40
|
+
* a side effect, invokes `onSession` (winning session, token already planted) or
|
|
41
|
+
* `onSignedOut` (no session).
|
|
173
42
|
*/
|
|
174
43
|
async function runSessionColdBoot(opts) {
|
|
175
44
|
const { oxy, store } = opts;
|
|
176
|
-
const dom = opts.dom ?? createBrowserColdBootDom();
|
|
177
|
-
const isWeb = opts.platform?.isWeb ?? (0, platform_1.isWeb)();
|
|
178
45
|
const isNative = opts.platform?.isNative ?? (0, platform_1.isNative)();
|
|
179
|
-
//
|
|
180
|
-
//
|
|
46
|
+
// Boot-local (not module-level) so it cannot leak across boots or break under
|
|
47
|
+
// bundler re-evaluation.
|
|
181
48
|
let signedOutReason = 'no_session';
|
|
182
|
-
let navigating = false;
|
|
183
49
|
const steps = [];
|
|
184
|
-
// 1.
|
|
50
|
+
// 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
|
|
51
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
52
|
+
// a single bearer-less POST (no cookie, no navigation).
|
|
185
53
|
steps.push({
|
|
186
|
-
id: '
|
|
187
|
-
enabled: () => isWeb && (0, deviceBootReturn_1.hashHasBootFragment)(dom.getHash()),
|
|
188
|
-
run: async () => {
|
|
189
|
-
const outcome = await (0, deviceBootReturn_1.consumeDeviceBootReturn)({
|
|
190
|
-
hash: dom.getHash(),
|
|
191
|
-
stripFragment: () => dom.stripFragment(),
|
|
192
|
-
readExpectedState: () => dom.getSessionItem(deviceBootReturn_1.BOOT_STATE_SESSION_KEY),
|
|
193
|
-
clearExpectedState: () => dom.removeSessionItem(deviceBootReturn_1.BOOT_STATE_SESSION_KEY),
|
|
194
|
-
store,
|
|
195
|
-
exchangeBootCode: (code) => oxy.exchangeBootCode(code),
|
|
196
|
-
plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
|
|
197
|
-
});
|
|
198
|
-
if (outcome.kind === 'session') {
|
|
199
|
-
return { kind: 'session', session: outcome.session };
|
|
200
|
-
}
|
|
201
|
-
if (outcome.kind === 'state-mismatch') {
|
|
202
|
-
signedOutReason = 'state-mismatch';
|
|
203
|
-
}
|
|
204
|
-
else if (outcome.kind === 'no-session') {
|
|
205
|
-
// `DeviceBootReason` also carries 'session'; a no-session outcome with
|
|
206
|
-
// that reason (a `session` reason but no code) is still signed out.
|
|
207
|
-
signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
|
|
208
|
-
}
|
|
209
|
-
// Fall through: a stored refresh family (a prior same-origin session) may
|
|
210
|
-
// still recover below. The once-ever flag blocks a second hop.
|
|
211
|
-
return { kind: 'skip' };
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
// 2. stored-tokens — warm-plant or rotate the persisted refresh family.
|
|
215
|
-
steps.push({
|
|
216
|
-
id: 'stored-tokens',
|
|
54
|
+
id: 'device-secret-mint',
|
|
217
55
|
run: async () => {
|
|
218
56
|
const persisted = await store.load();
|
|
219
|
-
if (!persisted) {
|
|
57
|
+
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
220
58
|
return { kind: 'skip' };
|
|
221
59
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
60
|
+
try {
|
|
61
|
+
const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
62
|
+
// Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
|
|
63
|
+
// fields, + the server's authoritative active account) BEFORE planting
|
|
64
|
+
// the minted access token, so a multi-tab race that rotates again can
|
|
65
|
+
// never strand this tab with a superseded secret.
|
|
66
|
+
const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
|
|
67
|
+
const next = {
|
|
68
|
+
...persisted,
|
|
69
|
+
deviceId: mint.state.deviceId,
|
|
70
|
+
deviceSecret: mint.nextDeviceSecret,
|
|
71
|
+
accessToken: mint.accessToken,
|
|
72
|
+
expiresAt: mint.expiresAt,
|
|
73
|
+
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
74
|
+
};
|
|
75
|
+
await store.save(next);
|
|
76
|
+
oxy.setTokens(mint.accessToken);
|
|
77
|
+
return {
|
|
78
|
+
kind: 'session',
|
|
79
|
+
session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
|
|
80
|
+
};
|
|
228
81
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
82
|
+
catch (error) {
|
|
83
|
+
const failure = classifyMintFailure(error);
|
|
84
|
+
if (failure === 'invalid_secret') {
|
|
85
|
+
// Stale/diverged secret — drop it so the mint lane stops firing. On
|
|
86
|
+
// native the shared-key step below can still recover; on web this ends
|
|
87
|
+
// signed out. Setting it undefined drops the key on the store's JSON
|
|
88
|
+
// serialization, and the mint guard treats undefined as absent.
|
|
89
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
90
|
+
return { kind: 'skip' };
|
|
91
|
+
}
|
|
92
|
+
if (failure === 'no_active_session') {
|
|
93
|
+
// Device known, no live session — authoritative signed-out.
|
|
94
|
+
signedOutReason = 'no_session';
|
|
95
|
+
return { kind: 'skip' };
|
|
96
|
+
}
|
|
97
|
+
// Transient (network / 5xx): keep the secret; a later attempt can succeed.
|
|
98
|
+
loggerUtils_1.logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
|
|
233
99
|
return { kind: 'skip' };
|
|
234
100
|
}
|
|
235
|
-
const after = await store.load();
|
|
236
|
-
const base = after ?? persisted;
|
|
237
|
-
return { kind: 'session', session: sessionFromPersisted(base, token) };
|
|
238
101
|
},
|
|
239
102
|
});
|
|
240
|
-
//
|
|
103
|
+
// 2. shared-key-signin (native) — re-mint from the shared identity.
|
|
241
104
|
steps.push({
|
|
242
105
|
id: 'shared-key-signin',
|
|
243
106
|
enabled: () => isNative,
|
|
@@ -246,20 +109,6 @@ async function runSessionColdBoot(opts) {
|
|
|
246
109
|
if (!session?.accessToken) {
|
|
247
110
|
return { kind: 'skip' };
|
|
248
111
|
}
|
|
249
|
-
// First shared-key sign-in on this device: issue + persist + mirror a
|
|
250
|
-
// shared deviceToken so every native Oxy app joins one DeviceSession.
|
|
251
|
-
// Best-effort — never fail the sign-in over device-token issuance.
|
|
252
|
-
try {
|
|
253
|
-
const existing = await keyManager_1.KeyManager.getSharedDeviceToken();
|
|
254
|
-
if (!existing) {
|
|
255
|
-
const deviceToken = await oxy.issueNativeDeviceToken();
|
|
256
|
-
await store.saveDeviceToken(deviceToken);
|
|
257
|
-
await keyManager_1.KeyManager.setSharedDeviceToken(deviceToken);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
catch (error) {
|
|
261
|
-
loggerUtils_1.logger.debug('Native deviceToken issuance skipped', { component: 'coldBootV2', method: 'shared-key-signin' }, error);
|
|
262
|
-
}
|
|
263
112
|
return {
|
|
264
113
|
kind: 'session',
|
|
265
114
|
session: {
|
|
@@ -270,66 +119,6 @@ async function runSessionColdBoot(opts) {
|
|
|
270
119
|
};
|
|
271
120
|
},
|
|
272
121
|
});
|
|
273
|
-
// 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
|
|
274
|
-
steps.push({
|
|
275
|
-
id: 'bootstrap-hop',
|
|
276
|
-
enabled: () => isWeb,
|
|
277
|
-
run: async () => {
|
|
278
|
-
const pageHost = dom.getLocationHostname();
|
|
279
|
-
let apiHost = null;
|
|
280
|
-
try {
|
|
281
|
-
apiHost = new URL(oxy.getBaseURL()).hostname;
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
apiHost = null;
|
|
285
|
-
}
|
|
286
|
-
if (!pageHost || !apiHost) {
|
|
287
|
-
return { kind: 'skip' };
|
|
288
|
-
}
|
|
289
|
-
// Same-apex: inline credentialed fetch, no redirect, runs every boot.
|
|
290
|
-
if (isSameApex(pageHost, apiHost)) {
|
|
291
|
-
const result = await oxy.requestWebSession();
|
|
292
|
-
// The rotated deviceToken is on BOTH arms (it is device-level, not
|
|
293
|
-
// session-level) — persist it before branching on the session.
|
|
294
|
-
await store.saveDeviceToken(result.deviceToken);
|
|
295
|
-
if (result.reason === 'session') {
|
|
296
|
-
const bundle = result.session;
|
|
297
|
-
const userId = (0, contracts_1.resolveUserId)(bundle.user);
|
|
298
|
-
if (!userId) {
|
|
299
|
-
return { kind: 'skip' };
|
|
300
|
-
}
|
|
301
|
-
const next = {
|
|
302
|
-
sessionId: bundle.sessionId,
|
|
303
|
-
refreshToken: bundle.refreshToken,
|
|
304
|
-
userId,
|
|
305
|
-
deviceToken: result.deviceToken,
|
|
306
|
-
accessToken: bundle.accessToken,
|
|
307
|
-
expiresAt: bundle.expiresAt,
|
|
308
|
-
};
|
|
309
|
-
await store.save(next);
|
|
310
|
-
oxy.setTokens(bundle.accessToken);
|
|
311
|
-
return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
|
|
312
|
-
}
|
|
313
|
-
// Known device, signed out.
|
|
314
|
-
signedOutReason = result.reason;
|
|
315
|
-
return { kind: 'skip' };
|
|
316
|
-
}
|
|
317
|
-
// Cross-apex: ONE visible top-level navigation, once-ever per origin.
|
|
318
|
-
if (dom.getLocalItem(exports.BOOT_ATTEMPTED_KEY)) {
|
|
319
|
-
return { kind: 'skip' };
|
|
320
|
-
}
|
|
321
|
-
const returnTo = opts.returnTo ?? dom.getReturnToHref();
|
|
322
|
-
if (!returnTo) {
|
|
323
|
-
return { kind: 'skip' };
|
|
324
|
-
}
|
|
325
|
-
const state = dom.randomState();
|
|
326
|
-
dom.setSessionItem(deviceBootReturn_1.BOOT_STATE_SESSION_KEY, state);
|
|
327
|
-
dom.setLocalItem(exports.BOOT_ATTEMPTED_KEY, '1');
|
|
328
|
-
navigating = true;
|
|
329
|
-
dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
|
|
330
|
-
return { kind: 'skip' };
|
|
331
|
-
},
|
|
332
|
-
});
|
|
333
122
|
const outcome = await (0, coldBoot_1.runColdBoot)({
|
|
334
123
|
steps,
|
|
335
124
|
onStepError: (id, error) => {
|
|
@@ -341,10 +130,6 @@ async function runSessionColdBoot(opts) {
|
|
|
341
130
|
await opts.onSession?.({ ...outcome.session, via: outcome.via });
|
|
342
131
|
return outcome;
|
|
343
132
|
}
|
|
344
|
-
|
|
345
|
-
// flash a signed-out state.
|
|
346
|
-
if (!navigating) {
|
|
347
|
-
await opts.onSignedOut?.(signedOutReason);
|
|
348
|
-
}
|
|
133
|
+
await opts.onSignedOut?.(signedOutReason);
|
|
349
134
|
return outcome;
|
|
350
135
|
}
|
|
@@ -57,11 +57,6 @@ const STORAGE_KEYS = {
|
|
|
57
57
|
SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
|
|
58
58
|
SHARED_SESSION_TOKEN: 'oxy_shared_session_token',
|
|
59
59
|
SHARED_SESSION_ID: 'oxy_shared_session_id',
|
|
60
|
-
// Opaque device-attribution token, shared across all Oxy apps on one device
|
|
61
|
-
// so they all resolve to a SINGLE server-side DeviceSession (device-first
|
|
62
|
-
// session model). Add-only: it never carries session state, only attributes
|
|
63
|
-
// a newly-credentialed session to the shared device set.
|
|
64
|
-
SHARED_DEVICE_TOKEN: 'oxy_shared_device_token',
|
|
65
60
|
};
|
|
66
61
|
/**
|
|
67
62
|
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
@@ -549,96 +544,6 @@ class KeyManager {
|
|
|
549
544
|
return false;
|
|
550
545
|
}
|
|
551
546
|
}
|
|
552
|
-
/**
|
|
553
|
-
* Store the opaque device-attribution token in the SHARED keychain so every
|
|
554
|
-
* Oxy app on this device reads the SAME token and therefore resolves to one
|
|
555
|
-
* server-side DeviceSession.
|
|
556
|
-
*
|
|
557
|
-
* Mirrors the shared-session storage options exactly (iOS keychain access
|
|
558
|
-
* group `group.so.oxy.shared`; Android shared secure store). Native-only —
|
|
559
|
-
* a no-op on web (the shared keychain does not exist there; web persists its
|
|
560
|
-
* deviceToken in the per-origin {@link AuthStateStore} instead).
|
|
561
|
-
*/
|
|
562
|
-
static async setSharedDeviceToken(deviceToken) {
|
|
563
|
-
if (isWebPlatform()) {
|
|
564
|
-
return; // Not supported on web
|
|
565
|
-
}
|
|
566
|
-
try {
|
|
567
|
-
const store = await initSecureStore();
|
|
568
|
-
if ((0, platform_1.isIOS)()) {
|
|
569
|
-
const opts = {
|
|
570
|
-
keychainAccessible: store.WHEN_UNLOCKED,
|
|
571
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
572
|
-
};
|
|
573
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken, opts);
|
|
574
|
-
}
|
|
575
|
-
else if ((0, platform_1.isAndroid)()) {
|
|
576
|
-
await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken);
|
|
577
|
-
}
|
|
578
|
-
if ((0, debugUtils_1.isDev)()) {
|
|
579
|
-
loggerUtils_1.logger.debug('Shared device token stored successfully', { component: 'KeyManager' });
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
catch (error) {
|
|
583
|
-
if ((0, debugUtils_1.isDev)()) {
|
|
584
|
-
loggerUtils_1.logger.error('Failed to store shared device token', error, { component: 'KeyManager' });
|
|
585
|
-
}
|
|
586
|
-
throw error;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Read the shared device-attribution token, or `null` when none is set (or
|
|
591
|
-
* on web). Mirrors {@link getSharedSession}'s keychain-access-group read.
|
|
592
|
-
*/
|
|
593
|
-
static async getSharedDeviceToken() {
|
|
594
|
-
if (isWebPlatform()) {
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
|
-
try {
|
|
598
|
-
const store = await initSecureStore();
|
|
599
|
-
if ((0, platform_1.isIOS)()) {
|
|
600
|
-
const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
601
|
-
return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
|
|
602
|
-
}
|
|
603
|
-
if ((0, platform_1.isAndroid)()) {
|
|
604
|
-
return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
|
|
605
|
-
}
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
catch (error) {
|
|
609
|
-
if ((0, debugUtils_1.isDev)()) {
|
|
610
|
-
loggerUtils_1.logger.warn('Failed to get shared device token', { component: 'KeyManager' }, error);
|
|
611
|
-
}
|
|
612
|
-
return null;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Delete the shared device-attribution token (device signout-all). Best-effort:
|
|
617
|
-
* a keychain failure is logged, not thrown, so it cannot block a sign-out.
|
|
618
|
-
*/
|
|
619
|
-
static async clearSharedDeviceToken() {
|
|
620
|
-
if (isWebPlatform()) {
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
try {
|
|
624
|
-
const store = await initSecureStore();
|
|
625
|
-
if ((0, platform_1.isIOS)()) {
|
|
626
|
-
const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
627
|
-
await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
|
|
628
|
-
}
|
|
629
|
-
else if ((0, platform_1.isAndroid)()) {
|
|
630
|
-
await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
|
|
631
|
-
}
|
|
632
|
-
if ((0, debugUtils_1.isDev)()) {
|
|
633
|
-
loggerUtils_1.logger.debug('Shared device token cleared successfully', { component: 'KeyManager' });
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
catch (error) {
|
|
637
|
-
if ((0, debugUtils_1.isDev)()) {
|
|
638
|
-
loggerUtils_1.logger.error('Failed to clear shared device token', error, { component: 'KeyManager' });
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
547
|
// ==================== END SHARED IDENTITY METHODS ====================
|
|
643
548
|
/**
|
|
644
549
|
* Atomically persist a key pair to secure storage with verification + backup.
|
package/dist/cjs/index.js
CHANGED
|
@@ -21,7 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
21
21
|
exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
|
|
22
22
|
exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = void 0;
|
|
23
23
|
exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidDisplayName = void 0;
|
|
24
|
-
exports.packageInfo = exports.
|
|
24
|
+
exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = void 0;
|
|
25
25
|
// Ensure crypto polyfills are loaded before anything else
|
|
26
26
|
require("./crypto/polyfill");
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
@@ -313,18 +313,17 @@ var accountDialogController_1 = require("./session/accountDialogController");
|
|
|
313
313
|
Object.defineProperty(exports, "AccountDialogController", { enumerable: true, get: function () { return accountDialogController_1.AccountDialogController; } });
|
|
314
314
|
Object.defineProperty(exports, "createAccountDialogController", { enumerable: true, get: function () { return accountDialogController_1.createAccountDialogController; } });
|
|
315
315
|
// ---------------------------------------------------------------------------
|
|
316
|
-
// Device-first session machinery (
|
|
317
|
-
// Persisted auth-state store, the unified
|
|
318
|
-
// cold-boot
|
|
319
|
-
//
|
|
320
|
-
//
|
|
316
|
+
// Device-first session machinery (zero-cookie transport).
|
|
317
|
+
// Persisted auth-state store, the unified re-mint handler + scheduler, and the
|
|
318
|
+
// cold-boot runner. Built ON the `runColdBoot` primitive + `SessionClient`. The
|
|
319
|
+
// device credential is `deviceId` + `deviceSecret`; the access token is re-minted
|
|
320
|
+
// via `POST /session/device/token`.
|
|
321
321
|
// ---------------------------------------------------------------------------
|
|
322
322
|
var authStateStore_1 = require("./session/authStateStore");
|
|
323
323
|
Object.defineProperty(exports, "createWebAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createWebAuthStateStore; } });
|
|
324
324
|
Object.defineProperty(exports, "createNativeAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createNativeAuthStateStore; } });
|
|
325
325
|
Object.defineProperty(exports, "createMemoryAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createMemoryAuthStateStore; } });
|
|
326
326
|
Object.defineProperty(exports, "AUTH_STATE_STORAGE_KEY", { enumerable: true, get: function () { return authStateStore_1.AUTH_STATE_STORAGE_KEY; } });
|
|
327
|
-
Object.defineProperty(exports, "DEVICE_TOKEN_STORAGE_KEY", { enumerable: true, get: function () { return authStateStore_1.DEVICE_TOKEN_STORAGE_KEY; } });
|
|
328
327
|
var refresh_1 = require("./session/refresh");
|
|
329
328
|
Object.defineProperty(exports, "refreshPersistedSession", { enumerable: true, get: function () { return refresh_1.refreshPersistedSession; } });
|
|
330
329
|
Object.defineProperty(exports, "createAuthRefreshHandler", { enumerable: true, get: function () { return refresh_1.createAuthRefreshHandler; } });
|
|
@@ -333,18 +332,6 @@ Object.defineProperty(exports, "startTokenRefreshScheduler", { enumerable: true,
|
|
|
333
332
|
Object.defineProperty(exports, "TOKEN_REFRESH_LEAD_MS", { enumerable: true, get: function () { return refresh_1.TOKEN_REFRESH_LEAD_MS; } });
|
|
334
333
|
var coldBootV2_1 = require("./boot/coldBootV2");
|
|
335
334
|
Object.defineProperty(exports, "runSessionColdBoot", { enumerable: true, get: function () { return coldBootV2_1.runSessionColdBoot; } });
|
|
336
|
-
Object.defineProperty(exports, "createBrowserColdBootDom", { enumerable: true, get: function () { return coldBootV2_1.createBrowserColdBootDom; } });
|
|
337
|
-
Object.defineProperty(exports, "isSameApex", { enumerable: true, get: function () { return coldBootV2_1.isSameApex; } });
|
|
338
|
-
Object.defineProperty(exports, "BOOT_ATTEMPTED_KEY", { enumerable: true, get: function () { return coldBootV2_1.BOOT_ATTEMPTED_KEY; } });
|
|
339
|
-
var deviceBootReturn_1 = require("./boot/deviceBootReturn");
|
|
340
|
-
Object.defineProperty(exports, "consumeDeviceBootReturn", { enumerable: true, get: function () { return deviceBootReturn_1.consumeDeviceBootReturn; } });
|
|
341
|
-
Object.defineProperty(exports, "parseDeviceBootFragment", { enumerable: true, get: function () { return deviceBootReturn_1.parseDeviceBootFragment; } });
|
|
342
|
-
Object.defineProperty(exports, "hashHasBootFragment", { enumerable: true, get: function () { return deviceBootReturn_1.hashHasBootFragment; } });
|
|
343
|
-
Object.defineProperty(exports, "BOOT_FRAGMENT_PARAM", { enumerable: true, get: function () { return deviceBootReturn_1.BOOT_FRAGMENT_PARAM; } });
|
|
344
|
-
Object.defineProperty(exports, "BOOT_STATE_SESSION_KEY", { enumerable: true, get: function () { return deviceBootReturn_1.BOOT_STATE_SESSION_KEY; } });
|
|
345
|
-
// The web-session result contract (`WebSessionResult`) is owned by
|
|
346
|
-
// `@oxyhq/contracts` — import it directly from there; `@oxyhq/core` does not
|
|
347
|
-
// re-export contract types.
|
|
348
335
|
// API response contracts (request/response Zod schemas + inferred types) live in
|
|
349
336
|
// `@oxyhq/contracts` — the single source of truth shared by the backend and every
|
|
350
337
|
// client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
|
|
@@ -796,11 +796,10 @@ function OxyServicesAuthMixin(Base) {
|
|
|
796
796
|
* (`{ twoFactorRequired, loginToken }`) to complete via
|
|
797
797
|
* {@link completeTwoFactorSignIn}, or a session arm.
|
|
798
798
|
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
* caller has an authenticated client without a second round-trip.
|
|
799
|
+
* On the session arm, a returned access token is planted immediately
|
|
800
|
+
* (mirroring {@link verifyChallenge}), so the caller has an authenticated
|
|
801
|
+
* client without a second round-trip. The response's `deviceId` +
|
|
802
|
+
* `deviceSecret` are the zero-cookie restore credential the caller persists.
|
|
804
803
|
*/
|
|
805
804
|
async passwordSignIn(identifier, password, options = {}) {
|
|
806
805
|
try {
|
|
@@ -809,7 +808,6 @@ function OxyServicesAuthMixin(Base) {
|
|
|
809
808
|
password,
|
|
810
809
|
deviceName: options.deviceName,
|
|
811
810
|
deviceFingerprint: options.deviceFingerprint,
|
|
812
|
-
deviceToken: options.deviceToken,
|
|
813
811
|
}, { cache: false });
|
|
814
812
|
const parsed = (0, contracts_1.safeParseContract)(contracts_1.loginResultSchema, res);
|
|
815
813
|
if (!parsed) {
|
|
@@ -837,7 +835,6 @@ function OxyServicesAuthMixin(Base) {
|
|
|
837
835
|
loginToken: params.loginToken,
|
|
838
836
|
token: params.token,
|
|
839
837
|
backupCode: params.backupCode,
|
|
840
|
-
deviceToken: params.deviceToken,
|
|
841
838
|
deviceName: params.deviceName,
|
|
842
839
|
}, { cache: false });
|
|
843
840
|
const parsed = (0, contracts_1.safeParseContract)(contracts_1.loginResultSchema, res);
|