@oxyhq/core 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/coldBootV2.js +66 -281
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +24 -92
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +14 -88
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -62
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +67 -279
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +25 -93
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +13 -87
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -62
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +22 -46
- package/dist/types/session/SessionClient.d.ts +4 -38
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +38 -43
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +2 -2
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +237 -236
- package/src/boot/coldBootV2.ts +92 -333
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +30 -115
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/session/SessionClient.ts +29 -120
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +47 -33
- package/src/session/__tests__/refresh.test.ts +72 -44
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +43 -111
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -83
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -152
- package/dist/esm/boot/deviceBootReturn.js +0 -146
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
- package/src/boot/deviceBootReturn.ts +0 -195
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
package/src/session/refresh.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified token refresh — THE single
|
|
2
|
+
* Unified token refresh — THE single access-token re-mint for web + native.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
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
|
|
8
|
-
* (`POST /
|
|
9
|
-
* (native only) re-mints via
|
|
10
|
-
*
|
|
11
|
-
* `AuthRefreshHandler` installed on `HttpService`)
|
|
12
|
-
*
|
|
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:
|
|
18
|
-
*
|
|
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
|
-
*
|
|
31
|
-
* (60s) so the scheduled
|
|
32
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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
|
-
*
|
|
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 /
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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,30 +93,34 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
120
93
|
|
|
121
94
|
const persisted = await store.load();
|
|
122
95
|
|
|
123
|
-
if (persisted?.
|
|
96
|
+
if (persisted?.deviceId && persisted?.deviceSecret) {
|
|
124
97
|
try {
|
|
125
|
-
const
|
|
126
|
-
oxy.setTokens(
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
accessToken:
|
|
132
|
-
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
109
|
await store.save(next);
|
|
138
|
-
return
|
|
110
|
+
return mint.accessToken;
|
|
139
111
|
} catch (error) {
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
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.
|
|
144
121
|
} else {
|
|
145
122
|
logger.debug(
|
|
146
|
-
'Persisted
|
|
123
|
+
'Persisted deviceSecret mint failed (transient) — keeping store',
|
|
147
124
|
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
148
125
|
error,
|
|
149
126
|
);
|
|
@@ -160,7 +137,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
160
137
|
}
|
|
161
138
|
} catch (error) {
|
|
162
139
|
logger.debug(
|
|
163
|
-
'Shared-key
|
|
140
|
+
'Shared-key re-mint fallback failed',
|
|
164
141
|
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
165
142
|
error,
|
|
166
143
|
);
|
|
@@ -200,7 +177,7 @@ export interface TokenRefreshSchedulerHandle {
|
|
|
200
177
|
}
|
|
201
178
|
|
|
202
179
|
/**
|
|
203
|
-
* Start the proactive
|
|
180
|
+
* Start the proactive re-mint scheduler against `oxy`.
|
|
204
181
|
*
|
|
205
182
|
* Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
|
|
206
183
|
* current access token's `exp`, calling
|
|
@@ -208,18 +185,18 @@ export interface TokenRefreshSchedulerHandle {
|
|
|
208
185
|
* handler; deduped + cooldown-guarded). After every attempt it reschedules
|
|
209
186
|
* from the possibly-rotated token. It also reschedules whenever the token
|
|
210
187
|
* changes (a sign-out that clears the token cancels the timer) and, on web
|
|
211
|
-
* tab-focus,
|
|
188
|
+
* tab-focus, re-mints immediately if already inside the lead window (a
|
|
212
189
|
* long-hidden tab throttles timers, so the token can be expired on return).
|
|
213
190
|
*
|
|
214
191
|
* No-ops cleanly when there is no token or an opaque/no-`exp` token — the
|
|
215
|
-
* reactive 401 path stays the only
|
|
192
|
+
* reactive 401 path stays the only re-mint trigger in that case. The timer is
|
|
216
193
|
* `.unref?.()`-ed so it never keeps a Node/Jest event loop alive.
|
|
217
194
|
*/
|
|
218
195
|
export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedulerHandle {
|
|
219
196
|
let disposed = false;
|
|
220
197
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
221
198
|
// 0 = no active backoff; grows on consecutive failures, resets on success /
|
|
222
|
-
// token change. Keeps a failing
|
|
199
|
+
// token change. Keeps a failing re-mint from re-arming at zero delay.
|
|
223
200
|
let failureBackoffMs = 0;
|
|
224
201
|
|
|
225
202
|
const clearTimer = (): void => {
|
|
@@ -234,11 +211,11 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
|
|
|
234
211
|
clearTimer();
|
|
235
212
|
const clamped = Math.min(Math.max(delayMs, MIN_SCHEDULE_DELAY_MS), MAX_TIMEOUT_DELAY_MS);
|
|
236
213
|
timer = setTimeout(runRefresh, clamped);
|
|
237
|
-
// Never keep a Node/Jest event loop alive for a background
|
|
214
|
+
// Never keep a Node/Jest event loop alive for a background re-mint timer.
|
|
238
215
|
timer.unref?.();
|
|
239
216
|
};
|
|
240
217
|
|
|
241
|
-
/** Schedule the next
|
|
218
|
+
/** Schedule the next re-mint from the current token's expiry (the healthy path). */
|
|
242
219
|
const scheduleFromExpiry = (): void => {
|
|
243
220
|
clearTimer();
|
|
244
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
|
|
8
|
-
*
|
|
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,152 +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
|
-
await deps.store.save(next);
|
|
139
|
-
deps.plantAccessToken(bundle.accessToken);
|
|
140
|
-
return {
|
|
141
|
-
kind: 'session',
|
|
142
|
-
session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
// The code burned/expired between hop and exchange — resolve signed-out
|
|
147
|
-
// rather than throwing (the once-ever hop already fired; do not retry).
|
|
148
|
-
return { kind: 'no-session', reason: 'no_session' };
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return { kind: 'no-session', reason: fragment.reason };
|
|
152
|
-
}
|
|
@@ -1,146 +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
|
-
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
|
-
}
|
|
@@ -1,83 +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 { type AuthTokenBundle, type DeviceBootFragment, type DeviceBootReason } from '@oxyhq/contracts';
|
|
19
|
-
import type { AuthStateStore } from '../session/authStateStore';
|
|
20
|
-
/** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
|
|
21
|
-
export declare const BOOT_FRAGMENT_PARAM = "oxy_boot";
|
|
22
|
-
/**
|
|
23
|
-
* `sessionStorage` key under which the bootstrap-hop initiator stashes the
|
|
24
|
-
* 128-bit CSRF `state` before navigating, and which the return step reads back
|
|
25
|
-
* (single-use).
|
|
26
|
-
*/
|
|
27
|
-
export declare const BOOT_STATE_SESSION_KEY = "oxy.boot.state";
|
|
28
|
-
/** True when a location hash carries the `oxy_boot` return fragment. */
|
|
29
|
-
export declare function hashHasBootFragment(hash: string): boolean;
|
|
30
|
-
/**
|
|
31
|
-
* Extract + decode + validate the `oxy_boot` fragment from a location hash.
|
|
32
|
-
* Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
|
|
33
|
-
* is absent, not valid base64url, not JSON, or fails the contract schema.
|
|
34
|
-
*/
|
|
35
|
-
export declare function parseDeviceBootFragment(hash: string): DeviceBootFragment | null;
|
|
36
|
-
/** The winning session shape a cold-boot step reports. */
|
|
37
|
-
export interface DeviceBootSession {
|
|
38
|
-
sessionId: string;
|
|
39
|
-
userId: string;
|
|
40
|
-
accessToken: string;
|
|
41
|
-
}
|
|
42
|
-
/** Outcome of {@link consumeDeviceBootReturn}. */
|
|
43
|
-
export type DeviceBootReturnOutcome = {
|
|
44
|
-
kind: 'none';
|
|
45
|
-
} | {
|
|
46
|
-
kind: 'state-mismatch';
|
|
47
|
-
} | {
|
|
48
|
-
kind: 'session';
|
|
49
|
-
session: DeviceBootSession;
|
|
50
|
-
} | {
|
|
51
|
-
kind: 'no-session';
|
|
52
|
-
reason: DeviceBootReason;
|
|
53
|
-
};
|
|
54
|
-
export interface ConsumeDeviceBootReturnDeps {
|
|
55
|
-
/** The current location hash (e.g. `window.location.hash`). */
|
|
56
|
-
hash: string;
|
|
57
|
-
/** Strip the fragment from the URL (e.g. `history.replaceState`). */
|
|
58
|
-
stripFragment: () => void;
|
|
59
|
-
/** Read the expected CSRF state (e.g. `sessionStorage.getItem(BOOT_STATE_SESSION_KEY)`). */
|
|
60
|
-
readExpectedState: () => string | null;
|
|
61
|
-
/** Clear the expected CSRF state (single-use). */
|
|
62
|
-
clearExpectedState: () => void;
|
|
63
|
-
store: AuthStateStore;
|
|
64
|
-
/** Exchange the single-use boot code for a token bundle (`oxy.exchangeBootCode`). */
|
|
65
|
-
exchangeBootCode: (code: string) => Promise<AuthTokenBundle>;
|
|
66
|
-
/** Plant the freshly-minted access token on the owner client (`oxy.setTokens`). */
|
|
67
|
-
plantAccessToken: (accessToken: string) => void;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Consume the device-boot return fragment.
|
|
71
|
-
*
|
|
72
|
-
* Order is load-bearing:
|
|
73
|
-
* 1. If no fragment is present, return `none` (no URL mutation).
|
|
74
|
-
* 2. STRIP the fragment from the URL immediately — before validation or any
|
|
75
|
-
* network — so the deviceToken/code never persist in history/referrer.
|
|
76
|
-
* 3. Verify the echoed `state` against the stashed (single-use) value; a
|
|
77
|
-
* mismatch returns `state-mismatch` without persisting or exchanging.
|
|
78
|
-
* 4. Persist the deviceToken (survives sign-out).
|
|
79
|
-
* 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
|
|
80
|
-
* persist the rotated session, plant the token, and return `session`.
|
|
81
|
-
* Otherwise return `no-session` with the reason.
|
|
82
|
-
*/
|
|
83
|
-
export declare function consumeDeviceBootReturn(deps: ConsumeDeviceBootReturnDeps): Promise<DeviceBootReturnOutcome>;
|