@oxyhq/core 5.5.0 → 7.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 +7 -4
- 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 +54 -45
- package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +8 -17
- package/dist/cjs/server/index.js +2 -2
- 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/shared/utils/debugUtils.js +3 -3
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +10 -39
- package/dist/cjs/utils/coldBoot.js +10 -8
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/registrableApex.js +49 -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 +7 -4
- 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 +34 -17
- package/dist/esm/mixins/OxyServices.accounts.js +13 -26
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +8 -17
- package/dist/esm/server/index.js +1 -1
- 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/shared/utils/debugUtils.js +3 -3
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +9 -37
- package/dist/esm/utils/coldBoot.js +10 -8
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/registrableApex.js +46 -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 +14 -1
- 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 +14 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
- 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.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 +5 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/models/session.d.ts +4 -5
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/session/SessionClient.d.ts +26 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/shared/utils/debugUtils.d.ts +3 -3
- package/dist/types/utils/accountUtils.d.ts +2 -17
- package/dist/types/utils/authWebUrl.d.ts +9 -35
- package/dist/types/utils/coldBoot.d.ts +17 -14
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/registrableApex.d.ts +31 -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 +20 -4
- 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 +75 -65
- package/src/mixins/OxyServices.accounts.ts +27 -39
- package/src/mixins/OxyServices.auth.ts +78 -253
- 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__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +10 -23
- package/src/models/interfaces.ts +0 -79
- package/src/models/session.ts +4 -5
- package/src/server/index.ts +1 -1
- package/src/session/SessionClient.ts +53 -2
- 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/shared/utils/debugUtils.ts +3 -3
- package/src/utils/__tests__/authWebUrl.test.ts +5 -40
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +2 -65
- package/src/utils/authWebUrl.ts +9 -39
- package/src/utils/coldBoot.ts +17 -14
- package/src/utils/platform.ts +21 -0
- package/src/utils/registrableApex.ts +45 -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/fapiAutoDetect.js +0 -99
- 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/fapiAutoDetect.js +0 -95
- 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.redirect.d.ts +0 -92
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
- 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__/fapiAutoDetect.test.ts +0 -183
- 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/fapiAutoDetect.ts +0 -91
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
|
@@ -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
|
+
});
|