@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.
Files changed (36) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +27 -0
  3. package/dist/cjs/boot/sessionColdBoot.js +83 -53
  4. package/dist/cjs/crypto/keyManager.js +45 -12
  5. package/dist/cjs/index.js +2 -1
  6. package/dist/cjs/session/SessionClient.js +9 -4
  7. package/dist/cjs/session/authStateStore.js +8 -0
  8. package/dist/cjs/session/refresh.js +110 -37
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/HttpService.js +27 -0
  11. package/dist/esm/boot/sessionColdBoot.js +83 -53
  12. package/dist/esm/crypto/keyManager.js +46 -13
  13. package/dist/esm/index.js +1 -1
  14. package/dist/esm/session/SessionClient.js +9 -4
  15. package/dist/esm/session/authStateStore.js +8 -0
  16. package/dist/esm/session/refresh.js +109 -37
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/HttpService.d.ts +21 -0
  19. package/dist/types/boot/sessionColdBoot.d.ts +7 -3
  20. package/dist/types/index.d.ts +3 -3
  21. package/dist/types/session/SessionClient.d.ts +26 -4
  22. package/dist/types/session/authStateStore.d.ts +15 -1
  23. package/dist/types/session/refresh.d.ts +67 -31
  24. package/package.json +1 -1
  25. package/src/HttpService.ts +31 -0
  26. package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
  27. package/src/boot/sessionColdBoot.ts +93 -74
  28. package/src/crypto/keyManager.ts +42 -15
  29. package/src/index.ts +3 -2
  30. package/src/session/SessionClient.ts +35 -7
  31. package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
  32. package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
  33. package/src/session/__tests__/authStateStore.test.ts +64 -6
  34. package/src/session/__tests__/refresh.test.ts +141 -2
  35. package/src/session/authStateStore.ts +23 -1
  36. package/src/session/refresh.ts +146 -40
@@ -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. `device-secret-mint` (web + native) — the zero-cookie transport: when the
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
- * 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
18
- * 3. Signed out.
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
- function classifyMintFailure(error) {
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. device-secret-mint (web + native) — the zero-cookie fast path. When the
51
- // origin persisted a deviceId + deviceSecret, mint a short access token with
52
- // a single bearer-less POST (no cookie, no navigation).
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: 'device-secret-mint',
54
+ id: 'warm-token-plant',
55
55
  run: async () => {
56
56
  const persisted = await store.load();
57
- if (!persisted?.deviceId || !persisted?.deviceSecret) {
57
+ if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
58
58
  return { kind: 'skip' };
59
59
  }
60
- try {
61
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
62
- // Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
63
- // fields, + the server's authoritative active account) BEFORE planting
64
- // the minted access token, so a multi-tab race that rotates again can
65
- // never strand this tab with a superseded secret.
66
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
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
- catch (error) {
83
- const failure = classifyMintFailure(error);
84
- if (failure === 'invalid_secret') {
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.save({ ...persisted, deviceSecret: undefined });
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
- if (failure === 'no_active_session') {
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
- // Transient (network / 5xx): keep the secret; a later attempt can succeed.
98
- loggerUtils_1.logger.debug('device-secret mint failed (transient) keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' }, error);
99
- return { kind: 'skip' };
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
- // 2. shared-key-signin (native) — re-mint from the shared identity.
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: Store in secure store (accessible via sharedUserId)
238
- // Note: All Oxy apps must have the same sharedUserId in AndroidManifest.xml
239
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
240
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
241
- });
242
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
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
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
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
- privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
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
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
381
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
382
- });
383
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
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; } });
@@ -84,7 +84,7 @@ class SessionClient {
84
84
  }
85
85
  }
86
86
  /** Validate + last-writer-wins by revision. Returns true if applied. */
87
- applyState(raw) {
87
+ applyState(raw, origin = 'push') {
88
88
  const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
89
89
  if (!next) {
90
90
  loggerUtils_1.logger.warn('[SessionClient] discarded invalid session state');
@@ -108,7 +108,7 @@ class SessionClient {
108
108
  this.notify();
109
109
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
110
110
  try {
111
- this.options.onUnauthenticated();
111
+ this.options.onUnauthenticated(origin);
112
112
  }
113
113
  catch (error) {
114
114
  loggerUtils_1.logger.error('[SessionClient] onUnauthenticated threw', error);
@@ -154,7 +154,10 @@ class SessionClient {
154
154
  loggerUtils_1.logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
155
155
  return;
156
156
  }
157
- this.applyState(sync.state);
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');
158
161
  if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
159
162
  this.host.setTokens(sync.activeToken.accessToken);
160
163
  }
@@ -292,7 +295,9 @@ class SessionClient {
292
295
  },
293
296
  });
294
297
  socket.on('session_state', (payload) => {
295
- const applied = this.applyState(payload);
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');
296
301
  if (!applied)
297
302
  return;
298
303
  // A push changed the active account on another device/tab — re-fetch state
@@ -200,7 +200,9 @@ function createMemoryAuthStateStore() {
200
200
  return {
201
201
  load: async () => current,
202
202
  save: async (state) => {
203
+ // Memory IS this store's durability backing — the write always lands.
203
204
  current = state;
205
+ return true;
204
206
  },
205
207
  clear: async () => {
206
208
  current = null;
@@ -293,6 +295,9 @@ function createWebAuthStateStore() {
293
295
  catch {
294
296
  // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
295
297
  }
298
+ // Report ONLY the durable-credential landing; the warm-token outcome above
299
+ // is intentionally excluded (it is a best-effort optimization).
300
+ return durablePersisted;
296
301
  },
297
302
  clear: async () => {
298
303
  sessionMirror = null;
@@ -376,6 +381,9 @@ function createNativeAuthStateStore(storage) {
376
381
  catch {
377
382
  // Locked / oversize keychain — non-fatal warm-boot loss only.
378
383
  }
384
+ // Report ONLY the durable-credential landing; the warm-token outcome above
385
+ // is intentionally excluded (it is a best-effort optimization).
386
+ return durablePersisted;
379
387
  },
380
388
  clear: async () => {
381
389
  sessionMirror = null;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TOKEN_REFRESH_LEAD_MS = void 0;
4
+ exports.refreshDeviceSecretArm = refreshDeviceSecretArm;
4
5
  exports.refreshPersistedSession = refreshPersistedSession;
5
6
  exports.createAuthRefreshHandler = createAuthRefreshHandler;
6
7
  exports.installAuthRefreshHandler = installAuthRefreshHandler;
@@ -37,64 +38,136 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
37
38
  */
38
39
  const MIN_FAILURE_BACKOFF_MS = 5000;
39
40
  const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
41
+ /**
42
+ * Arm 1 — the rotating device-secret mint, run under the owning client's
43
+ * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
44
+ *
45
+ * The server ROTATES the presented `deviceSecret` on every successful mint and
46
+ * the just-presented secret is valid only for a short grace window. If two lanes
47
+ * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
48
+ * the socket token transport, or a tab-focus reconcile) minted concurrently they
49
+ * would double-rotate the server and the durable store could converge on the
50
+ * SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
51
+ * user is signed out. Routing EVERY lane through this one single-flight makes
52
+ * concurrent callers await the SAME in-flight mint and all receive its result, so
53
+ * there is exactly one rotation and the store always converges on the true
54
+ * `current` secret.
55
+ *
56
+ * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
57
+ * the access token; a failed durable persist yields `persist-failed` WITHOUT
58
+ * planting. This function performs NO store mutation on failure — the caller
59
+ * applies the drop/clear policy (which differs web vs native) from the returned
60
+ * status.
61
+ */
62
+ async function refreshDeviceSecretArm(deps) {
63
+ const { oxy, store } = deps;
64
+ return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
65
+ const persisted = await store.load();
66
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
67
+ return { status: 'no-secret' };
68
+ }
69
+ let mint;
70
+ try {
71
+ mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
72
+ }
73
+ catch (error) {
74
+ if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
75
+ // Structural read (not `instanceof Error`): the thrown value can be a
76
+ // plain ApiError-shaped object or come from another realm.
77
+ const message = error?.message;
78
+ return typeof message === 'string' && message.includes('no_active_session')
79
+ ? { status: 'no-session' }
80
+ : { status: 'invalid-secret' };
81
+ }
82
+ return { status: 'transient' };
83
+ }
84
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
85
+ const next = {
86
+ ...persisted,
87
+ deviceId: mint.state.deviceId,
88
+ deviceSecret: mint.nextDeviceSecret,
89
+ accessToken: mint.accessToken,
90
+ expiresAt: mint.expiresAt,
91
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
92
+ };
93
+ // Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
94
+ // landed BEFORE planting the token. A failed durable persist must NOT plant.
95
+ const persistedOk = await store.save(next);
96
+ if (!persistedOk) {
97
+ return { status: 'persist-failed' };
98
+ }
99
+ oxy.setTokens(mint.accessToken);
100
+ return { status: 'ok', token: mint.accessToken, sessionId: next.sessionId, userId: next.userId };
101
+ });
102
+ }
40
103
  /**
41
104
  * Re-mint the persisted session and return the fresh access token, or `null`
42
105
  * when no arm could produce one.
43
106
  *
44
- * Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
45
- * `deviceSecret`, mint on success plant + persist the rotated secret. A 401
46
- * means the secret is diverged (`invalid_device_secret`) or the device has no
47
- * live session (`no_active_session`): drop the secret so the mint lane stops (or
48
- * clear the store on web, where there is no fallback). A transient error leaves
49
- * the store and returns `null`.
107
+ * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
108
+ * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
109
+ * or the device has no live session: drop the secret so the mint lane stops (or
110
+ * clear the store on web, where there is no fallback), then fall to arm 2 on
111
+ * native. A transient error or a durable-persist failure leaves the store and
112
+ * returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
50
113
  *
51
114
  * Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
52
- * re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
53
- * keychain not the per-origin store is the durable native credential, so this
54
- * arm does not write the store.
115
+ * re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
116
+ * recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
117
+ * lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
118
+ * in-session shared-key recovery must not leave the fast-lane credential empty.
55
119
  */
56
120
  async function refreshPersistedSession(deps) {
57
121
  const { oxy, store } = deps;
58
122
  const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? (0, platform_1.isNative)();
59
- const persisted = await store.load();
60
- if (persisted?.deviceId && persisted?.deviceSecret) {
61
- try {
62
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
63
- oxy.setTokens(mint.accessToken);
64
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
65
- const next = {
66
- ...persisted,
67
- deviceId: mint.state.deviceId,
68
- deviceSecret: mint.nextDeviceSecret,
69
- accessToken: mint.accessToken,
70
- expiresAt: mint.expiresAt,
71
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
72
- };
73
- await store.save(next);
74
- return mint.accessToken;
75
- }
76
- catch (error) {
77
- if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
78
- // Secret diverged / no active session. On a shared-key device drop only
79
- // the secret so arm 2 below can recover; otherwise the session is over.
80
- if (allowSharedKeyFallback) {
123
+ const arm1 = await refreshDeviceSecretArm({ oxy, store });
124
+ switch (arm1.status) {
125
+ case 'ok':
126
+ return arm1.token;
127
+ case 'transient':
128
+ loggerUtils_1.logger.debug('Persisted deviceSecret mint failed (transient) keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
129
+ return null;
130
+ case 'persist-failed':
131
+ // The server rotated the secret but it did not durably persist. Do NOT fall
132
+ // to shared-key and do NOT plant — a later attempt re-mints (the process
133
+ // mirror still holds the rotated secret the server accepts) and can persist
134
+ // once storage recovers. Never advertise a session on an unsaved secret.
135
+ loggerUtils_1.logger.error('Device-secret mint rotated the secret but it could not be durably persisted — refusing to plant (a later attempt re-mints)', undefined, { component: 'refresh', method: 'refreshPersistedSession' });
136
+ return null;
137
+ case 'invalid-secret':
138
+ case 'no-session': {
139
+ // 401: secret diverged or no live session. On a shared-key device drop only
140
+ // the secret (keep the identity so arm 2 can recover); otherwise (web) the
141
+ // session is over clear the store.
142
+ const persisted = await store.load();
143
+ if (allowSharedKeyFallback) {
144
+ if (persisted) {
81
145
  await store.save({ ...persisted, deviceSecret: undefined });
82
146
  }
83
- else {
84
- await store.clear();
85
- }
86
- // Fall through to the native shared-key arm.
87
147
  }
88
148
  else {
89
- loggerUtils_1.logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' }, error);
90
- return null;
149
+ await store.clear();
91
150
  }
151
+ break;
92
152
  }
153
+ case 'no-secret':
154
+ break;
93
155
  }
94
156
  if (allowSharedKeyFallback) {
95
157
  try {
96
158
  const session = await oxy.signInWithSharedIdentity();
97
159
  if (session?.accessToken) {
160
+ // Repopulate the fast device-secret lane from the shared-key re-mint.
161
+ if (session.deviceId && session.deviceSecret) {
162
+ await store.save({
163
+ sessionId: session.sessionId,
164
+ userId: session.user.id,
165
+ deviceId: session.deviceId,
166
+ deviceSecret: session.deviceSecret,
167
+ accessToken: session.accessToken,
168
+ expiresAt: session.expiresAt,
169
+ });
170
+ }
98
171
  return session.accessToken;
99
172
  }
100
173
  }