@oxyhq/core 9.2.2 → 9.2.4
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 +27 -0
- package/dist/cjs/boot/sessionColdBoot.js +83 -53
- package/dist/cjs/crypto/keyManager.js +45 -12
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/session/SessionClient.js +9 -4
- package/dist/cjs/session/authStateStore.js +8 -0
- package/dist/cjs/session/refresh.js +110 -37
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +27 -0
- package/dist/esm/boot/sessionColdBoot.js +83 -53
- package/dist/esm/crypto/keyManager.js +46 -13
- package/dist/esm/index.js +1 -1
- package/dist/esm/session/SessionClient.js +9 -4
- package/dist/esm/session/authStateStore.js +8 -0
- package/dist/esm/session/refresh.js +109 -37
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/boot/sessionColdBoot.d.ts +7 -3
- package/dist/types/index.d.ts +3 -3
- package/dist/types/session/SessionClient.d.ts +26 -4
- package/dist/types/session/authStateStore.d.ts +15 -1
- package/dist/types/session/refresh.d.ts +67 -31
- package/package.json +2 -2
- package/src/HttpService.ts +31 -0
- package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
- package/src/boot/sessionColdBoot.ts +93 -74
- package/src/crypto/keyManager.ts +42 -15
- package/src/index.ts +3 -2
- package/src/session/SessionClient.ts +35 -7
- package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
- package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
- package/src/session/__tests__/authStateStore.test.ts +64 -6
- package/src/session/__tests__/refresh.test.ts +141 -2
- package/src/session/authStateStore.ts +23 -1
- package/src/session/refresh.ts +146 -40
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - Request queuing
|
|
14
14
|
*/
|
|
15
15
|
import type { OxyConfig } from './models/interfaces';
|
|
16
|
+
import type { DeviceSecretMintOutcome } from './session/refresh';
|
|
16
17
|
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
17
18
|
export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
|
|
18
19
|
export type AccessTokenProvider = () => string | null;
|
|
@@ -77,6 +78,7 @@ export declare class HttpService {
|
|
|
77
78
|
private tokenRefreshCooldownUntil;
|
|
78
79
|
private authRefreshHandler;
|
|
79
80
|
private accessTokenProvider;
|
|
81
|
+
private deviceSecretMintInFlight;
|
|
80
82
|
/**
|
|
81
83
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
82
84
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -208,6 +210,25 @@ export declare class HttpService {
|
|
|
208
210
|
*/
|
|
209
211
|
private getAuthHeader;
|
|
210
212
|
refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
|
|
213
|
+
/**
|
|
214
|
+
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
215
|
+
* (`POST /session/device/token`).
|
|
216
|
+
*
|
|
217
|
+
* The server rotates the presented `deviceSecret` on every successful mint, so
|
|
218
|
+
* two concurrent mints would double-rotate and the durable store could end up
|
|
219
|
+
* holding a superseded secret → a later cold-boot mint 401s → the user is
|
|
220
|
+
* signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
|
|
221
|
+
* the device-first cold boot, the socket token transport, the tab-focus
|
|
222
|
+
* reconcile) funnels its `refreshDeviceSecretArm` call through here, so
|
|
223
|
+
* concurrent callers await the SAME in-flight mint and all receive its result —
|
|
224
|
+
* exactly one server rotation.
|
|
225
|
+
*
|
|
226
|
+
* Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
|
|
227
|
+
* handler incl. the native shared-key arm + the failure cooldown): this inner
|
|
228
|
+
* guard serializes the rotation itself across BOTH the handler and the
|
|
229
|
+
* handler-independent cold boot, which never runs through `refreshAccessToken`.
|
|
230
|
+
*/
|
|
231
|
+
runSingleFlightDeviceSecretMint(mint: () => Promise<DeviceSecretMintOutcome>): Promise<DeviceSecretMintOutcome>;
|
|
211
232
|
/**
|
|
212
233
|
* Unwrap standardized API response format
|
|
213
234
|
*/
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* the app renders with a "Sign in with Oxy" button.
|
|
8
8
|
*
|
|
9
9
|
* Ordered steps (first to yield a session wins):
|
|
10
|
-
* 1. `
|
|
10
|
+
* 1. `warm-token-plant` (web + native) — the fastest path: when the persisted
|
|
11
|
+
* store still holds a warm access token that is valid for more than the
|
|
12
|
+
* refresh lead window, plant it and yield the session with NO network
|
|
13
|
+
* round-trip. The background scheduler rotates it shortly after.
|
|
14
|
+
* 2. `device-secret-mint` (web + native) — the zero-cookie transport: when the
|
|
11
15
|
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
12
16
|
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
13
17
|
* navigation) and rotate the secret in-use.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
19
|
+
* 4. Signed out.
|
|
16
20
|
*
|
|
17
21
|
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
18
22
|
*/
|
package/dist/types/index.d.ts
CHANGED
|
@@ -90,7 +90,7 @@ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin
|
|
|
90
90
|
export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
|
|
91
91
|
export type { SyncHubAfterSignInOptions } from './session/hubSync';
|
|
92
92
|
export { SessionClient } from './session/SessionClient';
|
|
93
|
-
export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential } from './session/SessionClient';
|
|
93
|
+
export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential, SessionStateOrigin } from './session/SessionClient';
|
|
94
94
|
export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
95
95
|
export { createSessionClientHost } from './session/sessionClientHost';
|
|
96
96
|
export { createSessionClient } from './session/createSessionClient';
|
|
@@ -101,8 +101,8 @@ export { AccountDialogController, createAccountDialogController, } from './sessi
|
|
|
101
101
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
|
|
102
102
|
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
|
|
103
103
|
export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
|
|
104
|
-
export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
|
|
105
|
-
export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
|
|
104
|
+
export { refreshPersistedSession, refreshDeviceSecretArm, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
|
|
105
|
+
export type { RefreshDeps, TokenRefreshSchedulerHandle, DeviceSecretMintOutcome } from './session/refresh';
|
|
106
106
|
export { runSessionColdBoot } from './boot/sessionColdBoot';
|
|
107
107
|
export type { RunSessionColdBootOptions, SignedOutReason, DeviceBootSession, } from './boot/sessionColdBoot';
|
|
108
108
|
export { packageInfo } from './constants/version';
|
|
@@ -4,6 +4,21 @@ export interface TokenTransport {
|
|
|
4
4
|
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
|
|
5
5
|
ensureActiveToken(state: DeviceSessionState): Promise<void>;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Where an applied device state came from, so consumers can decide how
|
|
9
|
+
* AUTHORITATIVE a zero-account ("signed out") verdict is:
|
|
10
|
+
* - `request` — the response to a direct REST call this client made
|
|
11
|
+
* (`bootstrap` / `switch` / `signOut` / `add`). A stable, server-authoritative
|
|
12
|
+
* verdict: an empty state here reflects a real sign-out or revocation, so the
|
|
13
|
+
* durable device credential MAY be erased.
|
|
14
|
+
* - `push` — an out-of-band Socket.IO `session_state` broadcast. Potentially
|
|
15
|
+
* transient (a reconnect race / another device's mutation), so an empty state
|
|
16
|
+
* here must NOT erase THIS origin's durable device credential — only clear the
|
|
17
|
+
* local UI session. A dead credential re-mints to `no_active_session` and
|
|
18
|
+
* resolves signed-out cleanly on the next boot; a wrongly-erased one cannot be
|
|
19
|
+
* recovered without a fresh sign-in.
|
|
20
|
+
*/
|
|
21
|
+
export type SessionStateOrigin = 'request' | 'push';
|
|
7
22
|
export interface DeviceCredential {
|
|
8
23
|
deviceId: string;
|
|
9
24
|
deviceSecret: string;
|
|
@@ -25,13 +40,20 @@ export interface SessionClientOptions {
|
|
|
25
40
|
/**
|
|
26
41
|
* Invoked when an APPLIED state has zero accounts — i.e. a device
|
|
27
42
|
* signout-all removed the last account from this device set. Providers use
|
|
28
|
-
* this to clear
|
|
29
|
-
*
|
|
43
|
+
* this to clear local session state and, for a `request`-origin verdict, the
|
|
44
|
+
* persisted {@link AuthStateStore} so a reload does not try to restore a
|
|
45
|
+
* session that no longer exists on the device.
|
|
46
|
+
*
|
|
47
|
+
* The {@link SessionStateOrigin} is passed so the consumer can gate the
|
|
48
|
+
* DESTRUCTIVE credential wipe: a `push`-origin empty state (a socket broadcast,
|
|
49
|
+
* possibly a transient reconnect artifact) must NOT erase the durable device
|
|
50
|
+
* credential — only a `request`-origin verdict (a direct REST sign-out /
|
|
51
|
+
* revocation) is authoritative enough for that.
|
|
30
52
|
*
|
|
31
53
|
* Only fires when a state is actually applied (revision advanced), never on
|
|
32
54
|
* a stale/rejected push. Exceptions thrown by the callback are isolated.
|
|
33
55
|
*/
|
|
34
|
-
onUnauthenticated?: () => void;
|
|
56
|
+
onUnauthenticated?: (origin: SessionStateOrigin) => void;
|
|
35
57
|
/**
|
|
36
58
|
* Statically-injected `socket.io-client` factory (its `io` export).
|
|
37
59
|
* `@oxyhq/services` lists `socket.io-client` as a real dependency and
|
|
@@ -72,7 +94,7 @@ export declare class SessionClient {
|
|
|
72
94
|
private bindServerEvent;
|
|
73
95
|
protected notify(): void;
|
|
74
96
|
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
75
|
-
protected applyState(raw: unknown): boolean;
|
|
97
|
+
protected applyState(raw: unknown, origin?: SessionStateOrigin): boolean;
|
|
76
98
|
/**
|
|
77
99
|
* Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
|
|
78
100
|
* Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
|
|
@@ -64,7 +64,21 @@ export interface PersistedAuthState {
|
|
|
64
64
|
*/
|
|
65
65
|
export interface AuthStateStore {
|
|
66
66
|
load(): Promise<PersistedAuthState | null>;
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Persist the credential blob and report whether it durably landed.
|
|
69
|
+
*
|
|
70
|
+
* Resolves `true` when the state is retained consistent with this store's
|
|
71
|
+
* durability guarantee — a durable backing whose read-back matched, or a
|
|
72
|
+
* degraded/in-memory store that held it in memory. Resolves `false` when a
|
|
73
|
+
* DURABLE backing was expected but the write did NOT land (read-back mismatch
|
|
74
|
+
* or a thrown write); the in-memory mirror still keeps the session live for
|
|
75
|
+
* this process, but it will be lost on reload.
|
|
76
|
+
*
|
|
77
|
+
* A lane persisting a ROTATED device secret (the mint's `nextDeviceSecret`)
|
|
78
|
+
* MUST treat `false` as fatal for that mint: it must NOT plant/advertise a
|
|
79
|
+
* session built on a secret that will not survive a reload.
|
|
80
|
+
*/
|
|
81
|
+
save(state: PersistedAuthState): Promise<boolean>;
|
|
68
82
|
clear(): Promise<void>;
|
|
69
83
|
}
|
|
70
84
|
/**
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified token refresh — THE single access-token re-mint for web + native.
|
|
3
|
-
*
|
|
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:
|
|
7
|
-
*
|
|
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).
|
|
14
|
-
* - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
|
|
15
|
-
* `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
|
|
16
|
-
* dedup + cooldown (this module does NOT reimplement them).
|
|
17
|
-
* - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
|
|
18
|
-
* React type: re-mints ~60s before `exp`, re-arms on token change + web
|
|
19
|
-
* tab-focus, `.unref?.()`s its timer in Node.
|
|
20
|
-
*
|
|
21
|
-
* Framework-free; no module-level mutable state.
|
|
22
|
-
*/
|
|
23
1
|
import type { OxyServices } from '../OxyServices';
|
|
24
2
|
import type { AuthRefreshHandler } from '../HttpService';
|
|
25
3
|
import type { AuthStateStore } from './authStateStore';
|
|
@@ -40,21 +18,79 @@ export interface RefreshDeps {
|
|
|
40
18
|
*/
|
|
41
19
|
allowSharedKeyFallback?: boolean;
|
|
42
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
23
|
+
* the re-mint handler and the cold boot can react per the transport contract
|
|
24
|
+
* without re-classifying the raw error:
|
|
25
|
+
* - `ok` — minted, persisted the rotated secret, planted the token.
|
|
26
|
+
* - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
|
|
27
|
+
* - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
|
|
28
|
+
* diverged (another tab/device rotated it past the grace window).
|
|
29
|
+
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
30
|
+
* session (authoritative signed-out).
|
|
31
|
+
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
32
|
+
* - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
|
|
33
|
+
* the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
|
|
34
|
+
* deliberately NOT planted: advertising a healthy session on a secret that
|
|
35
|
+
* will not survive a reload is exactly the divergence that logs users out.
|
|
36
|
+
*/
|
|
37
|
+
export type DeviceSecretMintOutcome = {
|
|
38
|
+
status: 'ok';
|
|
39
|
+
token: string;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
userId: string;
|
|
42
|
+
} | {
|
|
43
|
+
status: 'no-secret';
|
|
44
|
+
} | {
|
|
45
|
+
status: 'invalid-secret';
|
|
46
|
+
} | {
|
|
47
|
+
status: 'no-session';
|
|
48
|
+
} | {
|
|
49
|
+
status: 'transient';
|
|
50
|
+
} | {
|
|
51
|
+
status: 'persist-failed';
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Arm 1 — the rotating device-secret mint, run under the owning client's
|
|
55
|
+
* PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
56
|
+
*
|
|
57
|
+
* The server ROTATES the presented `deviceSecret` on every successful mint and
|
|
58
|
+
* the just-presented secret is valid only for a short grace window. If two lanes
|
|
59
|
+
* (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
|
|
60
|
+
* the socket token transport, or a tab-focus reconcile) minted concurrently they
|
|
61
|
+
* would double-rotate the server and the durable store could converge on the
|
|
62
|
+
* SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
|
|
63
|
+
* user is signed out. Routing EVERY lane through this one single-flight makes
|
|
64
|
+
* concurrent callers await the SAME in-flight mint and all receive its result, so
|
|
65
|
+
* there is exactly one rotation and the store always converges on the true
|
|
66
|
+
* `current` secret.
|
|
67
|
+
*
|
|
68
|
+
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
69
|
+
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
70
|
+
* planting. This function performs NO store mutation on failure — the caller
|
|
71
|
+
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
72
|
+
* status.
|
|
73
|
+
*/
|
|
74
|
+
export declare function refreshDeviceSecretArm(deps: {
|
|
75
|
+
oxy: OxyServices;
|
|
76
|
+
store: AuthStateStore;
|
|
77
|
+
}): Promise<DeviceSecretMintOutcome>;
|
|
43
78
|
/**
|
|
44
79
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
45
80
|
* when no arm could produce one.
|
|
46
81
|
*
|
|
47
|
-
* Arm 1 (`POST /session/device/token
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
82
|
+
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
83
|
+
* from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
|
|
84
|
+
* or the device has no live session: drop the secret so the mint lane stops (or
|
|
85
|
+
* clear the store on web, where there is no fallback), then fall to arm 2 on
|
|
86
|
+
* native. A transient error — or a durable-persist failure — leaves the store and
|
|
87
|
+
* returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
|
|
53
88
|
*
|
|
54
89
|
* Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
|
|
55
|
-
* re-mint via `signInWithSharedIdentity` (which plants tokens).
|
|
56
|
-
*
|
|
57
|
-
*
|
|
90
|
+
* re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
|
|
91
|
+
* recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
|
|
92
|
+
* lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
|
|
93
|
+
* in-session shared-key recovery must not leave the fast-lane credential empty.
|
|
58
94
|
*/
|
|
59
95
|
export declare function refreshPersistedSession(deps: RefreshDeps): Promise<string | null>;
|
|
60
96
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "9.2.
|
|
3
|
+
"version": "9.2.4",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
97
|
"@oxyhq/contracts": "^0.13.2",
|
|
98
|
-
"@oxyhq/protocol": "^0.1.
|
|
98
|
+
"@oxyhq/protocol": "^0.1.5",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
|
101
101
|
"elliptic": "^6.6.1",
|
package/src/HttpService.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { isNative, getPlatformOS } from './utils/platform';
|
|
|
22
22
|
import { isReactNative } from '@oxyhq/protocol';
|
|
23
23
|
import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
|
|
24
24
|
import type { OxyConfig } from './models/interfaces';
|
|
25
|
+
import type { DeviceSecretMintOutcome } from './session/refresh';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Check if we're running in a native app environment (React Native, not web)
|
|
@@ -234,6 +235,7 @@ export class HttpService {
|
|
|
234
235
|
private tokenRefreshCooldownUntil: number = 0;
|
|
235
236
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
236
237
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
238
|
+
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
237
239
|
|
|
238
240
|
/**
|
|
239
241
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
@@ -1061,6 +1063,35 @@ export class HttpService {
|
|
|
1061
1063
|
return this.tokenRefreshPromise;
|
|
1062
1064
|
}
|
|
1063
1065
|
|
|
1066
|
+
/**
|
|
1067
|
+
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
1068
|
+
* (`POST /session/device/token`).
|
|
1069
|
+
*
|
|
1070
|
+
* The server rotates the presented `deviceSecret` on every successful mint, so
|
|
1071
|
+
* two concurrent mints would double-rotate and the durable store could end up
|
|
1072
|
+
* holding a superseded secret → a later cold-boot mint 401s → the user is
|
|
1073
|
+
* signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
|
|
1074
|
+
* the device-first cold boot, the socket token transport, the tab-focus
|
|
1075
|
+
* reconcile) funnels its `refreshDeviceSecretArm` call through here, so
|
|
1076
|
+
* concurrent callers await the SAME in-flight mint and all receive its result —
|
|
1077
|
+
* exactly one server rotation.
|
|
1078
|
+
*
|
|
1079
|
+
* Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
|
|
1080
|
+
* handler incl. the native shared-key arm + the failure cooldown): this inner
|
|
1081
|
+
* guard serializes the rotation itself across BOTH the handler and the
|
|
1082
|
+
* handler-independent cold boot, which never runs through `refreshAccessToken`.
|
|
1083
|
+
*/
|
|
1084
|
+
runSingleFlightDeviceSecretMint(
|
|
1085
|
+
mint: () => Promise<DeviceSecretMintOutcome>,
|
|
1086
|
+
): Promise<DeviceSecretMintOutcome> {
|
|
1087
|
+
if (!this.deviceSecretMintInFlight) {
|
|
1088
|
+
this.deviceSecretMintInFlight = mint().finally(() => {
|
|
1089
|
+
this.deviceSecretMintInFlight = null;
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
return this.deviceSecretMintInFlight;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1064
1095
|
/**
|
|
1065
1096
|
* Unwrap standardized API response format
|
|
1066
1097
|
*/
|
|
@@ -10,6 +10,7 @@ import type { OxyServices } from '../../OxyServices';
|
|
|
10
10
|
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
11
11
|
import type { SessionLoginResponse } from '../../models/session';
|
|
12
12
|
import { runSessionColdBoot } from '../sessionColdBoot';
|
|
13
|
+
import type { DeviceSecretMintOutcome } from '../../session/refresh';
|
|
13
14
|
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
14
15
|
|
|
15
16
|
interface OxyOverrides {
|
|
@@ -17,6 +18,19 @@ interface OxyOverrides {
|
|
|
17
18
|
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/** A real device-secret mint single-flight matching HttpService's. */
|
|
22
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
23
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
24
|
+
return (mint) => {
|
|
25
|
+
if (!inFlight) {
|
|
26
|
+
inFlight = mint().finally(() => {
|
|
27
|
+
inFlight = null;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return inFlight;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
21
35
|
const setTokens = jest.fn();
|
|
22
36
|
const oxy = {
|
|
@@ -30,6 +44,8 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
30
44
|
?? (async () => {
|
|
31
45
|
throw new Error('mintFromDeviceSecret not stubbed');
|
|
32
46
|
}),
|
|
47
|
+
// The device-secret-mint step runs through the client's single-flight.
|
|
48
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
33
49
|
} as unknown as OxyServices;
|
|
34
50
|
return { oxy, setTokens };
|
|
35
51
|
}
|
|
@@ -72,6 +88,109 @@ function seedCredStore(extra: Partial<PersistedAuthState> = {}) {
|
|
|
72
88
|
};
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
describe('runSessionColdBoot — warm-token-plant', () => {
|
|
92
|
+
/** Comfortably beyond the 60s refresh lead window. */
|
|
93
|
+
const farFuture = () => new Date(Date.now() + 3_600_000).toISOString();
|
|
94
|
+
|
|
95
|
+
it('plants a still-valid warm token FIRST, skipping the mint round-trip', async () => {
|
|
96
|
+
const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: farFuture() });
|
|
97
|
+
await seed();
|
|
98
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
99
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
100
|
+
const saveSpy = jest.spyOn(store, 'save');
|
|
101
|
+
const onSession = jest.fn();
|
|
102
|
+
|
|
103
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession });
|
|
104
|
+
|
|
105
|
+
expect(outcome).toEqual({ kind: 'session', via: 'warm-token-plant', session: expect.any(Object) });
|
|
106
|
+
expect(setTokens).toHaveBeenCalledWith('warm-access');
|
|
107
|
+
// The warm plant won before the mint lane — no network round-trip on first paint.
|
|
108
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
109
|
+
// Session identity comes straight from the persisted owning session.
|
|
110
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
sessionId: 'sess-old',
|
|
113
|
+
userId: 'user-old',
|
|
114
|
+
accessToken: 'warm-access',
|
|
115
|
+
via: 'warm-token-plant',
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
// Used AS-IS: the step never mints, rotates, or persists.
|
|
119
|
+
expect(saveSpy).not.toHaveBeenCalled();
|
|
120
|
+
expect(await store.load()).toMatchObject({ deviceSecret: 'ds-secret-orig', accessToken: 'warm-access' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('plants on native too (the step runs on both platforms)', async () => {
|
|
124
|
+
const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: farFuture() });
|
|
125
|
+
await seed();
|
|
126
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
127
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
128
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
129
|
+
|
|
130
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
131
|
+
|
|
132
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
|
|
133
|
+
expect(setTokens).toHaveBeenCalledWith('warm-access');
|
|
134
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
135
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it.each([
|
|
139
|
+
['within the refresh lead window', () => new Date(Date.now() + 30_000).toISOString()],
|
|
140
|
+
['already expired', () => new Date(Date.now() - 1_000).toISOString()],
|
|
141
|
+
['malformed (Date.parse NaN)', () => 'not-a-real-date'],
|
|
142
|
+
])('skips to the mint lane when the warm token is %s', async (_label, expiresAt) => {
|
|
143
|
+
const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: expiresAt() });
|
|
144
|
+
await seed();
|
|
145
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
146
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
147
|
+
|
|
148
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB });
|
|
149
|
+
|
|
150
|
+
// Warm plant skipped → the unchanged mint lane resolved the session.
|
|
151
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
152
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
153
|
+
expect(setTokens).toHaveBeenCalledWith('access-minted');
|
|
154
|
+
// The stale warm token was NOT planted.
|
|
155
|
+
expect(setTokens).not.toHaveBeenCalledWith('warm-access');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('skips (regression guard) when no accessToken is persisted → mint lane runs', async () => {
|
|
159
|
+
const { store, seed } = seedCredStore(); // deviceId/deviceSecret only, no warm token
|
|
160
|
+
await seed();
|
|
161
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
162
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
163
|
+
|
|
164
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB });
|
|
165
|
+
|
|
166
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
167
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
168
|
+
expect(setTokens).toHaveBeenCalledWith('access-minted');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('skips when a warm token is present but the owning sessionId/userId is missing', async () => {
|
|
172
|
+
// Device-only bootstrap blob: deviceId + deviceSecret but empty session identity.
|
|
173
|
+
const store = createMemoryAuthStateStore();
|
|
174
|
+
await store.save({
|
|
175
|
+
sessionId: '',
|
|
176
|
+
userId: '',
|
|
177
|
+
deviceId: 'dev-mint',
|
|
178
|
+
deviceSecret: 'ds-secret-orig',
|
|
179
|
+
accessToken: 'warm-access',
|
|
180
|
+
expiresAt: farFuture(),
|
|
181
|
+
});
|
|
182
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
183
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
184
|
+
|
|
185
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB });
|
|
186
|
+
|
|
187
|
+
// No owning session → warm plant skips → the mint lane resolves it.
|
|
188
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
189
|
+
expect(setTokens).not.toHaveBeenCalledWith('warm-access');
|
|
190
|
+
expect(mintFromDeviceSecret).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
75
194
|
describe('runSessionColdBoot — device-secret-mint', () => {
|
|
76
195
|
it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
|
|
77
196
|
const { store, seed } = seedCredStore();
|