@oxyhq/core 5.4.3 → 6.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.
Files changed (186) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -43
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +44 -2
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/createSessionClient.js +8 -2
  20. package/dist/cjs/session/refresh.js +264 -0
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +6 -15
  23. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/ssoBounce.js +15 -340
  26. package/dist/cjs/utils/validationUtils.js +57 -0
  27. package/dist/esm/.tsbuildinfo +1 -1
  28. package/dist/esm/HttpService.js +6 -3
  29. package/dist/esm/OxyServices.base.js +7 -102
  30. package/dist/esm/boot/coldBootV2.js +344 -0
  31. package/dist/esm/boot/deviceBootReturn.js +146 -0
  32. package/dist/esm/crypto/keyManager.js +95 -0
  33. package/dist/esm/i18n/locales/en-US.json +13 -1
  34. package/dist/esm/i18n/locales/es-ES.json +13 -1
  35. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  36. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  37. package/dist/esm/index.js +28 -18
  38. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  39. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  40. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +13 -17
  43. package/dist/esm/session/SessionClient.js +44 -2
  44. package/dist/esm/session/authStateStore.js +278 -0
  45. package/dist/esm/session/createSessionClient.js +8 -2
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/utils/accountUtils.js +0 -53
  48. package/dist/esm/utils/authWebUrl.js +6 -14
  49. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  50. package/dist/esm/utils/platform.js +18 -0
  51. package/dist/esm/utils/ssoBounce.js +14 -324
  52. package/dist/esm/utils/validationUtils.js +56 -0
  53. package/dist/types/.tsbuildinfo +1 -1
  54. package/dist/types/HttpService.d.ts +13 -0
  55. package/dist/types/OxyServices.base.d.ts +0 -52
  56. package/dist/types/OxyServices.d.ts +0 -25
  57. package/dist/types/boot/coldBootV2.d.ts +76 -0
  58. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  59. package/dist/types/crypto/keyManager.d.ts +21 -0
  60. package/dist/types/index.d.ts +16 -21
  61. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  66. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  67. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  71. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  85. package/dist/types/mixins/index.d.ts +6 -9
  86. package/dist/types/models/interfaces.d.ts +0 -67
  87. package/dist/types/session/SessionClient.d.ts +38 -1
  88. package/dist/types/session/authStateStore.d.ts +119 -0
  89. package/dist/types/session/createSessionClient.d.ts +8 -1
  90. package/dist/types/session/refresh.d.ts +93 -0
  91. package/dist/types/utils/accountUtils.d.ts +0 -14
  92. package/dist/types/utils/authWebUrl.d.ts +6 -12
  93. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  94. package/dist/types/utils/platform.d.ts +14 -0
  95. package/dist/types/utils/ssoBounce.d.ts +14 -262
  96. package/dist/types/utils/validationUtils.d.ts +15 -0
  97. package/package.json +2 -2
  98. package/src/HttpService.ts +19 -3
  99. package/src/OxyServices.base.ts +7 -112
  100. package/src/OxyServices.ts +0 -38
  101. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  102. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  103. package/src/boot/coldBootV2.ts +426 -0
  104. package/src/boot/deviceBootReturn.ts +195 -0
  105. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  106. package/src/crypto/keyManager.ts +101 -0
  107. package/src/i18n/locales/en-US.json +13 -1
  108. package/src/i18n/locales/es-ES.json +13 -1
  109. package/src/index.ts +78 -64
  110. package/src/mixins/OxyServices.accounts.ts +6 -13
  111. package/src/mixins/OxyServices.auth.ts +78 -253
  112. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  113. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  114. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  115. package/src/mixins/__tests__/accounts.test.ts +17 -44
  116. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  117. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  118. package/src/mixins/index.ts +16 -22
  119. package/src/models/interfaces.ts +0 -79
  120. package/src/session/SessionClient.ts +69 -3
  121. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  122. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  123. package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
  124. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  125. package/src/session/__tests__/authStateStore.test.ts +209 -0
  126. package/src/session/__tests__/refresh.test.ts +256 -0
  127. package/src/session/authStateStore.ts +335 -0
  128. package/src/session/createSessionClient.ts +9 -1
  129. package/src/session/refresh.ts +334 -0
  130. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  131. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  132. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  133. package/src/utils/accountUtils.ts +0 -62
  134. package/src/utils/authWebUrl.ts +6 -15
  135. package/src/utils/fapiAutoDetect.ts +16 -60
  136. package/src/utils/platform.ts +21 -0
  137. package/src/utils/ssoBounce.ts +14 -371
  138. package/src/utils/validationUtils.ts +62 -0
  139. package/dist/cjs/AuthManager.js +0 -1110
  140. package/dist/cjs/AuthManagerTypes.js +0 -13
  141. package/dist/cjs/CrossDomainAuth.js +0 -206
  142. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  143. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  144. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  145. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  146. package/dist/cjs/utils/ssoEstablish.js +0 -110
  147. package/dist/cjs/utils/ssoReturn.js +0 -267
  148. package/dist/esm/AuthManager.js +0 -1105
  149. package/dist/esm/AuthManagerTypes.js +0 -12
  150. package/dist/esm/CrossDomainAuth.js +0 -201
  151. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  152. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  153. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  154. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  155. package/dist/esm/utils/ssoEstablish.js +0 -107
  156. package/dist/esm/utils/ssoReturn.js +0 -263
  157. package/dist/types/AuthManager.d.ts +0 -380
  158. package/dist/types/AuthManagerTypes.d.ts +0 -81
  159. package/dist/types/CrossDomainAuth.d.ts +0 -164
  160. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  161. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  162. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  163. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  164. package/dist/types/utils/ssoReturn.d.ts +0 -146
  165. package/src/AuthManager.ts +0 -1269
  166. package/src/AuthManagerTypes.ts +0 -86
  167. package/src/CrossDomainAuth.ts +0 -243
  168. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  169. package/src/__tests__/authManager.security.test.ts +0 -377
  170. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  171. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  172. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  173. package/src/mixins/OxyServices.redirect.ts +0 -122
  174. package/src/mixins/OxyServices.silent.ts +0 -272
  175. package/src/mixins/OxyServices.sso.ts +0 -261
  176. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  177. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  178. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  179. package/src/mixins/__tests__/silent.test.ts +0 -102
  180. package/src/mixins/__tests__/sso.test.ts +0 -228
  181. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  182. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  183. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  184. package/src/utils/__tests__/ssoReturn.test.ts +0 -251
  185. package/src/utils/ssoEstablish.ts +0 -174
  186. package/src/utils/ssoReturn.ts +0 -372
@@ -0,0 +1,426 @@
1
+ /**
2
+ * coldBootV2 — one device-first cold boot for every consumer.
3
+ *
4
+ * On a fresh page load / app launch this resolves the device's session in a
5
+ * deterministic order, built on the pure `runColdBoot` primitive. It NEVER
6
+ * redirects to a login page: an unresolved boot ends in a signed-out state that
7
+ * the app renders with a "Sign in with Oxy" button.
8
+ *
9
+ * Ordered steps (first to yield a session wins):
10
+ * 1. `bootstrap-return` (web) — consume a `#oxy_boot` return fragment from a
11
+ * just-completed cross-apex hop: strip it, verify state, persist the
12
+ * deviceToken, exchange the code.
13
+ * 2. `stored-tokens` — the persisted per-origin refresh family: warm-plant a
14
+ * still-valid access token, else rotate via `/auth/refresh-token`.
15
+ * 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity,
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.
25
+ *
26
+ * ESM-safe (no `require()`); no react/react-native/expo imports.
27
+ */
28
+ import { resolveUserId } from '@oxyhq/contracts';
29
+ import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
30
+ import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform';
31
+ import { KeyManager } from '../crypto/keyManager';
32
+ import { logger } from '../utils/loggerUtils';
33
+ import type { OxyServices } from '../OxyServices';
34
+ import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
35
+ import { refreshPersistedSession } from '../session/refresh';
36
+ import {
37
+ consumeDeviceBootReturn,
38
+ hashHasBootFragment,
39
+ BOOT_STATE_SESSION_KEY,
40
+ type DeviceBootSession,
41
+ } from './deviceBootReturn';
42
+
43
+ /**
44
+ * localStorage flag marking that the cross-apex bootstrap navigation has fired
45
+ * once for this origin. Persistent (not session) so the visible redirect
46
+ * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
47
+ * bounced again; they sign in explicitly.
48
+ */
49
+ export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
50
+
51
+ /**
52
+ * Do not warm-plant a stored access token with less than this remaining — it
53
+ * would need an immediate refresh anyway, so fall through to the rotate path.
54
+ * Matches the refresh lead window.
55
+ */
56
+ const WARM_MIN_REMAINING_MS = 60_000;
57
+
58
+ /** Why a cold boot ended without a session. */
59
+ export type SignedOutReason =
60
+ | 'no_session'
61
+ | 'new_device'
62
+ | 'state-mismatch'
63
+ | 'error';
64
+
65
+ /**
66
+ * The DOM/storage seam. All access is injected so the boot is unit-testable
67
+ * under the jest `node` environment; {@link createBrowserColdBootDom} provides
68
+ * the guarded real-globals implementation used in production.
69
+ */
70
+ export interface ColdBootDom {
71
+ getHash(): string;
72
+ stripFragment(): void;
73
+ getSessionItem(key: string): string | null;
74
+ setSessionItem(key: string, value: string): void;
75
+ removeSessionItem(key: string): void;
76
+ getLocalItem(key: string): string | null;
77
+ setLocalItem(key: string, value: string): void;
78
+ getLocationHostname(): string | null;
79
+ /** Current href WITHOUT its hash fragment (the bootstrap `return_to`). */
80
+ getReturnToHref(): string | null;
81
+ navigate(url: string): void;
82
+ /** A fresh high-entropy CSRF state token. */
83
+ randomState(): string;
84
+ }
85
+
86
+ export interface RunSessionColdBootOptions {
87
+ oxy: OxyServices;
88
+ store: AuthStateStore;
89
+ /** Platform hints; default derived from `@oxyhq/core`'s platform detection. */
90
+ platform?: { isWeb?: boolean; isNative?: boolean };
91
+ /**
92
+ * The RP return URL for the cross-apex hop. Defaults to the current href
93
+ * (sans fragment) via the DOM seam.
94
+ */
95
+ returnTo?: string;
96
+ /** Invoked with the winning session (token already planted). */
97
+ onSession?: (session: DeviceBootSession & { via: string }) => void | Promise<void>;
98
+ /** Invoked when the boot ended signed out (not while navigating away). */
99
+ onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
100
+ onStepError?: (id: string, error: unknown) => void;
101
+ /** DOM/storage seam; defaults to the guarded browser implementation. */
102
+ dom?: ColdBootDom;
103
+ }
104
+
105
+ /**
106
+ * Generate a 128-bit hex CSRF state token. Prefers Web Crypto
107
+ * (`crypto.getRandomValues`, present in browsers and modern Node); falls back
108
+ * to a time+`Math.random` mix only when no CSPRNG is reachable (this token
109
+ * gates a single-use CSRF echo, not a long-lived secret).
110
+ */
111
+ function generateStateToken(): string {
112
+ const cryptoObj = (globalThis as { crypto?: Crypto }).crypto;
113
+ if (cryptoObj?.getRandomValues) {
114
+ const bytes = new Uint8Array(16);
115
+ cryptoObj.getRandomValues(bytes);
116
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
117
+ }
118
+ return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
119
+ }
120
+
121
+ /**
122
+ * The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
123
+ * missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
124
+ * or no-op, so the boot degrades to signed-out rather than crashing.
125
+ */
126
+ export function createBrowserColdBootDom(): ColdBootDom {
127
+ const safe = <T>(fn: () => T, fallback: T): T => {
128
+ try {
129
+ return fn();
130
+ } catch {
131
+ return fallback;
132
+ }
133
+ };
134
+ return {
135
+ getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
136
+ stripFragment: () =>
137
+ safe(() => {
138
+ if (typeof window !== 'undefined' && window.history?.replaceState) {
139
+ const { pathname, search } = window.location;
140
+ window.history.replaceState(null, '', `${pathname}${search}`);
141
+ }
142
+ }, undefined),
143
+ getSessionItem: (key) =>
144
+ safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
145
+ setSessionItem: (key, value) =>
146
+ safe(() => {
147
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value);
148
+ }, undefined),
149
+ removeSessionItem: (key) =>
150
+ safe(() => {
151
+ if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(key);
152
+ }, undefined),
153
+ getLocalItem: (key) =>
154
+ safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
155
+ setLocalItem: (key, value) =>
156
+ safe(() => {
157
+ if (typeof localStorage !== 'undefined') localStorage.setItem(key, value);
158
+ }, undefined),
159
+ getLocationHostname: () =>
160
+ safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
161
+ getReturnToHref: () =>
162
+ safe(() => {
163
+ if (typeof window === 'undefined') return null;
164
+ const { origin, pathname, search } = window.location;
165
+ return `${origin}${pathname}${search}`;
166
+ }, null),
167
+ navigate: (url) =>
168
+ safe(() => {
169
+ if (typeof window !== 'undefined') window.location.assign(url);
170
+ }, undefined),
171
+ randomState: generateStateToken,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Registrable domain = the last two labels of a host. A deliberately SMALL
177
+ * local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
178
+ * Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
179
+ * `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
180
+ * such as `co.uk` — never arises here because the compared API host is always
181
+ * `api.oxy.so`, so a page under a different registrable domain still classifies
182
+ * as cross-apex.
183
+ */
184
+ function registrableDomain(host: string): string {
185
+ const labels = host.toLowerCase().split('.').filter(Boolean);
186
+ return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
187
+ }
188
+
189
+ /**
190
+ * Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
191
+ * hosts have NO registrable domain — the last-two-labels heuristic would
192
+ * mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
193
+ * `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
194
+ * classified same-apex as a different LAN API and skip the cross-apex hop.
195
+ */
196
+ function isIpOrSingleLabel(host: string): boolean {
197
+ if (host.includes(':')) {
198
+ return true; // IPv6 literal
199
+ }
200
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
201
+ return true; // IPv4 literal
202
+ }
203
+ return !host.includes('.'); // single-label (e.g. `localhost`)
204
+ }
205
+
206
+ /**
207
+ * True when both hosts are same-site / same-apex. For a normal multi-label host
208
+ * pair, that means sharing a registrable domain. For an IP literal or a
209
+ * single-label host (no registrable domain), same-apex requires the two hosts
210
+ * to be EXACTLY equal — never grouped by a spurious trailing-label match.
211
+ */
212
+ export function isSameApex(pageHost: string, apiHost: string): boolean {
213
+ const a = pageHost.toLowerCase();
214
+ const b = apiHost.toLowerCase();
215
+ if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
216
+ return a === b;
217
+ }
218
+ const ra = registrableDomain(a);
219
+ const rb = registrableDomain(b);
220
+ return ra !== '' && ra === rb;
221
+ }
222
+
223
+ /** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
224
+ function sessionFromPersisted(state: PersistedAuthState, accessToken: string): DeviceBootSession {
225
+ return { sessionId: state.sessionId, userId: state.userId, accessToken };
226
+ }
227
+
228
+ /**
229
+ * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
230
+ * a side effect, invokes `onSession` (winning session, token already planted)
231
+ * or `onSignedOut` (no session — unless the boot is navigating away for the
232
+ * cross-apex hop, in which case neither fires).
233
+ */
234
+ export async function runSessionColdBoot(
235
+ opts: RunSessionColdBootOptions,
236
+ ): Promise<ColdBootOutcome<DeviceBootSession>> {
237
+ const { oxy, store } = opts;
238
+ const dom = opts.dom ?? createBrowserColdBootDom();
239
+ const isWeb = opts.platform?.isWeb ?? detectWeb();
240
+ const isNative = opts.platform?.isNative ?? detectNative();
241
+
242
+ // Signed-out reason + navigating flag are boot-local (not module-level), so
243
+ // they cannot leak across boots or break under bundler re-evaluation.
244
+ let signedOutReason: SignedOutReason = 'no_session';
245
+ let navigating = false;
246
+
247
+ const steps: Array<ColdBootStep<DeviceBootSession>> = [];
248
+
249
+ // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
250
+ steps.push({
251
+ id: 'bootstrap-return',
252
+ enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
253
+ run: async () => {
254
+ const outcome = await consumeDeviceBootReturn({
255
+ hash: dom.getHash(),
256
+ stripFragment: () => dom.stripFragment(),
257
+ readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
258
+ clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
259
+ store,
260
+ exchangeBootCode: (code) => oxy.exchangeBootCode(code),
261
+ plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
262
+ });
263
+ if (outcome.kind === 'session') {
264
+ return { kind: 'session', session: outcome.session };
265
+ }
266
+ if (outcome.kind === 'state-mismatch') {
267
+ signedOutReason = 'state-mismatch';
268
+ } else if (outcome.kind === 'no-session') {
269
+ // `DeviceBootReason` also carries 'session'; a no-session outcome with
270
+ // that reason (a `session` reason but no code) is still signed out.
271
+ signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
272
+ }
273
+ // Fall through: a stored refresh family (a prior same-origin session) may
274
+ // still recover below. The once-ever flag blocks a second hop.
275
+ return { kind: 'skip' };
276
+ },
277
+ });
278
+
279
+ // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
280
+ steps.push({
281
+ id: 'stored-tokens',
282
+ run: async () => {
283
+ const persisted = await store.load();
284
+ if (!persisted) {
285
+ return { kind: 'skip' };
286
+ }
287
+ // Warm path: a still-valid access token → plant immediately (no network).
288
+ if (
289
+ persisted.accessToken &&
290
+ persisted.expiresAt &&
291
+ Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS
292
+ ) {
293
+ oxy.setTokens(persisted.accessToken);
294
+ return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
295
+ }
296
+ // Rotate path: refreshPersistedSession plants + persists, and clears the
297
+ // store on a family-revoked error.
298
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
299
+ if (!token) {
300
+ return { kind: 'skip' };
301
+ }
302
+ const after = await store.load();
303
+ const base = after ?? persisted;
304
+ return { kind: 'session', session: sessionFromPersisted(base, token) };
305
+ },
306
+ });
307
+
308
+ // 3. shared-key-signin (native) — re-mint from the shared identity.
309
+ steps.push({
310
+ id: 'shared-key-signin',
311
+ enabled: () => isNative,
312
+ run: async () => {
313
+ const session = await oxy.signInWithSharedIdentity();
314
+ if (!session?.accessToken) {
315
+ return { kind: 'skip' };
316
+ }
317
+ // First shared-key sign-in on this device: issue + persist + mirror a
318
+ // shared deviceToken so every native Oxy app joins one DeviceSession.
319
+ // Best-effort — never fail the sign-in over device-token issuance.
320
+ try {
321
+ const existing = await KeyManager.getSharedDeviceToken();
322
+ if (!existing) {
323
+ const deviceToken = await oxy.issueNativeDeviceToken();
324
+ await store.saveDeviceToken(deviceToken);
325
+ await KeyManager.setSharedDeviceToken(deviceToken);
326
+ }
327
+ } catch (error) {
328
+ logger.debug(
329
+ 'Native deviceToken issuance skipped',
330
+ { component: 'coldBootV2', method: 'shared-key-signin' },
331
+ error,
332
+ );
333
+ }
334
+ return {
335
+ kind: 'session',
336
+ session: {
337
+ sessionId: session.sessionId,
338
+ userId: session.user.id,
339
+ accessToken: session.accessToken,
340
+ },
341
+ };
342
+ },
343
+ });
344
+
345
+ // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
346
+ steps.push({
347
+ id: 'bootstrap-hop',
348
+ enabled: () => isWeb,
349
+ run: async () => {
350
+ const pageHost = dom.getLocationHostname();
351
+ let apiHost: string | null = null;
352
+ try {
353
+ apiHost = new URL(oxy.getBaseURL()).hostname;
354
+ } catch {
355
+ apiHost = null;
356
+ }
357
+ if (!pageHost || !apiHost) {
358
+ return { kind: 'skip' };
359
+ }
360
+
361
+ // Same-apex: inline credentialed fetch, no redirect, runs every boot.
362
+ if (isSameApex(pageHost, apiHost)) {
363
+ const result = await oxy.requestWebSession();
364
+ // The rotated deviceToken is on BOTH arms (it is device-level, not
365
+ // session-level) — persist it before branching on the session.
366
+ await store.saveDeviceToken(result.deviceToken);
367
+ if (result.reason === 'session') {
368
+ const bundle = result.session;
369
+ const userId = resolveUserId(bundle.user);
370
+ if (!userId) {
371
+ return { kind: 'skip' };
372
+ }
373
+ const next: PersistedAuthState = {
374
+ sessionId: bundle.sessionId,
375
+ refreshToken: bundle.refreshToken,
376
+ userId,
377
+ deviceToken: result.deviceToken,
378
+ accessToken: bundle.accessToken,
379
+ expiresAt: bundle.expiresAt,
380
+ };
381
+ await store.save(next);
382
+ oxy.setTokens(bundle.accessToken);
383
+ return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
384
+ }
385
+ // Known device, signed out.
386
+ signedOutReason = result.reason;
387
+ return { kind: 'skip' };
388
+ }
389
+
390
+ // Cross-apex: ONE visible top-level navigation, once-ever per origin.
391
+ if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
392
+ return { kind: 'skip' };
393
+ }
394
+ const returnTo = opts.returnTo ?? dom.getReturnToHref();
395
+ if (!returnTo) {
396
+ return { kind: 'skip' };
397
+ }
398
+ const state = dom.randomState();
399
+ dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
400
+ dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
401
+ navigating = true;
402
+ dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
403
+ return { kind: 'skip' };
404
+ },
405
+ });
406
+
407
+ const outcome = await runColdBoot<DeviceBootSession>({
408
+ steps,
409
+ onStepError: (id, error) => {
410
+ signedOutReason = 'error';
411
+ opts.onStepError?.(id, error);
412
+ },
413
+ });
414
+
415
+ if (outcome.kind === 'session') {
416
+ await opts.onSession?.({ ...outcome.session, via: outcome.via });
417
+ return outcome;
418
+ }
419
+
420
+ // Navigating away for the cross-apex hop: the page is unloading, so do not
421
+ // flash a signed-out state.
422
+ if (!navigating) {
423
+ await opts.onSignedOut?.(signedOutReason);
424
+ }
425
+ return outcome;
426
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Device-boot return-fragment consumption (web cross-apex hop).
3
+ *
4
+ * After the top-level `GET /auth/device/bootstrap` hop, the API 303s back to the
5
+ * RP with a `#oxy_boot=<base64url(JSON)>` fragment. This module parses and
6
+ * consumes it: it strips the fragment from the URL FIRST (so the opaque
7
+ * deviceToken / code never linger in history or a `Referer`), verifies the
8
+ * echoed CSRF `state` against the value the initiator stashed in
9
+ * `sessionStorage`, persists the deviceToken, and — when a session resolved —
10
+ * exchanges the single-use `code` for a token bundle.
11
+ *
12
+ * Pure/injectable: all DOM access (hash, `history.replaceState`,
13
+ * `sessionStorage`) is passed in as callbacks so the logic is unit-testable
14
+ * under the jest `node` environment and reusable by `coldBootV2`.
15
+ *
16
+ * ESM-safe (no `require()`).
17
+ */
18
+ import {
19
+ deviceBootFragmentSchema,
20
+ resolveUserId,
21
+ safeParseContract,
22
+ type AuthTokenBundle,
23
+ type DeviceBootFragment,
24
+ type DeviceBootReason,
25
+ } from '@oxyhq/contracts';
26
+ import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
27
+
28
+ /** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
29
+ export const BOOT_FRAGMENT_PARAM = 'oxy_boot';
30
+
31
+ /**
32
+ * `sessionStorage` key under which the bootstrap-hop initiator stashes the
33
+ * 128-bit CSRF `state` before navigating, and which the return step reads back
34
+ * (single-use).
35
+ */
36
+ export const BOOT_STATE_SESSION_KEY = 'oxy.boot.state';
37
+
38
+ /**
39
+ * Decode a base64url string to UTF-8 text, or `null` on any malformed input.
40
+ * Handles both web (`atob` + `TextDecoder`) and Node (`Buffer`) without a
41
+ * `require()` — the ESM build stays clean.
42
+ */
43
+ function base64UrlDecode(input: string): string | null {
44
+ try {
45
+ let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
46
+ while (b64.length % 4 !== 0) {
47
+ b64 += '=';
48
+ }
49
+ if (typeof atob === 'function') {
50
+ const binary = atob(b64);
51
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
52
+ if (typeof TextDecoder !== 'undefined') {
53
+ return new TextDecoder().decode(bytes);
54
+ }
55
+ return binary;
56
+ }
57
+ if (typeof Buffer !== 'undefined') {
58
+ return Buffer.from(b64, 'base64').toString('utf-8');
59
+ }
60
+ return null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** True when a location hash carries the `oxy_boot` return fragment. */
67
+ export function hashHasBootFragment(hash: string): boolean {
68
+ return new RegExp(`(^|[#&])${BOOT_FRAGMENT_PARAM}=`).test(hash);
69
+ }
70
+
71
+ /**
72
+ * Extract + decode + validate the `oxy_boot` fragment from a location hash.
73
+ * Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
74
+ * is absent, not valid base64url, not JSON, or fails the contract schema.
75
+ */
76
+ export function parseDeviceBootFragment(hash: string): DeviceBootFragment | null {
77
+ const withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
78
+ const params = new URLSearchParams(withoutHash);
79
+ const raw = params.get(BOOT_FRAGMENT_PARAM);
80
+ if (!raw) {
81
+ return null;
82
+ }
83
+ const json = base64UrlDecode(raw);
84
+ if (!json) {
85
+ return null;
86
+ }
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(json);
90
+ } catch {
91
+ return null;
92
+ }
93
+ return safeParseContract(deviceBootFragmentSchema, parsed);
94
+ }
95
+
96
+ /** The winning session shape a cold-boot step reports. */
97
+ export interface DeviceBootSession {
98
+ sessionId: string;
99
+ userId: string;
100
+ accessToken: string;
101
+ }
102
+
103
+ /** Outcome of {@link consumeDeviceBootReturn}. */
104
+ export type DeviceBootReturnOutcome =
105
+ | { kind: 'none' }
106
+ | { kind: 'state-mismatch' }
107
+ | { kind: 'session'; session: DeviceBootSession }
108
+ | { kind: 'no-session'; reason: DeviceBootReason };
109
+
110
+ export interface ConsumeDeviceBootReturnDeps {
111
+ /** The current location hash (e.g. `window.location.hash`). */
112
+ hash: string;
113
+ /** Strip the fragment from the URL (e.g. `history.replaceState`). */
114
+ stripFragment: () => void;
115
+ /** Read the expected CSRF state (e.g. `sessionStorage.getItem(BOOT_STATE_SESSION_KEY)`). */
116
+ readExpectedState: () => string | null;
117
+ /** Clear the expected CSRF state (single-use). */
118
+ clearExpectedState: () => void;
119
+ store: AuthStateStore;
120
+ /** Exchange the single-use boot code for a token bundle (`oxy.exchangeBootCode`). */
121
+ exchangeBootCode: (code: string) => Promise<AuthTokenBundle>;
122
+ /** Plant the freshly-minted access token on the owner client (`oxy.setTokens`). */
123
+ plantAccessToken: (accessToken: string) => void;
124
+ }
125
+
126
+ /**
127
+ * Consume the device-boot return fragment.
128
+ *
129
+ * Order is load-bearing:
130
+ * 1. If no fragment is present, return `none` (no URL mutation).
131
+ * 2. STRIP the fragment from the URL immediately — before validation or any
132
+ * network — so the deviceToken/code never persist in history/referrer.
133
+ * 3. Verify the echoed `state` against the stashed (single-use) value; a
134
+ * mismatch returns `state-mismatch` without persisting or exchanging.
135
+ * 4. Persist the deviceToken (survives sign-out).
136
+ * 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
137
+ * persist the rotated session, plant the token, and return `session`.
138
+ * Otherwise return `no-session` with the reason.
139
+ */
140
+ export async function consumeDeviceBootReturn(
141
+ deps: ConsumeDeviceBootReturnDeps,
142
+ ): Promise<DeviceBootReturnOutcome> {
143
+ if (!hashHasBootFragment(deps.hash)) {
144
+ return { kind: 'none' };
145
+ }
146
+
147
+ // Strip FIRST — even a forged/malformed fragment must not linger in the URL.
148
+ deps.stripFragment();
149
+
150
+ const fragment = parseDeviceBootFragment(deps.hash);
151
+ if (!fragment) {
152
+ return { kind: 'none' };
153
+ }
154
+
155
+ const expected = deps.readExpectedState();
156
+ deps.clearExpectedState();
157
+ if (!expected || expected !== fragment.state) {
158
+ return { kind: 'state-mismatch' };
159
+ }
160
+
161
+ await deps.store.saveDeviceToken(fragment.deviceToken);
162
+
163
+ // `code` is guaranteed present on the `session` arm (the contract's
164
+ // discriminated union requires it; a session fragment without a code fails to
165
+ // parse and never reaches here).
166
+ if (fragment.reason === 'session') {
167
+ try {
168
+ const bundle = await deps.exchangeBootCode(fragment.code);
169
+ const userId = resolveUserId(bundle.user);
170
+ if (!userId) {
171
+ return { kind: 'no-session', reason: 'no_session' };
172
+ }
173
+ const next: PersistedAuthState = {
174
+ sessionId: bundle.sessionId,
175
+ refreshToken: bundle.refreshToken,
176
+ userId,
177
+ deviceToken: fragment.deviceToken,
178
+ accessToken: bundle.accessToken,
179
+ expiresAt: bundle.expiresAt,
180
+ };
181
+ await deps.store.save(next);
182
+ deps.plantAccessToken(bundle.accessToken);
183
+ return {
184
+ kind: 'session',
185
+ session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
186
+ };
187
+ } catch {
188
+ // The code burned/expired between hop and exchange — resolve signed-out
189
+ // rather than throwing (the once-ever hop already fired; do not retry).
190
+ return { kind: 'no-session', reason: 'no_session' };
191
+ }
192
+ }
193
+
194
+ return { kind: 'no-session', reason: fragment.reason };
195
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared device-token contract on web. Under the jest `node` environment
3
+ * `getPlatformOS()` resolves to `'web'`, so the shared-keychain device-token
4
+ * methods are no-ops (web persists its deviceToken in the per-origin
5
+ * AuthStateStore instead of a keychain). The native keychain plumbing is
6
+ * exercised via spies in the coldBootV2 suite.
7
+ */
8
+ import { KeyManager } from '../keyManager';
9
+
10
+ describe('KeyManager shared device token (web)', () => {
11
+ it('getSharedDeviceToken returns null on web', async () => {
12
+ expect(await KeyManager.getSharedDeviceToken()).toBeNull();
13
+ });
14
+
15
+ it('setSharedDeviceToken is a no-op that does not throw on web', async () => {
16
+ await expect(KeyManager.setSharedDeviceToken('dt-web')).resolves.toBeUndefined();
17
+ // Still null — nothing was persisted on web.
18
+ expect(await KeyManager.getSharedDeviceToken()).toBeNull();
19
+ });
20
+
21
+ it('clearSharedDeviceToken is a no-op that does not throw on web', async () => {
22
+ await expect(KeyManager.clearSharedDeviceToken()).resolves.toBeUndefined();
23
+ });
24
+ });