@oxyhq/core 9.2.2 → 9.2.3
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 +1 -1
- 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
|
@@ -7,21 +7,25 @@
|
|
|
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
|
*/
|
|
19
23
|
import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
|
|
20
24
|
import { isNative as detectNative } from '../utils/platform';
|
|
21
|
-
import { extractErrorStatus } from '../utils/errorUtils';
|
|
22
25
|
import { logger } from '../utils/loggerUtils';
|
|
26
|
+
import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh';
|
|
23
27
|
import type { OxyServices } from '../OxyServices';
|
|
24
|
-
import type { AuthStateStore
|
|
28
|
+
import type { AuthStateStore } from '../session/authStateStore';
|
|
25
29
|
|
|
26
30
|
/** The winning session shape a cold-boot step reports. */
|
|
27
31
|
export interface DeviceBootSession {
|
|
@@ -45,34 +49,6 @@ export interface RunSessionColdBootOptions {
|
|
|
45
49
|
onStepError?: (id: string, error: unknown) => void;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
/**
|
|
49
|
-
* How a `mintFromDeviceSecret` call failed, distinguished so the cold boot can
|
|
50
|
-
* react per the transport contract:
|
|
51
|
-
* - `invalid_secret` — the presented secret no longer matches (another tab/
|
|
52
|
-
* device rotated it, or theft divergence). Drop it and fall back.
|
|
53
|
-
* - `no_active_session` — the device is known but has no live session.
|
|
54
|
-
* Authoritative signed-out.
|
|
55
|
-
* - `transient` — network / 5xx. Keep the secret; a later attempt can succeed.
|
|
56
|
-
*
|
|
57
|
-
* The mint is bearer-less (`skipAuth`), so `HttpService` surfaces the server's
|
|
58
|
-
* 401 body string (`invalid_device_secret` | `no_active_session`) as the thrown
|
|
59
|
-
* error's `message`; any non-401 is transport/server failure.
|
|
60
|
-
*/
|
|
61
|
-
type MintFailure = 'invalid_secret' | 'no_active_session' | 'transient';
|
|
62
|
-
|
|
63
|
-
function classifyMintFailure(error: unknown): MintFailure {
|
|
64
|
-
if (extractErrorStatus(error) === 401) {
|
|
65
|
-
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
66
|
-
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
67
|
-
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
68
|
-
const message = (error as { message?: unknown })?.message;
|
|
69
|
-
return typeof message === 'string' && message.includes('no_active_session')
|
|
70
|
-
? 'no_active_session'
|
|
71
|
-
: 'invalid_secret';
|
|
72
|
-
}
|
|
73
|
-
return 'transient';
|
|
74
|
-
}
|
|
75
|
-
|
|
76
52
|
/**
|
|
77
53
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
78
54
|
* a side effect, invokes `onSession` (winning session, token already planted) or
|
|
@@ -90,64 +66,107 @@ export async function runSessionColdBoot(
|
|
|
90
66
|
|
|
91
67
|
const steps: Array<ColdBootStep<DeviceBootSession>> = [];
|
|
92
68
|
|
|
93
|
-
// 1.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
69
|
+
// 1. warm-token-plant (web + native) — the fastest path. When the persisted
|
|
70
|
+
// store already holds a still-valid warm access token (its expiry more than
|
|
71
|
+
// the refresh lead window away) plus its owning session identity, plant it
|
|
72
|
+
// and yield the session IMMEDIATELY, skipping the blocking mint round-trip on
|
|
73
|
+
// first paint. The token is used AS-IS: this step NEVER mints, rotates, or
|
|
74
|
+
// persists anything. The proactive `startTokenRefreshScheduler` + the
|
|
75
|
+
// request-time preflight (both wired in the services provider) rotate it in
|
|
76
|
+
// the background; a revoked token self-heals via the 401 -> re-mint -> clear
|
|
77
|
+
// path. This exposure is sanctioned by `authStateStore.ts` (~L30-36): the
|
|
78
|
+
// warm token is short-lived and adds nothing over the already-persisted
|
|
79
|
+
// `deviceSecret`.
|
|
96
80
|
steps.push({
|
|
97
|
-
id: '
|
|
81
|
+
id: 'warm-token-plant',
|
|
98
82
|
run: async () => {
|
|
99
83
|
const persisted = await store.load();
|
|
100
|
-
if (!persisted?.
|
|
84
|
+
if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
|
|
85
|
+
return { kind: 'skip' };
|
|
86
|
+
}
|
|
87
|
+
// Guard a malformed `expiresAt` (Date.parse -> NaN): treat as not-valid and
|
|
88
|
+
// fall through to the mint lane. A token still inside the refresh lead
|
|
89
|
+
// window (or already expired) is likewise skipped — let the mint lane get a
|
|
90
|
+
// fresh one rather than plant a token about to expire.
|
|
91
|
+
const expiresAtMs = new Date(persisted.expiresAt).getTime();
|
|
92
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + TOKEN_REFRESH_LEAD_MS) {
|
|
101
93
|
return { kind: 'skip' };
|
|
102
94
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
95
|
+
oxy.setTokens(persisted.accessToken);
|
|
96
|
+
return {
|
|
97
|
+
kind: 'session',
|
|
98
|
+
session: {
|
|
99
|
+
sessionId: persisted.sessionId,
|
|
100
|
+
userId: persisted.userId,
|
|
101
|
+
accessToken: persisted.accessToken,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 2. device-secret-mint (web + native) — the zero-cookie fast path. When the
|
|
108
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
109
|
+
// a single bearer-less POST (no cookie, no navigation). The mint itself runs
|
|
110
|
+
// through `refreshDeviceSecretArm`, which acquires the client's PROCESS-WIDE
|
|
111
|
+
// single-flight, persists the rotated `nextDeviceSecret` BEFORE planting the
|
|
112
|
+
// token, and returns a classified outcome — so this step can never
|
|
113
|
+
// double-rotate the server against the scheduler/transport/401 lanes, and
|
|
114
|
+
// the durable store always converges on the true `current` secret.
|
|
115
|
+
steps.push({
|
|
116
|
+
id: 'device-secret-mint',
|
|
117
|
+
run: async () => {
|
|
118
|
+
const result = await refreshDeviceSecretArm({ oxy, store });
|
|
119
|
+
switch (result.status) {
|
|
120
|
+
case 'ok':
|
|
121
|
+
// The arm persisted the rotated secret and planted the token.
|
|
122
|
+
return {
|
|
123
|
+
kind: 'session',
|
|
124
|
+
session: {
|
|
125
|
+
sessionId: result.sessionId,
|
|
126
|
+
userId: result.userId,
|
|
127
|
+
accessToken: result.token,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
case 'invalid-secret': {
|
|
127
131
|
// Stale/diverged secret — drop it so the mint lane stops firing. On
|
|
128
132
|
// native the shared-key step below can still recover; on web this ends
|
|
129
133
|
// signed out. Setting it undefined drops the key on the store's JSON
|
|
130
134
|
// serialization, and the mint guard treats undefined as absent.
|
|
131
|
-
await store.
|
|
135
|
+
const persisted = await store.load();
|
|
136
|
+
if (persisted) {
|
|
137
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
138
|
+
}
|
|
132
139
|
return { kind: 'skip' };
|
|
133
140
|
}
|
|
134
|
-
|
|
135
|
-
// Device known, no live session — authoritative signed-out.
|
|
141
|
+
case 'no-session':
|
|
142
|
+
// Device known, no live session — authoritative signed-out. Keep the
|
|
143
|
+
// secret (the device may sign in again).
|
|
136
144
|
signedOutReason = 'no_session';
|
|
137
145
|
return { kind: 'skip' };
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
case 'persist-failed':
|
|
147
|
+
// The mint rotated the secret but it could not be durably persisted —
|
|
148
|
+
// refuse to advertise a session that will not survive a reload. Keep
|
|
149
|
+
// the secret; a later boot/attempt re-mints once storage recovers.
|
|
150
|
+
logger.error(
|
|
151
|
+
'device-secret mint rotated the secret but it could not be durably persisted — not planting',
|
|
152
|
+
undefined,
|
|
153
|
+
{ component: 'sessionColdBoot', method: 'device-secret-mint' },
|
|
154
|
+
);
|
|
155
|
+
return { kind: 'skip' };
|
|
156
|
+
case 'transient':
|
|
157
|
+
// Network / 5xx: keep the secret; a later attempt can succeed.
|
|
158
|
+
logger.debug(
|
|
159
|
+
'device-secret mint failed (transient) — keeping secret',
|
|
160
|
+
{ component: 'sessionColdBoot', method: 'device-secret-mint' },
|
|
161
|
+
);
|
|
162
|
+
return { kind: 'skip' };
|
|
163
|
+
case 'no-secret':
|
|
164
|
+
return { kind: 'skip' };
|
|
146
165
|
}
|
|
147
166
|
},
|
|
148
167
|
});
|
|
149
168
|
|
|
150
|
-
//
|
|
169
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
151
170
|
steps.push({
|
|
152
171
|
id: 'shared-key-signin',
|
|
153
172
|
enabled: () => isNative,
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { ec as EC } from 'elliptic';
|
|
9
9
|
import type { ECKeyPair } from 'elliptic';
|
|
10
10
|
import { isWeb, isIOS, isAndroid } from '../utils/platform';
|
|
11
|
-
import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore } from '@oxyhq/protocol';
|
|
11
|
+
import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
|
|
12
12
|
import { logger } from '../utils/loggerUtils';
|
|
13
13
|
import { isDev } from '../shared/utils/debugUtils';
|
|
14
14
|
|
|
@@ -298,13 +298,20 @@ export class KeyManager {
|
|
|
298
298
|
);
|
|
299
299
|
}
|
|
300
300
|
} else if (isAndroid()) {
|
|
301
|
-
// Android:
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
301
|
+
// Android: write through the cross-app bridge (`@oxyhq/expo-oxy-identity`)
|
|
302
|
+
// when present — it persists into Commons's hardware-backed
|
|
303
|
+
// EncryptedSharedPreferences behind a signature-protected ContentProvider,
|
|
304
|
+
// so same-key Oxy apps can read it. When the bridge is not linked, fall
|
|
305
|
+
// back to the package-private secure store (no cross-app sharing).
|
|
306
|
+
const bridge = await loadSharedIdentityBridge();
|
|
307
|
+
if (bridge) {
|
|
308
|
+
await bridge.putShared(privateKey, publicKey);
|
|
309
|
+
} else {
|
|
310
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
|
|
311
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
312
|
+
});
|
|
313
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
314
|
+
}
|
|
308
315
|
}
|
|
309
316
|
|
|
310
317
|
// Update cache
|
|
@@ -341,7 +348,14 @@ export class KeyManager {
|
|
|
341
348
|
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
342
349
|
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
343
350
|
} else if (isAndroid()) {
|
|
344
|
-
|
|
351
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
352
|
+
// back to the package-private store the fallback write path used.
|
|
353
|
+
const bridge = await loadSharedIdentityBridge();
|
|
354
|
+
if (bridge) {
|
|
355
|
+
publicKey = (await bridge.getShared())?.publicKey ?? null;
|
|
356
|
+
} else {
|
|
357
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
358
|
+
}
|
|
345
359
|
}
|
|
346
360
|
|
|
347
361
|
// Cache result
|
|
@@ -378,7 +392,14 @@ export class KeyManager {
|
|
|
378
392
|
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
379
393
|
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
380
394
|
} else if (isAndroid()) {
|
|
381
|
-
|
|
395
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
396
|
+
// back to the package-private store the fallback write path used.
|
|
397
|
+
const bridge = await loadSharedIdentityBridge();
|
|
398
|
+
if (bridge) {
|
|
399
|
+
privateKey = (await bridge.getShared())?.privateKey ?? null;
|
|
400
|
+
} else {
|
|
401
|
+
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
402
|
+
}
|
|
382
403
|
}
|
|
383
404
|
|
|
384
405
|
return privateKey;
|
|
@@ -458,11 +479,17 @@ export class KeyManager {
|
|
|
458
479
|
};
|
|
459
480
|
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
460
481
|
} else if (isAndroid()) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
482
|
+
// Android: write through the cross-app bridge when present; otherwise the
|
|
483
|
+
// package-private store (kept consistent with the read fallback).
|
|
484
|
+
const bridge = await loadSharedIdentityBridge();
|
|
485
|
+
if (bridge) {
|
|
486
|
+
await bridge.putShared(canonicalPrivate, publicKey);
|
|
487
|
+
} else {
|
|
488
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
|
|
489
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
490
|
+
});
|
|
491
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
492
|
+
}
|
|
466
493
|
}
|
|
467
494
|
|
|
468
495
|
// Update cache
|
package/src/index.ts
CHANGED
|
@@ -557,7 +557,7 @@ export type { SyncHubAfterSignInOptions } from './session/hubSync';
|
|
|
557
557
|
// Session sync (device-scoped multi-account session client)
|
|
558
558
|
// ---------------------------------------------------------------------------
|
|
559
559
|
export { SessionClient } from './session/SessionClient';
|
|
560
|
-
export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential } from './session/SessionClient';
|
|
560
|
+
export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential, SessionStateOrigin } from './session/SessionClient';
|
|
561
561
|
// The injectable socket factory type: consumers that bundle socket.io-client
|
|
562
562
|
// (services/auth-sdk) pass its `io` export as `socketFactory` so realtime sync
|
|
563
563
|
// never relies on core's lazy dynamic import of a bare specifier.
|
|
@@ -629,12 +629,13 @@ export type {
|
|
|
629
629
|
|
|
630
630
|
export {
|
|
631
631
|
refreshPersistedSession,
|
|
632
|
+
refreshDeviceSecretArm,
|
|
632
633
|
createAuthRefreshHandler,
|
|
633
634
|
installAuthRefreshHandler,
|
|
634
635
|
startTokenRefreshScheduler,
|
|
635
636
|
TOKEN_REFRESH_LEAD_MS,
|
|
636
637
|
} from './session/refresh';
|
|
637
|
-
export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
|
|
638
|
+
export type { RefreshDeps, TokenRefreshSchedulerHandle, DeviceSecretMintOutcome } from './session/refresh';
|
|
638
639
|
|
|
639
640
|
export { runSessionColdBoot } from './boot/sessionColdBoot';
|
|
640
641
|
export type {
|
|
@@ -13,6 +13,22 @@ export interface TokenTransport {
|
|
|
13
13
|
ensureActiveToken(state: DeviceSessionState): Promise<void>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Where an applied device state came from, so consumers can decide how
|
|
18
|
+
* AUTHORITATIVE a zero-account ("signed out") verdict is:
|
|
19
|
+
* - `request` — the response to a direct REST call this client made
|
|
20
|
+
* (`bootstrap` / `switch` / `signOut` / `add`). A stable, server-authoritative
|
|
21
|
+
* verdict: an empty state here reflects a real sign-out or revocation, so the
|
|
22
|
+
* durable device credential MAY be erased.
|
|
23
|
+
* - `push` — an out-of-band Socket.IO `session_state` broadcast. Potentially
|
|
24
|
+
* transient (a reconnect race / another device's mutation), so an empty state
|
|
25
|
+
* here must NOT erase THIS origin's durable device credential — only clear the
|
|
26
|
+
* local UI session. A dead credential re-mints to `no_active_session` and
|
|
27
|
+
* resolves signed-out cleanly on the next boot; a wrongly-erased one cannot be
|
|
28
|
+
* recovered without a fresh sign-in.
|
|
29
|
+
*/
|
|
30
|
+
export type SessionStateOrigin = 'request' | 'push';
|
|
31
|
+
|
|
16
32
|
export interface DeviceCredential {
|
|
17
33
|
deviceId: string;
|
|
18
34
|
deviceSecret: string;
|
|
@@ -34,13 +50,20 @@ export interface SessionClientOptions {
|
|
|
34
50
|
/**
|
|
35
51
|
* Invoked when an APPLIED state has zero accounts — i.e. a device
|
|
36
52
|
* signout-all removed the last account from this device set. Providers use
|
|
37
|
-
* this to clear
|
|
38
|
-
*
|
|
53
|
+
* this to clear local session state and, for a `request`-origin verdict, the
|
|
54
|
+
* persisted {@link AuthStateStore} so a reload does not try to restore a
|
|
55
|
+
* session that no longer exists on the device.
|
|
56
|
+
*
|
|
57
|
+
* The {@link SessionStateOrigin} is passed so the consumer can gate the
|
|
58
|
+
* DESTRUCTIVE credential wipe: a `push`-origin empty state (a socket broadcast,
|
|
59
|
+
* possibly a transient reconnect artifact) must NOT erase the durable device
|
|
60
|
+
* credential — only a `request`-origin verdict (a direct REST sign-out /
|
|
61
|
+
* revocation) is authoritative enough for that.
|
|
39
62
|
*
|
|
40
63
|
* Only fires when a state is actually applied (revision advanced), never on
|
|
41
64
|
* a stale/rejected push. Exceptions thrown by the callback are isolated.
|
|
42
65
|
*/
|
|
43
|
-
onUnauthenticated?: () => void;
|
|
66
|
+
onUnauthenticated?: (origin: SessionStateOrigin) => void;
|
|
44
67
|
/**
|
|
45
68
|
* Statically-injected `socket.io-client` factory (its `io` export).
|
|
46
69
|
* `@oxyhq/services` lists `socket.io-client` as a real dependency and
|
|
@@ -148,7 +171,7 @@ export class SessionClient {
|
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
151
|
-
protected applyState(raw: unknown): boolean {
|
|
174
|
+
protected applyState(raw: unknown, origin: SessionStateOrigin = 'push'): boolean {
|
|
152
175
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
153
176
|
if (!next) {
|
|
154
177
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -176,7 +199,7 @@ export class SessionClient {
|
|
|
176
199
|
this.notify();
|
|
177
200
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
178
201
|
try {
|
|
179
|
-
this.options.onUnauthenticated();
|
|
202
|
+
this.options.onUnauthenticated(origin);
|
|
180
203
|
} catch (error) {
|
|
181
204
|
logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
182
205
|
}
|
|
@@ -224,7 +247,10 @@ export class SessionClient {
|
|
|
224
247
|
logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
|
|
225
248
|
return;
|
|
226
249
|
}
|
|
227
|
-
this
|
|
250
|
+
// A `sync` is always the response to a direct REST call this client made
|
|
251
|
+
// (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
|
|
252
|
+
// verdict.
|
|
253
|
+
this.applyState(sync.state, 'request');
|
|
228
254
|
if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
|
|
229
255
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
230
256
|
}
|
|
@@ -366,7 +392,9 @@ export class SessionClient {
|
|
|
366
392
|
},
|
|
367
393
|
});
|
|
368
394
|
socket.on('session_state', (payload: unknown) => {
|
|
369
|
-
|
|
395
|
+
// A socket broadcast is a `push`-origin — potentially transient, so an
|
|
396
|
+
// empty state here must not erase the durable device credential.
|
|
397
|
+
const applied = this.applyState(payload, 'push');
|
|
370
398
|
if (!applied) return;
|
|
371
399
|
// A push changed the active account on another device/tab — re-fetch state
|
|
372
400
|
// to plant the access token for the newly-active account. When this tab is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
-
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
2
|
+
import { SessionClient, type SessionClientHost, type SessionStateOrigin } from '../SessionClient';
|
|
3
|
+
import { createMemoryAuthStateStore } from '../authStateStore';
|
|
3
4
|
|
|
4
5
|
const stateWith = (rev: number, active: string | null, accountIds: string[]): DeviceSessionState => ({
|
|
5
6
|
deviceId: 'd1',
|
|
@@ -27,6 +28,10 @@ class TestClient extends SessionClient {
|
|
|
27
28
|
public apply(raw: unknown): boolean {
|
|
28
29
|
return this.applyState(raw);
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
public applyWith(raw: unknown, origin: SessionStateOrigin): boolean {
|
|
33
|
+
return this.applyState(raw, origin);
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
describe('SessionClient.registerAndActivate', () => {
|
|
@@ -90,4 +95,56 @@ describe('SessionClient onUnauthenticated', () => {
|
|
|
90
95
|
c.apply(stateWith(4, null, []));
|
|
91
96
|
expect(onUnauthenticated).not.toHaveBeenCalled();
|
|
92
97
|
});
|
|
98
|
+
|
|
99
|
+
it('passes the applied-state ORIGIN through to onUnauthenticated', () => {
|
|
100
|
+
const onUnauthenticated = jest.fn();
|
|
101
|
+
const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
|
|
102
|
+
|
|
103
|
+
// A socket-pushed empty state → `push` origin.
|
|
104
|
+
c.applyWith(stateWith(1, null, []), 'push');
|
|
105
|
+
expect(onUnauthenticated).toHaveBeenLastCalledWith('push');
|
|
106
|
+
|
|
107
|
+
// A direct REST response empty state → `request` origin.
|
|
108
|
+
c.applyWith(stateWith(2, null, []), 'request');
|
|
109
|
+
expect(onUnauthenticated).toHaveBeenLastCalledWith('request');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('SessionClient onUnauthenticated — durable credential guard (bug #4)', () => {
|
|
114
|
+
const CRED = { sessionId: 's1', userId: 'a1', deviceId: 'dev-1', deviceSecret: 'ds-1' };
|
|
115
|
+
|
|
116
|
+
// Mirror the provider's origin-gated wipe: erase the durable credential ONLY on
|
|
117
|
+
// a `request`-origin verdict, never on a (possibly transient) `push`.
|
|
118
|
+
function wireGuardedStore() {
|
|
119
|
+
const store = createMemoryAuthStateStore();
|
|
120
|
+
const onUnauthenticated = (origin: SessionStateOrigin) => {
|
|
121
|
+
if (origin === 'request') void store.clear();
|
|
122
|
+
};
|
|
123
|
+
return { store, onUnauthenticated };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('a transient socket-pushed accounts===0 does NOT wipe the durable credential', async () => {
|
|
127
|
+
const { store, onUnauthenticated } = wireGuardedStore();
|
|
128
|
+
await store.save(CRED);
|
|
129
|
+
const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
|
|
130
|
+
|
|
131
|
+
// A `push`-origin empty state (e.g. a reconnect race on another device).
|
|
132
|
+
c.applyWith(stateWith(2, null, []), 'push');
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
|
|
135
|
+
// The device credential survives — a reload can still restore the session.
|
|
136
|
+
expect(await store.load()).toEqual(CRED);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('a real (request-origin) sign-out DOES wipe the durable credential', async () => {
|
|
140
|
+
const { store, onUnauthenticated } = wireGuardedStore();
|
|
141
|
+
await store.save(CRED);
|
|
142
|
+
const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
|
|
143
|
+
|
|
144
|
+
// A `request`-origin empty state = the REST sign-out response.
|
|
145
|
+
c.applyWith(stateWith(2, null, []), 'request');
|
|
146
|
+
await Promise.resolve();
|
|
147
|
+
|
|
148
|
+
expect(await store.load()).toBeNull();
|
|
149
|
+
});
|
|
93
150
|
});
|
|
@@ -131,4 +131,36 @@ describe('SessionClient socket', () => {
|
|
|
131
131
|
c.stop();
|
|
132
132
|
expect(fakeSocket.connected).toBe(false);
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
it('a socket-pushed empty state fires onUnauthenticated with the PUSH origin (bug #4)', async () => {
|
|
136
|
+
const onUnauthenticated = jest.fn();
|
|
137
|
+
const c = new SessionClient(makeHost(), { onUnauthenticated });
|
|
138
|
+
await c.start();
|
|
139
|
+
|
|
140
|
+
const EMPTY: DeviceSessionState = { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 };
|
|
141
|
+
fakeSocket.trigger('session_state', EMPTY);
|
|
142
|
+
|
|
143
|
+
expect(onUnauthenticated).toHaveBeenCalledWith('push');
|
|
144
|
+
c.stop();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('a REST signOut-all empty response fires onUnauthenticated with the REQUEST origin', async () => {
|
|
148
|
+
const onUnauthenticated = jest.fn();
|
|
149
|
+
const EMPTY_SYNC = {
|
|
150
|
+
state: { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 },
|
|
151
|
+
activeToken: null,
|
|
152
|
+
};
|
|
153
|
+
// bootstrap during start() returns a populated state; the signout (and any
|
|
154
|
+
// stray reconcile) returns empty.
|
|
155
|
+
const makeRequest = jest.fn().mockResolvedValue(EMPTY_SYNC).mockResolvedValueOnce(SYNC(1));
|
|
156
|
+
const c = new SessionClient(makeHost({ makeRequest }), { onUnauthenticated });
|
|
157
|
+
await c.start();
|
|
158
|
+
|
|
159
|
+
await c.signOut({ all: true });
|
|
160
|
+
// Flush any fire-and-forget post-commit reconcile before asserting/teardown.
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
|
|
163
|
+
expect(onUnauthenticated).toHaveBeenCalledWith('request');
|
|
164
|
+
c.stop();
|
|
165
|
+
});
|
|
134
166
|
});
|
|
@@ -134,7 +134,8 @@ describe('createWebAuthStateStore', () => {
|
|
|
134
134
|
|
|
135
135
|
// Construction must not throw, and the store must still function (in-memory).
|
|
136
136
|
const store = createWebAuthStateStore();
|
|
137
|
-
|
|
137
|
+
// A degraded in-memory store IS its own durability backing → reports `true`.
|
|
138
|
+
await expect(store.save(SAMPLE)).resolves.toBe(true);
|
|
138
139
|
expect(await store.load()).toEqual(SAMPLE);
|
|
139
140
|
});
|
|
140
141
|
|
|
@@ -143,13 +144,46 @@ describe('createWebAuthStateStore', () => {
|
|
|
143
144
|
const store = createWebAuthStateStore();
|
|
144
145
|
const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
// The durable write threw → the store reports the persist did NOT land.
|
|
148
|
+
await expect(store.save(withCreds)).resolves.toBe(false);
|
|
147
149
|
// The write never reached storage...
|
|
148
150
|
expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
149
151
|
// ...but the in-memory mirror keeps the session (incl. the mint credential) live.
|
|
150
152
|
expect(await store.load()).toEqual(withCreds);
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
it('reports false when the durable write SILENTLY no-ops (read-back mismatch, no throw)', async () => {
|
|
156
|
+
// A backing store whose setItem neither throws nor persists — the exact
|
|
157
|
+
// failure the read-back guards. The mirror keeps the session live, but the
|
|
158
|
+
// credential did not land, so save() must report false.
|
|
159
|
+
const map = new Map<string, string>();
|
|
160
|
+
const storage = {
|
|
161
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
162
|
+
setItem: (k: string, v: string) => {
|
|
163
|
+
// Only the WARM key writes; the durable credential silently vanishes.
|
|
164
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v);
|
|
165
|
+
},
|
|
166
|
+
removeItem: (k: string) => {
|
|
167
|
+
map.delete(k);
|
|
168
|
+
},
|
|
169
|
+
clear: () => map.clear(),
|
|
170
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
171
|
+
get length() {
|
|
172
|
+
return map.size;
|
|
173
|
+
},
|
|
174
|
+
} as Storage;
|
|
175
|
+
installLocalStorage(storage);
|
|
176
|
+
const store = createWebAuthStateStore();
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
|
|
180
|
+
).resolves.toBe(false);
|
|
181
|
+
// The durable blob never landed…
|
|
182
|
+
expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
183
|
+
// …but the mirror still serves the session for this page's lifetime.
|
|
184
|
+
expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
|
|
185
|
+
});
|
|
186
|
+
|
|
153
187
|
it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
|
|
154
188
|
const storage = makeFakeStorage();
|
|
155
189
|
installLocalStorage(storage);
|
|
@@ -222,7 +256,7 @@ describe('createWebAuthStateStore', () => {
|
|
|
222
256
|
|
|
223
257
|
await expect(
|
|
224
258
|
store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' }),
|
|
225
|
-
).resolves.
|
|
259
|
+
).resolves.toBe(true);
|
|
226
260
|
|
|
227
261
|
// The durable mint credential landed despite the warm-token write throwing.
|
|
228
262
|
const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
|
|
@@ -304,11 +338,35 @@ describe('createNativeAuthStateStore', () => {
|
|
|
304
338
|
throw new Error('secure store locked');
|
|
305
339
|
},
|
|
306
340
|
});
|
|
307
|
-
|
|
308
|
-
|
|
341
|
+
// The durable write threw → reports the persist did NOT land…
|
|
342
|
+
await expect(store.save(SAMPLE)).resolves.toBe(false);
|
|
343
|
+
// …but the in-memory mirror preserves the session for this app run.
|
|
309
344
|
expect(await store.load()).toEqual(SAMPLE);
|
|
310
345
|
});
|
|
311
346
|
|
|
347
|
+
it('reports false when the durable SecureStore write SILENTLY no-ops (oversize value, no throw)', async () => {
|
|
348
|
+
// Android SecureStore can resolve a write WITHOUT throwing yet not persist an
|
|
349
|
+
// oversize value — the read-back is the only reliable proof. save() must
|
|
350
|
+
// report false so a rotating-secret lane refuses to plant on it.
|
|
351
|
+
const map = new Map<string, string>();
|
|
352
|
+
const storage: NativeKeyValueStorage = {
|
|
353
|
+
getItem: async (k) => map.get(k) ?? null,
|
|
354
|
+
setItem: async (k, v) => {
|
|
355
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v); // durable silently dropped
|
|
356
|
+
},
|
|
357
|
+
removeItem: async (k) => {
|
|
358
|
+
map.delete(k);
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const store = createNativeAuthStateStore(storage);
|
|
362
|
+
|
|
363
|
+
await expect(
|
|
364
|
+
store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
|
|
365
|
+
).resolves.toBe(false);
|
|
366
|
+
expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
|
|
367
|
+
expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
|
|
368
|
+
});
|
|
369
|
+
|
|
312
370
|
it('persists the durable credential even when the warm-token write fails (oversize SecureStore value)', async () => {
|
|
313
371
|
const map = new Map<string, string>();
|
|
314
372
|
const storage: NativeKeyValueStorage = {
|
|
@@ -329,7 +387,7 @@ describe('createNativeAuthStateStore', () => {
|
|
|
329
387
|
|
|
330
388
|
await expect(
|
|
331
389
|
store.save({ ...SAMPLE, deviceId: 'dev-n', deviceSecret: 'ds-n' }),
|
|
332
|
-
).resolves.
|
|
390
|
+
).resolves.toBe(true);
|
|
333
391
|
|
|
334
392
|
// The durable mint credential landed to disk.
|
|
335
393
|
expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|