@oxyhq/core 5.5.0 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -3
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +47 -44
- package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +13 -17
- package/dist/cjs/session/SessionClient.js +40 -1
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +6 -15
- package/dist/cjs/utils/fapiAutoDetect.js +16 -64
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/ssoBounce.js +15 -362
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -3
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +28 -18
- package/dist/esm/mixins/OxyServices.accounts.js +6 -13
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +13 -17
- package/dist/esm/session/SessionClient.js +40 -1
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +6 -14
- package/dist/esm/utils/fapiAutoDetect.js +16 -63
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/ssoBounce.js +14 -345
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -0
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +15 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +6 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/session/SessionClient.d.ts +25 -0
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/utils/accountUtils.d.ts +0 -14
- package/dist/types/utils/authWebUrl.d.ts +6 -12
- package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -280
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +19 -3
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +74 -65
- package/src/mixins/OxyServices.accounts.ts +6 -13
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.authorizedApps.ts +75 -0
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +16 -22
- package/src/models/interfaces.ts +0 -79
- package/src/session/SessionClient.ts +52 -1
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/refresh.ts +334 -0
- package/src/utils/__tests__/authWebUrl.test.ts +5 -29
- package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +0 -62
- package/src/utils/authWebUrl.ts +6 -15
- package/src/utils/fapiAutoDetect.ts +16 -60
- package/src/utils/platform.ts +21 -0
- package/src/utils/ssoBounce.ts +14 -393
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -275
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -271
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -156
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -276
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
package/dist/esm/HttpService.js
CHANGED
|
@@ -270,8 +270,11 @@ export class HttpService {
|
|
|
270
270
|
try {
|
|
271
271
|
// Build URL with params
|
|
272
272
|
const fullUrl = this.buildURL(url, params);
|
|
273
|
-
// Get auth token (with auto-refresh)
|
|
274
|
-
|
|
273
|
+
// Get auth token (with auto-refresh). `skipAuth` requests (the
|
|
274
|
+
// body-authenticated refresh endpoint) send NO bearer and skip the
|
|
275
|
+
// near-expiry preflight — see RequestOptions.skipAuth for the deadlock
|
|
276
|
+
// this avoids.
|
|
277
|
+
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
275
278
|
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated
|
|
276
279
|
// SDK clients are not vulnerable to ambient-cookie CSRF, and linked app
|
|
277
280
|
// APIs should not need to implement a duplicate `/csrf-token` route.
|
|
@@ -358,7 +361,7 @@ export class HttpService {
|
|
|
358
361
|
// On 401, delegate refresh to AuthManager and retry once before
|
|
359
362
|
// giving up. HttpService deliberately does not know any session
|
|
360
363
|
// routes; the AuthManager is the single session authority.
|
|
361
|
-
if (response.status === 401 && !config._isAuthRetry) {
|
|
364
|
+
if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
|
|
362
365
|
const refreshed = await this.refreshAccessToken('response-401');
|
|
363
366
|
if (refreshed) {
|
|
364
367
|
// `deduplicate: false` is REQUIRED on the retry (mirrors the 403
|
|
@@ -7,10 +7,6 @@ import { jwtDecode } from 'jwt-decode';
|
|
|
7
7
|
import { handleHttpError } from './utils/errorUtils.js';
|
|
8
8
|
import { HttpService } from './HttpService.js';
|
|
9
9
|
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
|
|
10
|
-
import { resolveCentralAuthUrl } from './utils/authWebUrl.js';
|
|
11
|
-
import { isWeb } from './utils/platform.js';
|
|
12
|
-
import { registrableApex } from './utils/fapiAutoDetect.js';
|
|
13
|
-
import { logger } from './utils/loggerUtils.js';
|
|
14
10
|
/**
|
|
15
11
|
* Base class for OxyServices with core infrastructure
|
|
16
12
|
*/
|
|
@@ -22,21 +18,14 @@ export class OxyServicesBase {
|
|
|
22
18
|
if (!config || typeof config !== 'object') {
|
|
23
19
|
throw new Error('OxyConfig is required');
|
|
24
20
|
}
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// per-apex resolution, but it is NOT the constructor default anymore.
|
|
32
|
-
// An explicit `authWebUrl` always wins (we only fill it when absent).
|
|
33
|
-
const resolvedConfig = config.authWebUrl
|
|
34
|
-
? config
|
|
35
|
-
: { ...config, authWebUrl: resolveCentralAuthUrl(config.authWebUrl) };
|
|
36
|
-
this.config = resolvedConfig;
|
|
37
|
-
this.cloudURL = resolvedConfig.cloudURL || 'https://cloud.oxy.so';
|
|
21
|
+
// `authWebUrl` is a plain optional config value now (used only for building
|
|
22
|
+
// third-party "Sign in with Oxy" OAuth links). The SDK no longer derives or
|
|
23
|
+
// defaults an IdP host — the device-first cold boot restores sessions from
|
|
24
|
+
// the persisted refresh store, not an `auth.<apex>` bounce.
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.cloudURL = config.cloudURL || 'https://cloud.oxy.so';
|
|
38
27
|
// Initialize unified HTTP service (handles auth, caching, deduplication, queuing, retry)
|
|
39
|
-
this.httpService = new HttpService(
|
|
28
|
+
this.httpService = new HttpService(config);
|
|
40
29
|
}
|
|
41
30
|
// Test-only utility to reset tokens on this instance between jest tests
|
|
42
31
|
// Note: tokens are now per-instance, so create new instances in tests for isolation
|
|
@@ -65,22 +54,6 @@ export class OxyServicesBase {
|
|
|
65
54
|
getBaseURL() {
|
|
66
55
|
return this.httpService.getBaseURL();
|
|
67
56
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Get the base URL the SDK's first-party session/refresh calls should target.
|
|
70
|
-
*
|
|
71
|
-
* Returns the configured `sessionBaseUrl` when provided, otherwise falls back
|
|
72
|
-
* to the API `baseURL` (`getBaseURL()`). Per the 2026 session architecture
|
|
73
|
-
* (docs/SESSION-ARCHITECTURE.md), non-`oxy.so` apps point this at their own
|
|
74
|
-
* same-site backend (e.g. `https://api.mention.earth`) whose session bridge
|
|
75
|
-
* forwards the user's refresh credential to `api.oxy.so`; `*.oxy.so` apps
|
|
76
|
-
* leave it unset so it resolves to `https://api.oxy.so` and nothing changes.
|
|
77
|
-
*
|
|
78
|
-
* This is additive: it only exposes configuration for `@oxyhq/services` to
|
|
79
|
-
* consume in a later phase. No refresh/auth logic in core reads it yet.
|
|
80
|
-
*/
|
|
81
|
-
getSessionBaseUrl() {
|
|
82
|
-
return this.config.sessionBaseUrl ?? this.getBaseURL();
|
|
83
|
-
}
|
|
84
57
|
/**
|
|
85
58
|
* Get the HTTP service instance
|
|
86
59
|
* Useful for advanced use cases where direct access to the HTTP service is needed
|
|
@@ -253,74 +226,6 @@ export class OxyServicesBase {
|
|
|
253
226
|
getAccessToken() {
|
|
254
227
|
return this.httpService.getAccessToken();
|
|
255
228
|
}
|
|
256
|
-
/**
|
|
257
|
-
* Register the CURRENTLY-ACTIVE session in the device's first-party
|
|
258
|
-
* multi-account refresh-cookie set by calling `POST /auth/session`.
|
|
259
|
-
*
|
|
260
|
-
* This is the single, shared primitive every web primary-session commit and the
|
|
261
|
-
* account switch use to plant their `oxy_rt_<authuser>` slot. It MUST be a
|
|
262
|
-
* dedicated call to `/auth/session` rather than relying on whichever endpoint
|
|
263
|
-
* established the session: that endpoint is frequently OUTSIDE the cookie's
|
|
264
|
-
* `Path=/auth` scope (`/accounts/:id/switch`) or is a cross-origin/credential-
|
|
265
|
-
* less restore (`/sso/exchange`, the IdP `/auth/silent` postMessage) that cannot
|
|
266
|
-
* set an `api.oxy.so` cookie at all. `/auth/session` runs where the device's
|
|
267
|
-
* existing slots ARE visible, so the server resolves this user's slot (reusing an
|
|
268
|
-
* existing one or allocating a new one) without clobbering a sibling account,
|
|
269
|
-
* mints a fresh access token bound to the same session, and returns the resolved
|
|
270
|
-
* `authuser`.
|
|
271
|
-
*
|
|
272
|
-
* Behaviour:
|
|
273
|
-
* - Requires a planted bearer (the caller must have already installed the
|
|
274
|
-
* session's access token); `/auth/session` derives the session from it.
|
|
275
|
-
* - On success re-plants the rotated access token (so the active token matches
|
|
276
|
-
* the freshly-rotated cookie) and returns the device `authuser` slot.
|
|
277
|
-
* - WEB-ONLY: on native there are no first-party refresh cookies → returns
|
|
278
|
-
* `null`.
|
|
279
|
-
* - FIRST-PARTY-ONLY: the cookie is host-only on the API host with
|
|
280
|
-
* `SameSite=Lax`, so it only sticks when the page is SAME-SITE (same
|
|
281
|
-
* registrable apex) as the API. On a cross-apex RP (`mention.earth` calling
|
|
282
|
-
* `api.oxy.so`) the browser rejects the `Set-Cookie` as a third-party cookie,
|
|
283
|
-
* so a returned slot would be a phantom never enumerated by `refresh-all`.
|
|
284
|
-
* Those RPs durably restore via the per-apex `/auth/silent` iframe + `/sso`
|
|
285
|
-
* bounce, NOT this device set → returns `null` without calling the API.
|
|
286
|
-
* - BEST-EFFORT: a failure (e.g. transient network) never throws — the session
|
|
287
|
-
* stays active in-memory; only its reload durability via the device set is at
|
|
288
|
-
* risk. The caller treats `null` as "not registered in the device set".
|
|
289
|
-
*
|
|
290
|
-
* @returns The resolved device `authuser` slot, or `null` on native / cross-apex
|
|
291
|
-
* / failure.
|
|
292
|
-
*/
|
|
293
|
-
async establishDeviceRefreshSlot() {
|
|
294
|
-
if (!isWeb()) {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
if (typeof window !== 'undefined' && window.location?.hostname) {
|
|
298
|
-
const pageApex = registrableApex(window.location.hostname);
|
|
299
|
-
let apiApex = null;
|
|
300
|
-
try {
|
|
301
|
-
apiApex = registrableApex(new URL(this.getBaseURL()).hostname);
|
|
302
|
-
}
|
|
303
|
-
catch {
|
|
304
|
-
apiApex = null;
|
|
305
|
-
}
|
|
306
|
-
if (!pageApex || !apiApex || pageApex !== apiApex) {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
try {
|
|
311
|
-
const established = await this.makeRequest('POST', '/auth/session', undefined, { cache: false });
|
|
312
|
-
// `/auth/session` mints a fresh access token off the same session; re-plant
|
|
313
|
-
// it so the active token matches the rotated cookie.
|
|
314
|
-
if (established?.accessToken) {
|
|
315
|
-
this.setTokens(established.accessToken);
|
|
316
|
-
}
|
|
317
|
-
return typeof established?.authuser === 'number' ? established.authuser : null;
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
logger.warn('[OxyServices] Failed to establish device refresh cookie via POST /auth/session; the session is active in-session but may not survive a reload as part of the device account set', { component: 'OxyServices', method: 'establishDeviceRefreshSlot' }, error);
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
229
|
/**
|
|
325
230
|
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
326
231
|
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|
|
@@ -0,0 +1,344 @@
|
|
|
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 } from '../utils/coldBoot.js';
|
|
30
|
+
import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform.js';
|
|
31
|
+
import { KeyManager } from '../crypto/keyManager.js';
|
|
32
|
+
import { logger } from '../utils/loggerUtils.js';
|
|
33
|
+
import { refreshPersistedSession } from '../session/refresh.js';
|
|
34
|
+
import { consumeDeviceBootReturn, hashHasBootFragment, BOOT_STATE_SESSION_KEY, } from './deviceBootReturn.js';
|
|
35
|
+
/**
|
|
36
|
+
* localStorage flag marking that the cross-apex bootstrap navigation has fired
|
|
37
|
+
* once for this origin. Persistent (not session) so the visible redirect
|
|
38
|
+
* happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
|
|
39
|
+
* bounced again; they sign in explicitly.
|
|
40
|
+
*/
|
|
41
|
+
export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
|
|
42
|
+
/**
|
|
43
|
+
* Do not warm-plant a stored access token with less than this remaining — it
|
|
44
|
+
* would need an immediate refresh anyway, so fall through to the rotate path.
|
|
45
|
+
* Matches the refresh lead window.
|
|
46
|
+
*/
|
|
47
|
+
const WARM_MIN_REMAINING_MS = 60000;
|
|
48
|
+
/**
|
|
49
|
+
* Generate a 128-bit hex CSRF state token. Prefers Web Crypto
|
|
50
|
+
* (`crypto.getRandomValues`, present in browsers and modern Node); falls back
|
|
51
|
+
* to a time+`Math.random` mix only when no CSPRNG is reachable (this token
|
|
52
|
+
* gates a single-use CSRF echo, not a long-lived secret).
|
|
53
|
+
*/
|
|
54
|
+
function generateStateToken() {
|
|
55
|
+
const cryptoObj = globalThis.crypto;
|
|
56
|
+
if (cryptoObj?.getRandomValues) {
|
|
57
|
+
const bytes = new Uint8Array(16);
|
|
58
|
+
cryptoObj.getRandomValues(bytes);
|
|
59
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
60
|
+
}
|
|
61
|
+
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
|
|
65
|
+
* missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
|
|
66
|
+
* or no-op, so the boot degrades to signed-out rather than crashing.
|
|
67
|
+
*/
|
|
68
|
+
export function createBrowserColdBootDom() {
|
|
69
|
+
const safe = (fn, fallback) => {
|
|
70
|
+
try {
|
|
71
|
+
return fn();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
|
|
79
|
+
stripFragment: () => safe(() => {
|
|
80
|
+
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
|
81
|
+
const { pathname, search } = window.location;
|
|
82
|
+
window.history.replaceState(null, '', `${pathname}${search}`);
|
|
83
|
+
}
|
|
84
|
+
}, undefined),
|
|
85
|
+
getSessionItem: (key) => safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
|
|
86
|
+
setSessionItem: (key, value) => safe(() => {
|
|
87
|
+
if (typeof sessionStorage !== 'undefined')
|
|
88
|
+
sessionStorage.setItem(key, value);
|
|
89
|
+
}, undefined),
|
|
90
|
+
removeSessionItem: (key) => safe(() => {
|
|
91
|
+
if (typeof sessionStorage !== 'undefined')
|
|
92
|
+
sessionStorage.removeItem(key);
|
|
93
|
+
}, undefined),
|
|
94
|
+
getLocalItem: (key) => safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
|
|
95
|
+
setLocalItem: (key, value) => safe(() => {
|
|
96
|
+
if (typeof localStorage !== 'undefined')
|
|
97
|
+
localStorage.setItem(key, value);
|
|
98
|
+
}, undefined),
|
|
99
|
+
getLocationHostname: () => safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
|
|
100
|
+
getReturnToHref: () => safe(() => {
|
|
101
|
+
if (typeof window === 'undefined')
|
|
102
|
+
return null;
|
|
103
|
+
const { origin, pathname, search } = window.location;
|
|
104
|
+
return `${origin}${pathname}${search}`;
|
|
105
|
+
}, null),
|
|
106
|
+
navigate: (url) => safe(() => {
|
|
107
|
+
if (typeof window !== 'undefined')
|
|
108
|
+
window.location.assign(url);
|
|
109
|
+
}, undefined),
|
|
110
|
+
randomState: generateStateToken,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Registrable domain = the last two labels of a host. A deliberately SMALL
|
|
115
|
+
* local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
|
|
116
|
+
* Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
|
|
117
|
+
* `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
|
|
118
|
+
* such as `co.uk` — never arises here because the compared API host is always
|
|
119
|
+
* `api.oxy.so`, so a page under a different registrable domain still classifies
|
|
120
|
+
* as cross-apex.
|
|
121
|
+
*/
|
|
122
|
+
function registrableDomain(host) {
|
|
123
|
+
const labels = host.toLowerCase().split('.').filter(Boolean);
|
|
124
|
+
return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
|
|
128
|
+
* hosts have NO registrable domain — the last-two-labels heuristic would
|
|
129
|
+
* mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
|
|
130
|
+
* `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
|
|
131
|
+
* classified same-apex as a different LAN API and skip the cross-apex hop.
|
|
132
|
+
*/
|
|
133
|
+
function isIpOrSingleLabel(host) {
|
|
134
|
+
if (host.includes(':')) {
|
|
135
|
+
return true; // IPv6 literal
|
|
136
|
+
}
|
|
137
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
138
|
+
return true; // IPv4 literal
|
|
139
|
+
}
|
|
140
|
+
return !host.includes('.'); // single-label (e.g. `localhost`)
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* True when both hosts are same-site / same-apex. For a normal multi-label host
|
|
144
|
+
* pair, that means sharing a registrable domain. For an IP literal or a
|
|
145
|
+
* single-label host (no registrable domain), same-apex requires the two hosts
|
|
146
|
+
* to be EXACTLY equal — never grouped by a spurious trailing-label match.
|
|
147
|
+
*/
|
|
148
|
+
export function isSameApex(pageHost, apiHost) {
|
|
149
|
+
const a = pageHost.toLowerCase();
|
|
150
|
+
const b = apiHost.toLowerCase();
|
|
151
|
+
if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
|
|
152
|
+
return a === b;
|
|
153
|
+
}
|
|
154
|
+
const ra = registrableDomain(a);
|
|
155
|
+
const rb = registrableDomain(b);
|
|
156
|
+
return ra !== '' && ra === rb;
|
|
157
|
+
}
|
|
158
|
+
/** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
|
|
159
|
+
function sessionFromPersisted(state, accessToken) {
|
|
160
|
+
return { sessionId: state.sessionId, userId: state.userId, accessToken };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
164
|
+
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
165
|
+
* or `onSignedOut` (no session — unless the boot is navigating away for the
|
|
166
|
+
* cross-apex hop, in which case neither fires).
|
|
167
|
+
*/
|
|
168
|
+
export async function runSessionColdBoot(opts) {
|
|
169
|
+
const { oxy, store } = opts;
|
|
170
|
+
const dom = opts.dom ?? createBrowserColdBootDom();
|
|
171
|
+
const isWeb = opts.platform?.isWeb ?? detectWeb();
|
|
172
|
+
const isNative = opts.platform?.isNative ?? detectNative();
|
|
173
|
+
// Signed-out reason + navigating flag are boot-local (not module-level), so
|
|
174
|
+
// they cannot leak across boots or break under bundler re-evaluation.
|
|
175
|
+
let signedOutReason = 'no_session';
|
|
176
|
+
let navigating = false;
|
|
177
|
+
const steps = [];
|
|
178
|
+
// 1. bootstrap-return (web) — consume a #oxy_boot fragment.
|
|
179
|
+
steps.push({
|
|
180
|
+
id: 'bootstrap-return',
|
|
181
|
+
enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
|
|
182
|
+
run: async () => {
|
|
183
|
+
const outcome = await consumeDeviceBootReturn({
|
|
184
|
+
hash: dom.getHash(),
|
|
185
|
+
stripFragment: () => dom.stripFragment(),
|
|
186
|
+
readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
|
|
187
|
+
clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
|
|
188
|
+
store,
|
|
189
|
+
exchangeBootCode: (code) => oxy.exchangeBootCode(code),
|
|
190
|
+
plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
|
|
191
|
+
});
|
|
192
|
+
if (outcome.kind === 'session') {
|
|
193
|
+
return { kind: 'session', session: outcome.session };
|
|
194
|
+
}
|
|
195
|
+
if (outcome.kind === 'state-mismatch') {
|
|
196
|
+
signedOutReason = 'state-mismatch';
|
|
197
|
+
}
|
|
198
|
+
else if (outcome.kind === 'no-session') {
|
|
199
|
+
// `DeviceBootReason` also carries 'session'; a no-session outcome with
|
|
200
|
+
// that reason (a `session` reason but no code) is still signed out.
|
|
201
|
+
signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
|
|
202
|
+
}
|
|
203
|
+
// Fall through: a stored refresh family (a prior same-origin session) may
|
|
204
|
+
// still recover below. The once-ever flag blocks a second hop.
|
|
205
|
+
return { kind: 'skip' };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
// 2. stored-tokens — warm-plant or rotate the persisted refresh family.
|
|
209
|
+
steps.push({
|
|
210
|
+
id: 'stored-tokens',
|
|
211
|
+
run: async () => {
|
|
212
|
+
const persisted = await store.load();
|
|
213
|
+
if (!persisted) {
|
|
214
|
+
return { kind: 'skip' };
|
|
215
|
+
}
|
|
216
|
+
// Warm path: a still-valid access token → plant immediately (no network).
|
|
217
|
+
if (persisted.accessToken &&
|
|
218
|
+
persisted.expiresAt &&
|
|
219
|
+
Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS) {
|
|
220
|
+
oxy.setTokens(persisted.accessToken);
|
|
221
|
+
return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
|
|
222
|
+
}
|
|
223
|
+
// Rotate path: refreshPersistedSession plants + persists, and clears the
|
|
224
|
+
// store on a family-revoked error.
|
|
225
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
|
|
226
|
+
if (!token) {
|
|
227
|
+
return { kind: 'skip' };
|
|
228
|
+
}
|
|
229
|
+
const after = await store.load();
|
|
230
|
+
const base = after ?? persisted;
|
|
231
|
+
return { kind: 'session', session: sessionFromPersisted(base, token) };
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
235
|
+
steps.push({
|
|
236
|
+
id: 'shared-key-signin',
|
|
237
|
+
enabled: () => isNative,
|
|
238
|
+
run: async () => {
|
|
239
|
+
const session = await oxy.signInWithSharedIdentity();
|
|
240
|
+
if (!session?.accessToken) {
|
|
241
|
+
return { kind: 'skip' };
|
|
242
|
+
}
|
|
243
|
+
// First shared-key sign-in on this device: issue + persist + mirror a
|
|
244
|
+
// shared deviceToken so every native Oxy app joins one DeviceSession.
|
|
245
|
+
// Best-effort — never fail the sign-in over device-token issuance.
|
|
246
|
+
try {
|
|
247
|
+
const existing = await KeyManager.getSharedDeviceToken();
|
|
248
|
+
if (!existing) {
|
|
249
|
+
const deviceToken = await oxy.issueNativeDeviceToken();
|
|
250
|
+
await store.saveDeviceToken(deviceToken);
|
|
251
|
+
await KeyManager.setSharedDeviceToken(deviceToken);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
logger.debug('Native deviceToken issuance skipped', { component: 'coldBootV2', method: 'shared-key-signin' }, error);
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
kind: 'session',
|
|
259
|
+
session: {
|
|
260
|
+
sessionId: session.sessionId,
|
|
261
|
+
userId: session.user.id,
|
|
262
|
+
accessToken: session.accessToken,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
// 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
|
|
268
|
+
steps.push({
|
|
269
|
+
id: 'bootstrap-hop',
|
|
270
|
+
enabled: () => isWeb,
|
|
271
|
+
run: async () => {
|
|
272
|
+
const pageHost = dom.getLocationHostname();
|
|
273
|
+
let apiHost = null;
|
|
274
|
+
try {
|
|
275
|
+
apiHost = new URL(oxy.getBaseURL()).hostname;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
apiHost = null;
|
|
279
|
+
}
|
|
280
|
+
if (!pageHost || !apiHost) {
|
|
281
|
+
return { kind: 'skip' };
|
|
282
|
+
}
|
|
283
|
+
// Same-apex: inline credentialed fetch, no redirect, runs every boot.
|
|
284
|
+
if (isSameApex(pageHost, apiHost)) {
|
|
285
|
+
const result = await oxy.requestWebSession();
|
|
286
|
+
// The rotated deviceToken is on BOTH arms (it is device-level, not
|
|
287
|
+
// session-level) — persist it before branching on the session.
|
|
288
|
+
await store.saveDeviceToken(result.deviceToken);
|
|
289
|
+
if (result.reason === 'session') {
|
|
290
|
+
const bundle = result.session;
|
|
291
|
+
const userId = resolveUserId(bundle.user);
|
|
292
|
+
if (!userId) {
|
|
293
|
+
return { kind: 'skip' };
|
|
294
|
+
}
|
|
295
|
+
const next = {
|
|
296
|
+
sessionId: bundle.sessionId,
|
|
297
|
+
refreshToken: bundle.refreshToken,
|
|
298
|
+
userId,
|
|
299
|
+
deviceToken: result.deviceToken,
|
|
300
|
+
accessToken: bundle.accessToken,
|
|
301
|
+
expiresAt: bundle.expiresAt,
|
|
302
|
+
};
|
|
303
|
+
await store.save(next);
|
|
304
|
+
oxy.setTokens(bundle.accessToken);
|
|
305
|
+
return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
|
|
306
|
+
}
|
|
307
|
+
// Known device, signed out.
|
|
308
|
+
signedOutReason = result.reason;
|
|
309
|
+
return { kind: 'skip' };
|
|
310
|
+
}
|
|
311
|
+
// Cross-apex: ONE visible top-level navigation, once-ever per origin.
|
|
312
|
+
if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
|
|
313
|
+
return { kind: 'skip' };
|
|
314
|
+
}
|
|
315
|
+
const returnTo = opts.returnTo ?? dom.getReturnToHref();
|
|
316
|
+
if (!returnTo) {
|
|
317
|
+
return { kind: 'skip' };
|
|
318
|
+
}
|
|
319
|
+
const state = dom.randomState();
|
|
320
|
+
dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
|
|
321
|
+
dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
|
|
322
|
+
navigating = true;
|
|
323
|
+
dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
|
|
324
|
+
return { kind: 'skip' };
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
const outcome = await runColdBoot({
|
|
328
|
+
steps,
|
|
329
|
+
onStepError: (id, error) => {
|
|
330
|
+
signedOutReason = 'error';
|
|
331
|
+
opts.onStepError?.(id, error);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
if (outcome.kind === 'session') {
|
|
335
|
+
await opts.onSession?.({ ...outcome.session, via: outcome.via });
|
|
336
|
+
return outcome;
|
|
337
|
+
}
|
|
338
|
+
// Navigating away for the cross-apex hop: the page is unloading, so do not
|
|
339
|
+
// flash a signed-out state.
|
|
340
|
+
if (!navigating) {
|
|
341
|
+
await opts.onSignedOut?.(signedOutReason);
|
|
342
|
+
}
|
|
343
|
+
return outcome;
|
|
344
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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 { deviceBootFragmentSchema, resolveUserId, safeParseContract, } from '@oxyhq/contracts';
|
|
19
|
+
/** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
|
|
20
|
+
export const BOOT_FRAGMENT_PARAM = 'oxy_boot';
|
|
21
|
+
/**
|
|
22
|
+
* `sessionStorage` key under which the bootstrap-hop initiator stashes the
|
|
23
|
+
* 128-bit CSRF `state` before navigating, and which the return step reads back
|
|
24
|
+
* (single-use).
|
|
25
|
+
*/
|
|
26
|
+
export const BOOT_STATE_SESSION_KEY = 'oxy.boot.state';
|
|
27
|
+
/**
|
|
28
|
+
* Decode a base64url string to UTF-8 text, or `null` on any malformed input.
|
|
29
|
+
* Handles both web (`atob` + `TextDecoder`) and Node (`Buffer`) without a
|
|
30
|
+
* `require()` — the ESM build stays clean.
|
|
31
|
+
*/
|
|
32
|
+
function base64UrlDecode(input) {
|
|
33
|
+
try {
|
|
34
|
+
let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
35
|
+
while (b64.length % 4 !== 0) {
|
|
36
|
+
b64 += '=';
|
|
37
|
+
}
|
|
38
|
+
if (typeof atob === 'function') {
|
|
39
|
+
const binary = atob(b64);
|
|
40
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
41
|
+
if (typeof TextDecoder !== 'undefined') {
|
|
42
|
+
return new TextDecoder().decode(bytes);
|
|
43
|
+
}
|
|
44
|
+
return binary;
|
|
45
|
+
}
|
|
46
|
+
if (typeof Buffer !== 'undefined') {
|
|
47
|
+
return Buffer.from(b64, 'base64').toString('utf-8');
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** True when a location hash carries the `oxy_boot` return fragment. */
|
|
56
|
+
export function hashHasBootFragment(hash) {
|
|
57
|
+
return new RegExp(`(^|[#&])${BOOT_FRAGMENT_PARAM}=`).test(hash);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Extract + decode + validate the `oxy_boot` fragment from a location hash.
|
|
61
|
+
* Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
|
|
62
|
+
* is absent, not valid base64url, not JSON, or fails the contract schema.
|
|
63
|
+
*/
|
|
64
|
+
export function parseDeviceBootFragment(hash) {
|
|
65
|
+
const withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
66
|
+
const params = new URLSearchParams(withoutHash);
|
|
67
|
+
const raw = params.get(BOOT_FRAGMENT_PARAM);
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const json = base64UrlDecode(raw);
|
|
72
|
+
if (!json) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(json);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return safeParseContract(deviceBootFragmentSchema, parsed);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Consume the device-boot return fragment.
|
|
86
|
+
*
|
|
87
|
+
* Order is load-bearing:
|
|
88
|
+
* 1. If no fragment is present, return `none` (no URL mutation).
|
|
89
|
+
* 2. STRIP the fragment from the URL immediately — before validation or any
|
|
90
|
+
* network — so the deviceToken/code never persist in history/referrer.
|
|
91
|
+
* 3. Verify the echoed `state` against the stashed (single-use) value; a
|
|
92
|
+
* mismatch returns `state-mismatch` without persisting or exchanging.
|
|
93
|
+
* 4. Persist the deviceToken (survives sign-out).
|
|
94
|
+
* 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
|
|
95
|
+
* persist the rotated session, plant the token, and return `session`.
|
|
96
|
+
* Otherwise return `no-session` with the reason.
|
|
97
|
+
*/
|
|
98
|
+
export async function consumeDeviceBootReturn(deps) {
|
|
99
|
+
if (!hashHasBootFragment(deps.hash)) {
|
|
100
|
+
return { kind: 'none' };
|
|
101
|
+
}
|
|
102
|
+
// Strip FIRST — even a forged/malformed fragment must not linger in the URL.
|
|
103
|
+
deps.stripFragment();
|
|
104
|
+
const fragment = parseDeviceBootFragment(deps.hash);
|
|
105
|
+
if (!fragment) {
|
|
106
|
+
return { kind: 'none' };
|
|
107
|
+
}
|
|
108
|
+
const expected = deps.readExpectedState();
|
|
109
|
+
deps.clearExpectedState();
|
|
110
|
+
if (!expected || expected !== fragment.state) {
|
|
111
|
+
return { kind: 'state-mismatch' };
|
|
112
|
+
}
|
|
113
|
+
await deps.store.saveDeviceToken(fragment.deviceToken);
|
|
114
|
+
// `code` is guaranteed present on the `session` arm (the contract's
|
|
115
|
+
// discriminated union requires it; a session fragment without a code fails to
|
|
116
|
+
// parse and never reaches here).
|
|
117
|
+
if (fragment.reason === 'session') {
|
|
118
|
+
try {
|
|
119
|
+
const bundle = await deps.exchangeBootCode(fragment.code);
|
|
120
|
+
const userId = resolveUserId(bundle.user);
|
|
121
|
+
if (!userId) {
|
|
122
|
+
return { kind: 'no-session', reason: 'no_session' };
|
|
123
|
+
}
|
|
124
|
+
const next = {
|
|
125
|
+
sessionId: bundle.sessionId,
|
|
126
|
+
refreshToken: bundle.refreshToken,
|
|
127
|
+
userId,
|
|
128
|
+
deviceToken: fragment.deviceToken,
|
|
129
|
+
accessToken: bundle.accessToken,
|
|
130
|
+
expiresAt: bundle.expiresAt,
|
|
131
|
+
};
|
|
132
|
+
await deps.store.save(next);
|
|
133
|
+
deps.plantAccessToken(bundle.accessToken);
|
|
134
|
+
return {
|
|
135
|
+
kind: 'session',
|
|
136
|
+
session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// The code burned/expired between hop and exchange — resolve signed-out
|
|
141
|
+
// rather than throwing (the once-ever hop already fired; do not retry).
|
|
142
|
+
return { kind: 'no-session', reason: 'no_session' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { kind: 'no-session', reason: fragment.reason };
|
|
146
|
+
}
|