@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
package/dist/cjs/HttpService.js
CHANGED
|
@@ -135,6 +135,7 @@ class HttpService {
|
|
|
135
135
|
this.tokenRefreshCooldownUntil = 0;
|
|
136
136
|
this.authRefreshHandler = null;
|
|
137
137
|
this.accessTokenProvider = null;
|
|
138
|
+
this.deviceSecretMintInFlight = null;
|
|
138
139
|
/**
|
|
139
140
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
140
141
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -837,6 +838,32 @@ class HttpService {
|
|
|
837
838
|
}
|
|
838
839
|
return this.tokenRefreshPromise;
|
|
839
840
|
}
|
|
841
|
+
/**
|
|
842
|
+
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
843
|
+
* (`POST /session/device/token`).
|
|
844
|
+
*
|
|
845
|
+
* The server rotates the presented `deviceSecret` on every successful mint, so
|
|
846
|
+
* two concurrent mints would double-rotate and the durable store could end up
|
|
847
|
+
* holding a superseded secret → a later cold-boot mint 401s → the user is
|
|
848
|
+
* signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
|
|
849
|
+
* the device-first cold boot, the socket token transport, the tab-focus
|
|
850
|
+
* reconcile) funnels its `refreshDeviceSecretArm` call through here, so
|
|
851
|
+
* concurrent callers await the SAME in-flight mint and all receive its result —
|
|
852
|
+
* exactly one server rotation.
|
|
853
|
+
*
|
|
854
|
+
* Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
|
|
855
|
+
* handler incl. the native shared-key arm + the failure cooldown): this inner
|
|
856
|
+
* guard serializes the rotation itself across BOTH the handler and the
|
|
857
|
+
* handler-independent cold boot, which never runs through `refreshAccessToken`.
|
|
858
|
+
*/
|
|
859
|
+
runSingleFlightDeviceSecretMint(mint) {
|
|
860
|
+
if (!this.deviceSecretMintInFlight) {
|
|
861
|
+
this.deviceSecretMintInFlight = mint().finally(() => {
|
|
862
|
+
this.deviceSecretMintInFlight = null;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return this.deviceSecretMintInFlight;
|
|
866
|
+
}
|
|
840
867
|
/**
|
|
841
868
|
* Unwrap standardized API response format
|
|
842
869
|
*/
|
|
@@ -10,31 +10,23 @@ exports.runSessionColdBoot = runSessionColdBoot;
|
|
|
10
10
|
* the app renders with a "Sign in with Oxy" button.
|
|
11
11
|
*
|
|
12
12
|
* Ordered steps (first to yield a session wins):
|
|
13
|
-
* 1. `
|
|
13
|
+
* 1. `warm-token-plant` (web + native) — the fastest path: when the persisted
|
|
14
|
+
* store still holds a warm access token that is valid for more than the
|
|
15
|
+
* refresh lead window, plant it and yield the session with NO network
|
|
16
|
+
* round-trip. The background scheduler rotates it shortly after.
|
|
17
|
+
* 2. `device-secret-mint` (web + native) — the zero-cookie transport: when the
|
|
14
18
|
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
15
19
|
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
16
20
|
* navigation) and rotate the secret in-use.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
22
|
+
* 4. Signed out.
|
|
19
23
|
*
|
|
20
24
|
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
21
25
|
*/
|
|
22
26
|
const coldBoot_1 = require("../utils/coldBoot");
|
|
23
27
|
const platform_1 = require("../utils/platform");
|
|
24
|
-
const errorUtils_1 = require("../utils/errorUtils");
|
|
25
28
|
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
26
|
-
|
|
27
|
-
if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
|
|
28
|
-
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
29
|
-
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
30
|
-
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
31
|
-
const message = error?.message;
|
|
32
|
-
return typeof message === 'string' && message.includes('no_active_session')
|
|
33
|
-
? 'no_active_session'
|
|
34
|
-
: 'invalid_secret';
|
|
35
|
-
}
|
|
36
|
-
return 'transient';
|
|
37
|
-
}
|
|
29
|
+
const refresh_1 = require("../session/refresh");
|
|
38
30
|
/**
|
|
39
31
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
40
32
|
* a side effect, invokes `onSession` (winning session, token already planted) or
|
|
@@ -47,60 +39,98 @@ async function runSessionColdBoot(opts) {
|
|
|
47
39
|
// bundler re-evaluation.
|
|
48
40
|
let signedOutReason = 'no_session';
|
|
49
41
|
const steps = [];
|
|
50
|
-
// 1.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
42
|
+
// 1. warm-token-plant (web + native) — the fastest path. When the persisted
|
|
43
|
+
// store already holds a still-valid warm access token (its expiry more than
|
|
44
|
+
// the refresh lead window away) plus its owning session identity, plant it
|
|
45
|
+
// and yield the session IMMEDIATELY, skipping the blocking mint round-trip on
|
|
46
|
+
// first paint. The token is used AS-IS: this step NEVER mints, rotates, or
|
|
47
|
+
// persists anything. The proactive `startTokenRefreshScheduler` + the
|
|
48
|
+
// request-time preflight (both wired in the services provider) rotate it in
|
|
49
|
+
// the background; a revoked token self-heals via the 401 -> re-mint -> clear
|
|
50
|
+
// path. This exposure is sanctioned by `authStateStore.ts` (~L30-36): the
|
|
51
|
+
// warm token is short-lived and adds nothing over the already-persisted
|
|
52
|
+
// `deviceSecret`.
|
|
53
53
|
steps.push({
|
|
54
|
-
id: '
|
|
54
|
+
id: 'warm-token-plant',
|
|
55
55
|
run: async () => {
|
|
56
56
|
const persisted = await store.load();
|
|
57
|
-
if (!persisted?.
|
|
57
|
+
if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
|
|
58
58
|
return { kind: 'skip' };
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const next = {
|
|
68
|
-
...persisted,
|
|
69
|
-
deviceId: mint.state.deviceId,
|
|
70
|
-
deviceSecret: mint.nextDeviceSecret,
|
|
71
|
-
accessToken: mint.accessToken,
|
|
72
|
-
expiresAt: mint.expiresAt,
|
|
73
|
-
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
74
|
-
};
|
|
75
|
-
await store.save(next);
|
|
76
|
-
oxy.setTokens(mint.accessToken);
|
|
77
|
-
return {
|
|
78
|
-
kind: 'session',
|
|
79
|
-
session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
|
|
80
|
-
};
|
|
60
|
+
// Guard a malformed `expiresAt` (Date.parse -> NaN): treat as not-valid and
|
|
61
|
+
// fall through to the mint lane. A token still inside the refresh lead
|
|
62
|
+
// window (or already expired) is likewise skipped — let the mint lane get a
|
|
63
|
+
// fresh one rather than plant a token about to expire.
|
|
64
|
+
const expiresAtMs = new Date(persisted.expiresAt).getTime();
|
|
65
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + refresh_1.TOKEN_REFRESH_LEAD_MS) {
|
|
66
|
+
return { kind: 'skip' };
|
|
81
67
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
68
|
+
oxy.setTokens(persisted.accessToken);
|
|
69
|
+
return {
|
|
70
|
+
kind: 'session',
|
|
71
|
+
session: {
|
|
72
|
+
sessionId: persisted.sessionId,
|
|
73
|
+
userId: persisted.userId,
|
|
74
|
+
accessToken: persisted.accessToken,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// 2. device-secret-mint (web + native) — the zero-cookie fast path. When the
|
|
80
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
81
|
+
// a single bearer-less POST (no cookie, no navigation). The mint itself runs
|
|
82
|
+
// through `refreshDeviceSecretArm`, which acquires the client's PROCESS-WIDE
|
|
83
|
+
// single-flight, persists the rotated `nextDeviceSecret` BEFORE planting the
|
|
84
|
+
// token, and returns a classified outcome — so this step can never
|
|
85
|
+
// double-rotate the server against the scheduler/transport/401 lanes, and
|
|
86
|
+
// the durable store always converges on the true `current` secret.
|
|
87
|
+
steps.push({
|
|
88
|
+
id: 'device-secret-mint',
|
|
89
|
+
run: async () => {
|
|
90
|
+
const result = await (0, refresh_1.refreshDeviceSecretArm)({ oxy, store });
|
|
91
|
+
switch (result.status) {
|
|
92
|
+
case 'ok':
|
|
93
|
+
// The arm persisted the rotated secret and planted the token.
|
|
94
|
+
return {
|
|
95
|
+
kind: 'session',
|
|
96
|
+
session: {
|
|
97
|
+
sessionId: result.sessionId,
|
|
98
|
+
userId: result.userId,
|
|
99
|
+
accessToken: result.token,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
case 'invalid-secret': {
|
|
85
103
|
// Stale/diverged secret — drop it so the mint lane stops firing. On
|
|
86
104
|
// native the shared-key step below can still recover; on web this ends
|
|
87
105
|
// signed out. Setting it undefined drops the key on the store's JSON
|
|
88
106
|
// serialization, and the mint guard treats undefined as absent.
|
|
89
|
-
await store.
|
|
107
|
+
const persisted = await store.load();
|
|
108
|
+
if (persisted) {
|
|
109
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
110
|
+
}
|
|
90
111
|
return { kind: 'skip' };
|
|
91
112
|
}
|
|
92
|
-
|
|
93
|
-
// Device known, no live session — authoritative signed-out.
|
|
113
|
+
case 'no-session':
|
|
114
|
+
// Device known, no live session — authoritative signed-out. Keep the
|
|
115
|
+
// secret (the device may sign in again).
|
|
94
116
|
signedOutReason = 'no_session';
|
|
95
117
|
return { kind: 'skip' };
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
case 'persist-failed':
|
|
119
|
+
// The mint rotated the secret but it could not be durably persisted —
|
|
120
|
+
// refuse to advertise a session that will not survive a reload. Keep
|
|
121
|
+
// the secret; a later boot/attempt re-mints once storage recovers.
|
|
122
|
+
loggerUtils_1.logger.error('device-secret mint rotated the secret but it could not be durably persisted — not planting', undefined, { component: 'sessionColdBoot', method: 'device-secret-mint' });
|
|
123
|
+
return { kind: 'skip' };
|
|
124
|
+
case 'transient':
|
|
125
|
+
// Network / 5xx: keep the secret; a later attempt can succeed.
|
|
126
|
+
loggerUtils_1.logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
|
|
127
|
+
return { kind: 'skip' };
|
|
128
|
+
case 'no-secret':
|
|
129
|
+
return { kind: 'skip' };
|
|
100
130
|
}
|
|
101
131
|
},
|
|
102
132
|
});
|
|
103
|
-
//
|
|
133
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
104
134
|
steps.push({
|
|
105
135
|
id: 'shared-key-signin',
|
|
106
136
|
enabled: () => isNative,
|
|
@@ -234,12 +234,21 @@ class KeyManager {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
else if ((0, platform_1.isAndroid)()) {
|
|
237
|
-
// Android:
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
await
|
|
237
|
+
// Android: write through the cross-app bridge (`@oxyhq/expo-oxy-identity`)
|
|
238
|
+
// when present — it persists into Commons's hardware-backed
|
|
239
|
+
// EncryptedSharedPreferences behind a signature-protected ContentProvider,
|
|
240
|
+
// so same-key Oxy apps can read it. When the bridge is not linked, fall
|
|
241
|
+
// back to the package-private secure store (no cross-app sharing).
|
|
242
|
+
const bridge = await (0, protocol_1.loadSharedIdentityBridge)();
|
|
243
|
+
if (bridge) {
|
|
244
|
+
await bridge.putShared(privateKey, publicKey);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
|
|
248
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
249
|
+
});
|
|
250
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
251
|
+
}
|
|
243
252
|
}
|
|
244
253
|
// Update cache
|
|
245
254
|
KeyManager.cachedSharedPublicKey = publicKey;
|
|
@@ -270,7 +279,15 @@ class KeyManager {
|
|
|
270
279
|
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
271
280
|
}
|
|
272
281
|
else if ((0, platform_1.isAndroid)()) {
|
|
273
|
-
|
|
282
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
283
|
+
// back to the package-private store the fallback write path used.
|
|
284
|
+
const bridge = await (0, protocol_1.loadSharedIdentityBridge)();
|
|
285
|
+
if (bridge) {
|
|
286
|
+
publicKey = (await bridge.getShared())?.publicKey ?? null;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
290
|
+
}
|
|
274
291
|
}
|
|
275
292
|
// Cache result
|
|
276
293
|
KeyManager.cachedSharedPublicKey = publicKey;
|
|
@@ -304,7 +321,15 @@ class KeyManager {
|
|
|
304
321
|
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
305
322
|
}
|
|
306
323
|
else if ((0, platform_1.isAndroid)()) {
|
|
307
|
-
|
|
324
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
325
|
+
// back to the package-private store the fallback write path used.
|
|
326
|
+
const bridge = await (0, protocol_1.loadSharedIdentityBridge)();
|
|
327
|
+
if (bridge) {
|
|
328
|
+
privateKey = (await bridge.getShared())?.privateKey ?? null;
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
332
|
+
}
|
|
308
333
|
}
|
|
309
334
|
return privateKey;
|
|
310
335
|
}
|
|
@@ -377,10 +402,18 @@ class KeyManager {
|
|
|
377
402
|
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
378
403
|
}
|
|
379
404
|
else if ((0, platform_1.isAndroid)()) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
405
|
+
// Android: write through the cross-app bridge when present; otherwise the
|
|
406
|
+
// package-private store (kept consistent with the read fallback).
|
|
407
|
+
const bridge = await (0, protocol_1.loadSharedIdentityBridge)();
|
|
408
|
+
if (bridge) {
|
|
409
|
+
await bridge.putShared(canonicalPrivate, publicKey);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
|
|
413
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
414
|
+
});
|
|
415
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
416
|
+
}
|
|
384
417
|
}
|
|
385
418
|
// Update cache
|
|
386
419
|
KeyManager.cachedSharedPublicKey = publicKey;
|
package/dist/cjs/index.js
CHANGED
|
@@ -21,7 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
21
21
|
exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.ORGANIZATION_CATEGORIES = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
|
|
22
22
|
exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = void 0;
|
|
23
23
|
exports.buildIdpHubOrigin = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidDisplayName = exports.isValidPassword = void 0;
|
|
24
|
-
exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = void 0;
|
|
24
|
+
exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = void 0;
|
|
25
25
|
// Ensure crypto polyfills are loaded before anything else
|
|
26
26
|
require("./crypto/polyfill");
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
@@ -347,6 +347,7 @@ Object.defineProperty(exports, "createMemoryAuthStateStore", { enumerable: true,
|
|
|
347
347
|
Object.defineProperty(exports, "AUTH_STATE_STORAGE_KEY", { enumerable: true, get: function () { return authStateStore_1.AUTH_STATE_STORAGE_KEY; } });
|
|
348
348
|
var refresh_1 = require("./session/refresh");
|
|
349
349
|
Object.defineProperty(exports, "refreshPersistedSession", { enumerable: true, get: function () { return refresh_1.refreshPersistedSession; } });
|
|
350
|
+
Object.defineProperty(exports, "refreshDeviceSecretArm", { enumerable: true, get: function () { return refresh_1.refreshDeviceSecretArm; } });
|
|
350
351
|
Object.defineProperty(exports, "createAuthRefreshHandler", { enumerable: true, get: function () { return refresh_1.createAuthRefreshHandler; } });
|
|
351
352
|
Object.defineProperty(exports, "installAuthRefreshHandler", { enumerable: true, get: function () { return refresh_1.installAuthRefreshHandler; } });
|
|
352
353
|
Object.defineProperty(exports, "startTokenRefreshScheduler", { enumerable: true, get: function () { return refresh_1.startTokenRefreshScheduler; } });
|
|
@@ -9,8 +9,8 @@ exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
|
|
|
9
9
|
*/
|
|
10
10
|
const jwt_decode_1 = require("jwt-decode");
|
|
11
11
|
const protocol_1 = require("@oxyhq/protocol");
|
|
12
|
+
const apiUtils_1 = require("../utils/apiUtils");
|
|
12
13
|
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
13
|
-
const mixinHelpers_1 = require("./mixinHelpers");
|
|
14
14
|
/**
|
|
15
15
|
* Expected JWT audience for tokens issued by the Oxy auth service.
|
|
16
16
|
*/
|
|
@@ -111,10 +111,14 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
111
111
|
*/
|
|
112
112
|
async fetchLinkMetadata(url) {
|
|
113
113
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
const path = (0, apiUtils_1.buildUrl)('/links/preview', { url, wait: 1 });
|
|
115
|
+
const preview = await this.makeRequest('GET', path, undefined, { cache: false });
|
|
116
|
+
return {
|
|
117
|
+
url: preview.url,
|
|
118
|
+
title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
|
|
119
|
+
description: preview.description?.trim() || 'Link',
|
|
120
|
+
image: preview.image,
|
|
121
|
+
};
|
|
118
122
|
}
|
|
119
123
|
catch (error) {
|
|
120
124
|
throw this.handleError(error);
|
|
@@ -24,6 +24,10 @@ class SessionClient {
|
|
|
24
24
|
this.started = false;
|
|
25
25
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
26
26
|
this.channel = null;
|
|
27
|
+
/** App-facing subscriptions to named server-pushed socket events. */
|
|
28
|
+
this.serverEvents = new Map();
|
|
29
|
+
/** Event names already bound on the CURRENT socket instance. */
|
|
30
|
+
this.boundServerEvents = new Set();
|
|
27
31
|
}
|
|
28
32
|
getState() {
|
|
29
33
|
return this.state;
|
|
@@ -34,6 +38,41 @@ class SessionClient {
|
|
|
34
38
|
this.listeners.delete(listener);
|
|
35
39
|
};
|
|
36
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
|
|
43
|
+
* Listeners survive reconnects and socket re-creation; the returned function
|
|
44
|
+
* unsubscribes. Payloads are delivered as-is — callers validate shape.
|
|
45
|
+
*/
|
|
46
|
+
onServerEvent(event, listener) {
|
|
47
|
+
let listeners = this.serverEvents.get(event);
|
|
48
|
+
if (!listeners) {
|
|
49
|
+
listeners = new Set();
|
|
50
|
+
this.serverEvents.set(event, listeners);
|
|
51
|
+
}
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
this.bindServerEvent(event);
|
|
54
|
+
return () => {
|
|
55
|
+
listeners.delete(listener);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
bindServerEvent(event) {
|
|
59
|
+
if (!this.socket || this.boundServerEvents.has(event))
|
|
60
|
+
return;
|
|
61
|
+
this.boundServerEvents.add(event);
|
|
62
|
+
this.socket.on(event, (payload) => {
|
|
63
|
+
const listeners = this.serverEvents.get(event);
|
|
64
|
+
if (!listeners)
|
|
65
|
+
return;
|
|
66
|
+
for (const listener of [...listeners]) {
|
|
67
|
+
try {
|
|
68
|
+
listener(payload);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
loggerUtils_1.logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
37
76
|
notify() {
|
|
38
77
|
for (const listener of this.listeners) {
|
|
39
78
|
try {
|
|
@@ -45,7 +84,7 @@ class SessionClient {
|
|
|
45
84
|
}
|
|
46
85
|
}
|
|
47
86
|
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
48
|
-
applyState(raw) {
|
|
87
|
+
applyState(raw, origin = 'push') {
|
|
49
88
|
const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
|
|
50
89
|
if (!next) {
|
|
51
90
|
loggerUtils_1.logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -69,7 +108,7 @@ class SessionClient {
|
|
|
69
108
|
this.notify();
|
|
70
109
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
71
110
|
try {
|
|
72
|
-
this.options.onUnauthenticated();
|
|
111
|
+
this.options.onUnauthenticated(origin);
|
|
73
112
|
}
|
|
74
113
|
catch (error) {
|
|
75
114
|
loggerUtils_1.logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
@@ -115,7 +154,10 @@ class SessionClient {
|
|
|
115
154
|
loggerUtils_1.logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
|
|
116
155
|
return;
|
|
117
156
|
}
|
|
118
|
-
this
|
|
157
|
+
// A `sync` is always the response to a direct REST call this client made
|
|
158
|
+
// (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
|
|
159
|
+
// verdict.
|
|
160
|
+
this.applyState(sync.state, 'request');
|
|
119
161
|
if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
|
|
120
162
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
121
163
|
}
|
|
@@ -213,6 +255,7 @@ class SessionClient {
|
|
|
213
255
|
if (this.socket) {
|
|
214
256
|
this.socket.disconnect();
|
|
215
257
|
this.socket = null;
|
|
258
|
+
this.boundServerEvents.clear();
|
|
216
259
|
}
|
|
217
260
|
}
|
|
218
261
|
async connectSocket() {
|
|
@@ -252,7 +295,9 @@ class SessionClient {
|
|
|
252
295
|
},
|
|
253
296
|
});
|
|
254
297
|
socket.on('session_state', (payload) => {
|
|
255
|
-
|
|
298
|
+
// A socket broadcast is a `push`-origin — potentially transient, so an
|
|
299
|
+
// empty state here must not erase the durable device credential.
|
|
300
|
+
const applied = this.applyState(payload, 'push');
|
|
256
301
|
if (!applied)
|
|
257
302
|
return;
|
|
258
303
|
// A push changed the active account on another device/tab — re-fetch state
|
|
@@ -267,6 +312,11 @@ class SessionClient {
|
|
|
267
312
|
}
|
|
268
313
|
});
|
|
269
314
|
this.socket = socket;
|
|
315
|
+
// (Re)bind app-facing server-event subscriptions on the fresh socket.
|
|
316
|
+
this.boundServerEvents.clear();
|
|
317
|
+
for (const event of this.serverEvents.keys()) {
|
|
318
|
+
this.bindServerEvent(event);
|
|
319
|
+
}
|
|
270
320
|
}
|
|
271
321
|
/**
|
|
272
322
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
@@ -34,6 +34,12 @@ const authWebUrl_1 = require("../utils/authWebUrl");
|
|
|
34
34
|
const oauthPkce_1 = require("../utils/oauthPkce");
|
|
35
35
|
const accountProjection_1 = require("./accountProjection");
|
|
36
36
|
const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
37
|
+
/**
|
|
38
|
+
* Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
|
|
39
|
+
* installed Commons on the same device; the `oxycommons://approve?...` deep link
|
|
40
|
+
* itself is the flow's `qrPayload`.
|
|
41
|
+
*/
|
|
42
|
+
const COMMONS_APP_SCHEME = 'oxycommons://';
|
|
37
43
|
const IDLE_SIGN_IN = {
|
|
38
44
|
phase: 'idle',
|
|
39
45
|
authorizeCode: null,
|
|
@@ -76,6 +82,7 @@ class AccountDialogController {
|
|
|
76
82
|
this.authRedirectUri = options.authRedirectUri ?? null;
|
|
77
83
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
78
84
|
this.openUrl = options.openUrl;
|
|
85
|
+
this.canOpenApp = options.canOpenApp;
|
|
79
86
|
this.snapshot = this.computeSnapshot();
|
|
80
87
|
}
|
|
81
88
|
// =========================================================================
|
|
@@ -400,11 +407,34 @@ class AccountDialogController {
|
|
|
400
407
|
error: null,
|
|
401
408
|
});
|
|
402
409
|
this.scheduleNextPoll(handle.sessionToken);
|
|
410
|
+
// Same-device convenience: if Commons is installed (native only — `canOpenApp`
|
|
411
|
+
// is undefined/false on web), deep-link straight into its approve screen with
|
|
412
|
+
// the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
|
|
413
|
+
// stay live as the fallback, so a user who dismisses the app-open still
|
|
414
|
+
// completes the sign-in by scanning.
|
|
415
|
+
void this.maybeOpenCommons(handle.qrPayload);
|
|
403
416
|
}
|
|
404
417
|
catch (error) {
|
|
405
418
|
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
|
|
406
419
|
}
|
|
407
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* When a `canOpenApp` probe is injected and reports Commons installed, open the
|
|
423
|
+
* approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
|
|
424
|
+
* probe/open failure is logged and swallowed — the QR/polling fallback remains.
|
|
425
|
+
*/
|
|
426
|
+
async maybeOpenCommons(qrPayload) {
|
|
427
|
+
if (!this.canOpenApp || !this.openUrl)
|
|
428
|
+
return;
|
|
429
|
+
try {
|
|
430
|
+
if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
|
|
431
|
+
this.openUrl(qrPayload);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
loggerUtils_1.logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
408
438
|
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
409
439
|
cancelSignIn() {
|
|
410
440
|
this.clearPollTimer();
|