@oxyhq/core 8.1.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/coldBootV2.js +22 -332
  3. package/dist/cjs/crypto/keyManager.js +0 -95
  4. package/dist/cjs/index.js +6 -19
  5. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  7. package/dist/cjs/mixins/index.js +2 -3
  8. package/dist/cjs/session/SessionClient.js +29 -100
  9. package/dist/cjs/session/accountDialogController.js +0 -13
  10. package/dist/cjs/session/authStateStore.js +9 -89
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -71
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +23 -330
  16. package/dist/esm/crypto/keyManager.js +0 -95
  17. package/dist/esm/index.js +7 -11
  18. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  19. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  20. package/dist/esm/mixins/index.js +2 -3
  21. package/dist/esm/session/SessionClient.js +29 -100
  22. package/dist/esm/session/accountDialogController.js +0 -13
  23. package/dist/esm/session/authStateStore.js +8 -88
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -71
  26. package/dist/esm/utils/registrableApex.js +2 -6
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/HttpService.d.ts +3 -3
  29. package/dist/types/boot/coldBootV2.d.ts +28 -53
  30. package/dist/types/crypto/keyManager.d.ts +0 -21
  31. package/dist/types/index.d.ts +3 -5
  32. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  33. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  34. package/dist/types/session/SessionClient.d.ts +4 -38
  35. package/dist/types/session/accountDialogController.d.ts +1 -3
  36. package/dist/types/session/authStateStore.d.ts +32 -57
  37. package/dist/types/session/createSessionClient.d.ts +2 -9
  38. package/dist/types/session/refresh.d.ts +32 -28
  39. package/dist/types/utils/registrableApex.d.ts +2 -6
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +3 -3
  42. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  43. package/src/boot/coldBootV2.ts +35 -391
  44. package/src/crypto/keyManager.ts +0 -101
  45. package/src/index.ts +7 -30
  46. package/src/mixins/OxyServices.auth.ts +5 -9
  47. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  49. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  50. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  51. package/src/mixins/index.ts +2 -3
  52. package/src/session/SessionClient.ts +29 -120
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  54. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  55. package/src/session/__tests__/authStateStore.test.ts +29 -42
  56. package/src/session/__tests__/refresh.test.ts +66 -52
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +32 -126
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -92
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  63. package/dist/esm/boot/deviceBootReturn.js +0 -161
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  66. package/src/boot/deviceBootReturn.ts +0 -210
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -1,21 +1,22 @@
1
1
  /**
2
- * Unified token refresh — THE single refresh implementation for web + native.
2
+ * Unified token refresh — THE single access-token re-mint for web + native.
3
3
  *
4
- * ONE persisted-refresh-token rotation shared by every consumer (it replaced
5
- * the pre-device-first per-platform duplicates):
4
+ * The access token is short-lived; there is no refresh token. To keep a session
5
+ * alive past the access token's TTL the client re-mints via the zero-cookie
6
+ * device transport:
6
7
  *
7
- * - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
8
- * (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
9
- * (native only) re-mints via the shared-keychain identity when there is no
10
- * live refresh token. It is used BOTH reactively (wrapped as the
11
- * `AuthRefreshHandler` installed on `HttpService`) AND proactively (the
12
- * cold-boot `stored-tokens` step calls it directly).
8
+ * - `refreshPersistedSession` — arm 1 mints a fresh access token from the
9
+ * persisted `deviceId` + `deviceSecret` (`POST /session/device/token`),
10
+ * planting + persisting the rotated secret; arm 2 (native only) re-mints via
11
+ * the shared-keychain identity when there is no usable secret. It is used BOTH
12
+ * reactively (wrapped as the `AuthRefreshHandler` installed on `HttpService`)
13
+ * AND proactively (the scheduler below calls it).
13
14
  * - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
14
15
  * `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
15
16
  * dedup + cooldown (this module does NOT reimplement them).
16
17
  * - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
17
- * React type: refreshes ~60s before `exp`,
18
- * re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
18
+ * React type: re-mints ~60s before `exp`, re-arms on token change + web
19
+ * tab-focus, `.unref?.()`s its timer in Node.
19
20
  *
20
21
  * Framework-free; no module-level mutable state.
21
22
  */
@@ -23,13 +24,14 @@ import type { OxyServices } from '../OxyServices';
23
24
  import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
24
25
  import type { AuthStateStore, PersistedAuthState } from './authStateStore';
25
26
  import { isNative } from '../utils/platform';
27
+ import { extractErrorStatus } from '../utils/errorUtils';
26
28
  import { logger } from '../utils/loggerUtils';
27
29
 
28
30
  /**
29
31
  * Lead time (ms) before access-token expiry at which the proactive scheduler
30
- * refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
31
- * (60s) so the scheduled refresh and the request-time preflight refresh use
32
- * the same window — the scheduler just fires it during idle/background.
32
+ * re-mints. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
33
+ * (60s) so the scheduled re-mint and the request-time preflight use the same
34
+ * window — the scheduler just fires it during idle/background.
33
35
  */
34
36
  export const TOKEN_REFRESH_LEAD_MS = 60_000;
35
37
 
@@ -43,14 +45,14 @@ const MAX_TIMEOUT_DELAY_MS = 2_147_483_647;
43
45
  /**
44
46
  * Floor (ms) on ANY scheduled delay. An already-expired / in-lead-window token
45
47
  * computes a non-positive `exp − now − lead`; without this floor that becomes
46
- * `setTimeout(…, 0)`, and a FAILING refresh (offline / server error) would
48
+ * `setTimeout(…, 0)`, and a FAILING re-mint (offline / server error) would
47
49
  * re-arm at 0 in the finally block → a tight 100%-CPU busy loop. The floor
48
50
  * guarantees every re-arm yields the event loop.
49
51
  */
50
52
  const MIN_SCHEDULE_DELAY_MS = 1_000;
51
53
 
52
54
  /**
53
- * Backoff schedule (ms) applied when a scheduled refresh FAILS: first retry
55
+ * Backoff schedule (ms) applied when a scheduled re-mint FAILS: first retry
54
56
  * after {@link MIN_FAILURE_BACKOFF_MS}, doubling up to {@link MAX_FAILURE_BACKOFF_MS}.
55
57
  * Reset to 0 on any success or token change. This is what converts the former
56
58
  * zero-delay failure loop into a bounded, backing-off retry.
@@ -58,61 +60,32 @@ const MIN_SCHEDULE_DELAY_MS = 1_000;
58
60
  const MIN_FAILURE_BACKOFF_MS = 5_000;
59
61
  const MAX_FAILURE_BACKOFF_MS = 5 * 60_000;
60
62
 
61
- /**
62
- * Error codes (in addition to HTTP 401/403) that mean the stored refresh token
63
- * is permanently unusable — the family was revoked or a reuse was detected. On
64
- * any of these the persisted store is CLEARED (the session is truly over);
65
- * transient failures (network, 5xx) leave the store intact so a later attempt
66
- * can still succeed.
67
- */
68
- const REVOKED_REFRESH_CODES = new Set([
69
- 'invalid_grant',
70
- 'refresh_token_revoked',
71
- 'refresh_token_reuse',
72
- 'token_reuse',
73
- 'invalid_token',
74
- ]);
75
-
76
- interface HttpishError {
77
- status?: number;
78
- code?: string;
79
- }
80
-
81
- /** Does this error mean the refresh token is permanently dead (vs. transient)? */
82
- function isRevokedRefreshError(error: unknown): boolean {
83
- if (!error || typeof error !== 'object') {
84
- return false;
85
- }
86
- const e = error as HttpishError;
87
- if (e.status === 401 || e.status === 403) {
88
- return true;
89
- }
90
- return typeof e.code === 'string' && REVOKED_REFRESH_CODES.has(e.code);
91
- }
92
-
93
63
  export interface RefreshDeps {
94
64
  oxy: OxyServices;
95
65
  store: AuthStateStore;
96
66
  /**
97
- * Whether to fall back to the native shared-keychain re-mint (arm 2) when
98
- * there is no live refresh token / arm 1 is revoked. Defaults to `isNative()`
99
- * — web has no shared keychain. Exposed for tests.
67
+ * Whether to fall back to the native shared-keychain re-mint (arm 2) when the
68
+ * persisted secret is absent / rejected. Defaults to `isNative()` — web has no
69
+ * shared keychain. Exposed for tests.
100
70
  */
101
71
  allowSharedKeyFallback?: boolean;
102
72
  }
103
73
 
104
74
  /**
105
- * Rotate the persisted session and return the fresh access token, or `null`
75
+ * Re-mint the persisted session and return the fresh access token, or `null`
106
76
  * when no arm could produce one.
107
77
  *
108
- * Arm 1 (`POST /auth/refresh-token`): if the store holds a refresh token, rotate
109
- * it — on success plant + persist the rotated pair; on a REVOKED error clear the
110
- * store; on a transient error leave the store and return `null`.
78
+ * Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
79
+ * `deviceSecret`, mint — on success plant + persist the rotated secret. A 401
80
+ * means the secret is diverged (`invalid_device_secret`) or the device has no
81
+ * live session (`no_active_session`): drop the secret so the mint lane stops (or
82
+ * clear the store on web, where there is no fallback). A transient error leaves
83
+ * the store and returns `null`.
111
84
  *
112
- * Arm 2 (native shared-keychain): when there is no refresh token or arm 1 was
113
- * revoked, re-mint via `signInWithSharedIdentity` (which plants tokens). The
114
- * shared keychain — not the per-origin store — is the durable native credential,
115
- * so this arm does not write the store.
85
+ * Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
86
+ * re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
87
+ * keychain — not the per-origin store — is the durable native credential, so this
88
+ * arm does not write the store.
116
89
  */
117
90
  export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
118
91
  const { oxy, store } = deps;
@@ -120,39 +93,34 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
120
93
 
121
94
  const persisted = await store.load();
122
95
 
123
- if (persisted?.refreshToken) {
96
+ if (persisted?.deviceId && persisted?.deviceSecret) {
124
97
  try {
125
- const rotated = await oxy.refreshWithToken(persisted.refreshToken);
126
- oxy.setTokens(rotated.accessToken);
98
+ const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
99
+ oxy.setTokens(mint.accessToken);
100
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
127
101
  const next: PersistedAuthState = {
128
- sessionId: rotated.sessionId,
129
- refreshToken: rotated.refreshToken,
130
- userId: persisted.userId,
131
- accessToken: rotated.accessToken,
132
- expiresAt: rotated.expiresAt,
102
+ ...persisted,
103
+ deviceId: mint.state.deviceId,
104
+ deviceSecret: mint.nextDeviceSecret,
105
+ accessToken: mint.accessToken,
106
+ expiresAt: mint.expiresAt,
107
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
133
108
  };
134
- if (persisted.deviceToken) {
135
- next.deviceToken = persisted.deviceToken;
136
- }
137
- // The refresh response carries no device credentials — carry the persisted
138
- // deviceId/deviceSecret (phase 2c) forward so a rotation never drops the
139
- // zero-cookie mint lane (mirrors the deviceToken preservation above).
140
- if (persisted.deviceId) {
141
- next.deviceId = persisted.deviceId;
142
- }
143
- if (persisted.deviceSecret) {
144
- next.deviceSecret = persisted.deviceSecret;
145
- }
146
109
  await store.save(next);
147
- return rotated.accessToken;
110
+ return mint.accessToken;
148
111
  } catch (error) {
149
- if (isRevokedRefreshError(error)) {
150
- await store.clear();
151
- // Fall through to the native shared-key arm below on a shared-key
152
- // device the refresh family being revoked does not end the session.
112
+ if (extractErrorStatus(error) === 401) {
113
+ // Secret diverged / no active session. On a shared-key device drop only
114
+ // the secret so arm 2 below can recover; otherwise the session is over.
115
+ if (allowSharedKeyFallback) {
116
+ await store.save({ ...persisted, deviceSecret: undefined });
117
+ } else {
118
+ await store.clear();
119
+ }
120
+ // Fall through to the native shared-key arm.
153
121
  } else {
154
122
  logger.debug(
155
- 'Persisted refresh failed (transient) — keeping store',
123
+ 'Persisted deviceSecret mint failed (transient) — keeping store',
156
124
  { component: 'refresh', method: 'refreshPersistedSession' },
157
125
  error,
158
126
  );
@@ -169,7 +137,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
169
137
  }
170
138
  } catch (error) {
171
139
  logger.debug(
172
- 'Shared-key refresh fallback failed',
140
+ 'Shared-key re-mint fallback failed',
173
141
  { component: 'refresh', method: 'refreshPersistedSession' },
174
142
  error,
175
143
  );
@@ -209,7 +177,7 @@ export interface TokenRefreshSchedulerHandle {
209
177
  }
210
178
 
211
179
  /**
212
- * Start the proactive refresh scheduler against `oxy`.
180
+ * Start the proactive re-mint scheduler against `oxy`.
213
181
  *
214
182
  * Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
215
183
  * current access token's `exp`, calling
@@ -217,18 +185,18 @@ export interface TokenRefreshSchedulerHandle {
217
185
  * handler; deduped + cooldown-guarded). After every attempt it reschedules
218
186
  * from the possibly-rotated token. It also reschedules whenever the token
219
187
  * changes (a sign-out that clears the token cancels the timer) and, on web
220
- * tab-focus, refreshes immediately if already inside the lead window (a
188
+ * tab-focus, re-mints immediately if already inside the lead window (a
221
189
  * long-hidden tab throttles timers, so the token can be expired on return).
222
190
  *
223
191
  * No-ops cleanly when there is no token or an opaque/no-`exp` token — the
224
- * reactive 401 path stays the only refresh trigger in that case. The timer is
192
+ * reactive 401 path stays the only re-mint trigger in that case. The timer is
225
193
  * `.unref?.()`-ed so it never keeps a Node/Jest event loop alive.
226
194
  */
227
195
  export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedulerHandle {
228
196
  let disposed = false;
229
197
  let timer: ReturnType<typeof setTimeout> | null = null;
230
198
  // 0 = no active backoff; grows on consecutive failures, resets on success /
231
- // token change. Keeps a failing refresh from re-arming at zero delay.
199
+ // token change. Keeps a failing re-mint from re-arming at zero delay.
232
200
  let failureBackoffMs = 0;
233
201
 
234
202
  const clearTimer = (): void => {
@@ -243,11 +211,11 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
243
211
  clearTimer();
244
212
  const clamped = Math.min(Math.max(delayMs, MIN_SCHEDULE_DELAY_MS), MAX_TIMEOUT_DELAY_MS);
245
213
  timer = setTimeout(runRefresh, clamped);
246
- // Never keep a Node/Jest event loop alive for a background refresh timer.
214
+ // Never keep a Node/Jest event loop alive for a background re-mint timer.
247
215
  timer.unref?.();
248
216
  };
249
217
 
250
- /** Schedule the next refresh from the current token's expiry (the healthy path). */
218
+ /** Schedule the next re-mint from the current token's expiry (the healthy path). */
251
219
  const scheduleFromExpiry = (): void => {
252
220
  clearTimer();
253
221
  if (disposed || !oxy.getAccessToken()) {
@@ -4,12 +4,8 @@
4
4
  * The client FAPI auto-detection helper was removed in the device-first cutover
5
5
  * (which is why this file is now named for what it actually is, not the old
6
6
  * `fapiAutoDetect`). What survives is the pure registrable-domain kernel, still
7
- * used server-side by the api's device-first same-apex trust checks
8
- * (`deviceAuth.ts`'s `POST /auth/device/web-session`, via `sameSite.ts`'s
9
- * `isSameSiteTrustedRequest`) and the `@oxyhq/core/server` CORS/re-export layer.
10
- *
11
- * `registrableApex` is NOT legacy — the device-first same-apex check is a live
12
- * consumer, so this kernel stays regardless of the SSO/FedCM removal.
7
+ * used by the `@oxyhq/core/server` CORS layer (the `*.oxy.so` same-apex trust
8
+ * check) and its re-export surface.
13
9
  */
14
10
 
15
11
  import { getDomain } from 'tldts';
@@ -1,167 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BOOT_STATE_SESSION_KEY = exports.BOOT_FRAGMENT_PARAM = void 0;
4
- exports.hashHasBootFragment = hashHasBootFragment;
5
- exports.parseDeviceBootFragment = parseDeviceBootFragment;
6
- exports.consumeDeviceBootReturn = consumeDeviceBootReturn;
7
- /**
8
- * Device-boot return-fragment consumption (web cross-apex hop).
9
- *
10
- * After the top-level `GET /auth/device/bootstrap` hop, the API 303s back to the
11
- * RP with a `#oxy_boot=<base64url(JSON)>` fragment. This module parses and
12
- * consumes it: it strips the fragment from the URL FIRST (so the opaque
13
- * deviceToken / code never linger in history or a `Referer`), verifies the
14
- * echoed CSRF `state` against the value the initiator stashed in
15
- * `sessionStorage`, persists the deviceToken, and — when a session resolved —
16
- * exchanges the single-use `code` for a token bundle.
17
- *
18
- * Pure/injectable: all DOM access (hash, `history.replaceState`,
19
- * `sessionStorage`) is passed in as callbacks so the logic is unit-testable
20
- * under the jest `node` environment and reusable by `coldBootV2`.
21
- *
22
- * ESM-safe (no `require()`).
23
- */
24
- const contracts_1 = require("@oxyhq/contracts");
25
- /** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
26
- exports.BOOT_FRAGMENT_PARAM = 'oxy_boot';
27
- /**
28
- * `sessionStorage` key under which the bootstrap-hop initiator stashes the
29
- * 128-bit CSRF `state` before navigating, and which the return step reads back
30
- * (single-use).
31
- */
32
- exports.BOOT_STATE_SESSION_KEY = 'oxy.boot.state';
33
- /**
34
- * Decode a base64url string to UTF-8 text, or `null` on any malformed input.
35
- * Handles both web (`atob` + `TextDecoder`) and Node (`Buffer`) without a
36
- * `require()` — the ESM build stays clean.
37
- */
38
- function base64UrlDecode(input) {
39
- try {
40
- let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
41
- while (b64.length % 4 !== 0) {
42
- b64 += '=';
43
- }
44
- if (typeof atob === 'function') {
45
- const binary = atob(b64);
46
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
47
- if (typeof TextDecoder !== 'undefined') {
48
- return new TextDecoder().decode(bytes);
49
- }
50
- return binary;
51
- }
52
- if (typeof Buffer !== 'undefined') {
53
- return Buffer.from(b64, 'base64').toString('utf-8');
54
- }
55
- return null;
56
- }
57
- catch {
58
- return null;
59
- }
60
- }
61
- /** True when a location hash carries the `oxy_boot` return fragment. */
62
- function hashHasBootFragment(hash) {
63
- return new RegExp(`(^|[#&])${exports.BOOT_FRAGMENT_PARAM}=`).test(hash);
64
- }
65
- /**
66
- * Extract + decode + validate the `oxy_boot` fragment from a location hash.
67
- * Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
68
- * is absent, not valid base64url, not JSON, or fails the contract schema.
69
- */
70
- function parseDeviceBootFragment(hash) {
71
- const withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
72
- const params = new URLSearchParams(withoutHash);
73
- const raw = params.get(exports.BOOT_FRAGMENT_PARAM);
74
- if (!raw) {
75
- return null;
76
- }
77
- const json = base64UrlDecode(raw);
78
- if (!json) {
79
- return null;
80
- }
81
- let parsed;
82
- try {
83
- parsed = JSON.parse(json);
84
- }
85
- catch {
86
- return null;
87
- }
88
- return (0, contracts_1.safeParseContract)(contracts_1.deviceBootFragmentSchema, parsed);
89
- }
90
- /**
91
- * Consume the device-boot return fragment.
92
- *
93
- * Order is load-bearing:
94
- * 1. If no fragment is present, return `none` (no URL mutation).
95
- * 2. STRIP the fragment from the URL immediately — before validation or any
96
- * network — so the deviceToken/code never persist in history/referrer.
97
- * 3. Verify the echoed `state` against the stashed (single-use) value; a
98
- * mismatch returns `state-mismatch` without persisting or exchanging.
99
- * 4. Persist the deviceToken (survives sign-out).
100
- * 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
101
- * persist the rotated session, plant the token, and return `session`.
102
- * Otherwise return `no-session` with the reason.
103
- */
104
- async function consumeDeviceBootReturn(deps) {
105
- if (!hashHasBootFragment(deps.hash)) {
106
- return { kind: 'none' };
107
- }
108
- // Strip FIRST — even a forged/malformed fragment must not linger in the URL.
109
- deps.stripFragment();
110
- const fragment = parseDeviceBootFragment(deps.hash);
111
- if (!fragment) {
112
- return { kind: 'none' };
113
- }
114
- const expected = deps.readExpectedState();
115
- deps.clearExpectedState();
116
- if (!expected || expected !== fragment.state) {
117
- return { kind: 'state-mismatch' };
118
- }
119
- await deps.store.saveDeviceToken(fragment.deviceToken);
120
- // `code` is guaranteed present on the `session` arm (the contract's
121
- // discriminated union requires it; a session fragment without a code fails to
122
- // parse and never reaches here).
123
- if (fragment.reason === 'session') {
124
- try {
125
- const bundle = await deps.exchangeBootCode(fragment.code);
126
- const userId = (0, contracts_1.resolveUserId)(bundle.user);
127
- if (!userId) {
128
- return { kind: 'no-session', reason: 'no_session' };
129
- }
130
- const next = {
131
- sessionId: bundle.sessionId,
132
- refreshToken: bundle.refreshToken,
133
- userId,
134
- deviceToken: fragment.deviceToken,
135
- accessToken: bundle.accessToken,
136
- expiresAt: bundle.expiresAt,
137
- };
138
- // Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
139
- // but NOT a deviceId. Persist the secret, and carry any prior deviceId
140
- // forward (from a deviceId-bearing login lane) so the pair stays usable by
141
- // the zero-cookie mint — an overwrite here must not orphan the mint lane.
142
- const prior = await deps.store.load();
143
- if (prior?.deviceId) {
144
- next.deviceId = prior.deviceId;
145
- }
146
- // Prefer the bundle's secret (the server just rotated onto it); keep the
147
- // prior one when the bundle omits it so a cookie-lane boot can never orphan
148
- // a still-valid secret captured by an earlier login lane.
149
- const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
150
- if (carriedSecret) {
151
- next.deviceSecret = carriedSecret;
152
- }
153
- await deps.store.save(next);
154
- deps.plantAccessToken(bundle.accessToken);
155
- return {
156
- kind: 'session',
157
- session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
158
- };
159
- }
160
- catch {
161
- // The code burned/expired between hop and exchange — resolve signed-out
162
- // rather than throwing (the once-ever hop already fired; do not retry).
163
- return { kind: 'no-session', reason: 'no_session' };
164
- }
165
- }
166
- return { kind: 'no-session', reason: fragment.reason };
167
- }
@@ -1,161 +0,0 @@
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
- // Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
133
- // but NOT a deviceId. Persist the secret, and carry any prior deviceId
134
- // forward (from a deviceId-bearing login lane) so the pair stays usable by
135
- // the zero-cookie mint — an overwrite here must not orphan the mint lane.
136
- const prior = await deps.store.load();
137
- if (prior?.deviceId) {
138
- next.deviceId = prior.deviceId;
139
- }
140
- // Prefer the bundle's secret (the server just rotated onto it); keep the
141
- // prior one when the bundle omits it so a cookie-lane boot can never orphan
142
- // a still-valid secret captured by an earlier login lane.
143
- const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
144
- if (carriedSecret) {
145
- next.deviceSecret = carriedSecret;
146
- }
147
- await deps.store.save(next);
148
- deps.plantAccessToken(bundle.accessToken);
149
- return {
150
- kind: 'session',
151
- session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
152
- };
153
- }
154
- catch {
155
- // The code burned/expired between hop and exchange — resolve signed-out
156
- // rather than throwing (the once-ever hop already fired; do not retry).
157
- return { kind: 'no-session', reason: 'no_session' };
158
- }
159
- }
160
- return { kind: 'no-session', reason: fragment.reason };
161
- }