@oxyhq/core 9.2.1 → 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/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +54 -4
- package/dist/cjs/session/accountDialogController.js +30 -0
- package/dist/cjs/session/authStateStore.js +204 -16
- 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/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +54 -4
- package/dist/esm/session/accountDialogController.js +30 -0
- package/dist/esm/session/authStateStore.js +203 -15
- 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 +37 -4
- package/dist/types/session/accountDialogController.d.ts +17 -0
- package/dist/types/session/authStateStore.d.ts +48 -9
- 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/mixins/OxyServices.utility.ts +10 -9
- package/src/session/SessionClient.ts +79 -7
- package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
- package/src/session/__tests__/accountDialogController.test.ts +85 -0
- package/src/session/__tests__/authStateStore.test.ts +232 -4
- package/src/session/__tests__/refresh.test.ts +141 -2
- package/src/session/accountDialogController.ts +45 -0
- package/src/session/authStateStore.ts +242 -16
- 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 {
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* and Express.js authentication middleware
|
|
6
6
|
*/
|
|
7
7
|
import { jwtDecode } from 'jwt-decode';
|
|
8
|
+
import type { LinkPreview } from '@oxyhq/contracts';
|
|
8
9
|
import type { ApiError, User } from '../models/interfaces';
|
|
9
10
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
11
|
import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
12
|
+
import { buildUrl } from '../utils/apiUtils';
|
|
11
13
|
import { logger } from '../utils/loggerUtils';
|
|
12
14
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
13
15
|
|
|
@@ -217,15 +219,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
217
219
|
image?: string;
|
|
218
220
|
}> {
|
|
219
221
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
222
|
+
const path = buildUrl('/links/preview', { url, wait: 1 });
|
|
223
|
+
const preview = await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
|
|
224
|
+
return {
|
|
225
|
+
url: preview.url,
|
|
226
|
+
title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
|
|
227
|
+
description: preview.description?.trim() || 'Link',
|
|
228
|
+
image: preview.image,
|
|
229
|
+
};
|
|
229
230
|
} catch (error) {
|
|
230
231
|
throw this.handleError(error);
|
|
231
232
|
}
|
|
@@ -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
|
|
@@ -82,6 +105,10 @@ export class SessionClient {
|
|
|
82
105
|
private started = false;
|
|
83
106
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
84
107
|
private channel: SessionBroadcastChannel | null = null;
|
|
108
|
+
/** App-facing subscriptions to named server-pushed socket events. */
|
|
109
|
+
private readonly serverEvents = new Map<string, Set<(payload: unknown) => void>>();
|
|
110
|
+
/** Event names already bound on the CURRENT socket instance. */
|
|
111
|
+
private readonly boundServerEvents = new Set<string>();
|
|
85
112
|
|
|
86
113
|
constructor(
|
|
87
114
|
protected readonly host: SessionClientHost,
|
|
@@ -99,6 +126,40 @@ export class SessionClient {
|
|
|
99
126
|
};
|
|
100
127
|
}
|
|
101
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
|
|
131
|
+
* Listeners survive reconnects and socket re-creation; the returned function
|
|
132
|
+
* unsubscribes. Payloads are delivered as-is — callers validate shape.
|
|
133
|
+
*/
|
|
134
|
+
onServerEvent(event: string, listener: (payload: unknown) => void): () => void {
|
|
135
|
+
let listeners = this.serverEvents.get(event);
|
|
136
|
+
if (!listeners) {
|
|
137
|
+
listeners = new Set();
|
|
138
|
+
this.serverEvents.set(event, listeners);
|
|
139
|
+
}
|
|
140
|
+
listeners.add(listener);
|
|
141
|
+
this.bindServerEvent(event);
|
|
142
|
+
return () => {
|
|
143
|
+
listeners.delete(listener);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private bindServerEvent(event: string): void {
|
|
148
|
+
if (!this.socket || this.boundServerEvents.has(event)) return;
|
|
149
|
+
this.boundServerEvents.add(event);
|
|
150
|
+
this.socket.on(event, (payload: unknown) => {
|
|
151
|
+
const listeners = this.serverEvents.get(event);
|
|
152
|
+
if (!listeners) return;
|
|
153
|
+
for (const listener of [...listeners]) {
|
|
154
|
+
try {
|
|
155
|
+
listener(payload);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
102
163
|
protected notify(): void {
|
|
103
164
|
for (const listener of this.listeners) {
|
|
104
165
|
try {
|
|
@@ -110,7 +171,7 @@ export class SessionClient {
|
|
|
110
171
|
}
|
|
111
172
|
|
|
112
173
|
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
113
|
-
protected applyState(raw: unknown): boolean {
|
|
174
|
+
protected applyState(raw: unknown, origin: SessionStateOrigin = 'push'): boolean {
|
|
114
175
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
115
176
|
if (!next) {
|
|
116
177
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -138,7 +199,7 @@ export class SessionClient {
|
|
|
138
199
|
this.notify();
|
|
139
200
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
140
201
|
try {
|
|
141
|
-
this.options.onUnauthenticated();
|
|
202
|
+
this.options.onUnauthenticated(origin);
|
|
142
203
|
} catch (error) {
|
|
143
204
|
logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
144
205
|
}
|
|
@@ -186,7 +247,10 @@ export class SessionClient {
|
|
|
186
247
|
logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
|
|
187
248
|
return;
|
|
188
249
|
}
|
|
189
|
-
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');
|
|
190
254
|
if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
|
|
191
255
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
192
256
|
}
|
|
@@ -287,6 +351,7 @@ export class SessionClient {
|
|
|
287
351
|
if (this.socket) {
|
|
288
352
|
this.socket.disconnect();
|
|
289
353
|
this.socket = null;
|
|
354
|
+
this.boundServerEvents.clear();
|
|
290
355
|
}
|
|
291
356
|
}
|
|
292
357
|
|
|
@@ -327,7 +392,9 @@ export class SessionClient {
|
|
|
327
392
|
},
|
|
328
393
|
});
|
|
329
394
|
socket.on('session_state', (payload: unknown) => {
|
|
330
|
-
|
|
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');
|
|
331
398
|
if (!applied) return;
|
|
332
399
|
// A push changed the active account on another device/tab — re-fetch state
|
|
333
400
|
// to plant the access token for the newly-active account. When this tab is
|
|
@@ -341,6 +408,11 @@ export class SessionClient {
|
|
|
341
408
|
}
|
|
342
409
|
});
|
|
343
410
|
this.socket = socket;
|
|
411
|
+
// (Re)bind app-facing server-event subscriptions on the fresh socket.
|
|
412
|
+
this.boundServerEvents.clear();
|
|
413
|
+
for (const event of this.serverEvents.keys()) {
|
|
414
|
+
this.bindServerEvent(event);
|
|
415
|
+
}
|
|
344
416
|
}
|
|
345
417
|
|
|
346
418
|
/**
|
|
@@ -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
|
});
|