@oxy.so/core 1.9.1 → 1.10.0

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 (44) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +56 -163
  4. package/dist/cjs/OxyServices.base.js +4 -2
  5. package/dist/cjs/boot/sessionColdBoot.js +3 -0
  6. package/dist/cjs/mixins/OxyServices.user.js +2 -5
  7. package/dist/cjs/server/cors.js +0 -1
  8. package/dist/cjs/session/refresh.js +32 -2
  9. package/dist/cjs/utils/errorUtils.js +1 -1
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/HttpService.js +56 -163
  12. package/dist/esm/OxyServices.base.js +4 -2
  13. package/dist/esm/boot/sessionColdBoot.js +3 -0
  14. package/dist/esm/mixins/OxyServices.user.js +2 -5
  15. package/dist/esm/server/cors.js +0 -1
  16. package/dist/esm/session/refresh.js +32 -2
  17. package/dist/esm/utils/errorUtils.js +1 -1
  18. package/dist/types/.tsbuildinfo +1 -1
  19. package/dist/types/HttpService.d.ts +31 -7
  20. package/dist/types/OxyServices.base.d.ts +3 -1
  21. package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
  22. package/dist/types/mixins/OxyServices.user.d.ts +1 -4
  23. package/dist/types/session/refresh.d.ts +7 -0
  24. package/dist/types/utils/errorUtils.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +59 -187
  27. package/src/OxyServices.base.ts +4 -2
  28. package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
  29. package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
  30. package/src/__tests__/inSessionRefresh.test.ts +53 -0
  31. package/src/__tests__/linkedClient.test.ts +0 -7
  32. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  33. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  34. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  35. package/src/boot/sessionColdBoot.ts +3 -0
  36. package/src/mixins/OxyServices.user.ts +2 -5
  37. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  38. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  39. package/src/mixins/__tests__/topics.test.ts +2 -3
  40. package/src/server/cors.ts +0 -1
  41. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  42. package/src/session/__tests__/refresh.test.ts +115 -2
  43. package/src/session/refresh.ts +38 -2
  44. package/src/utils/errorUtils.ts +1 -1
@@ -114,6 +114,11 @@ export interface RefreshDeps {
114
114
  * The device secret is FINE — it is the identity binding that went stale, so
115
115
  * the caller must re-establish from the local key, never drop the credential.
116
116
  * - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
117
+ * - `session-ended` — the mint succeeded, but the local session was ended
118
+ * (`HttpService.endSession`, i.e. a sign-out) while it was in flight. Nothing
119
+ * is planted. The rotated secret is persisted only if the store still holds
120
+ * the credential that was presented — a store the sign-out cleared stays
121
+ * clear.
117
122
  * - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
118
123
  * durably persisted. The token is deliberately NOT planted: advertising a
119
124
  * healthy session on a secret that will not survive a reload is exactly the
@@ -126,6 +131,7 @@ export type DeviceSecretMintOutcome =
126
131
  | { status: 'no-session' }
127
132
  | { status: 'account-not-on-device' }
128
133
  | { status: 'transient' }
134
+ | { status: 'session-ended' }
129
135
  | { status: 'persist-failed' };
130
136
 
131
137
  /**
@@ -159,6 +165,7 @@ export async function refreshDeviceSecretArm(deps: {
159
165
  const { oxy, store } = deps;
160
166
  const pin = deps.pin ?? null;
161
167
  return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
168
+ const epoch = oxy.httpService.getSessionEpoch();
162
169
  const persisted = await store.load();
163
170
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
164
171
  return { status: 'no-secret' };
@@ -222,12 +229,26 @@ export async function refreshDeviceSecretArm(deps: {
222
229
  expiresAt: mint.expiresAt,
223
230
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
224
231
  };
232
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
233
+ // Signed out while minting. The server has rotated the secret, so a store
234
+ // that still holds the presented one must learn the rotation (the
235
+ // token-null lane keeps the store so a reload can restore); a store the
236
+ // sign-out cleared must NOT be refilled. Either way, plant nothing.
237
+ const current = await store.load();
238
+ if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
239
+ await store.save(next);
240
+ }
241
+ return { status: 'session-ended' };
242
+ }
225
243
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
226
244
  // A failed durable persist must NOT plant.
227
245
  const persistedOk = await store.save(next);
228
246
  if (!persistedOk) {
229
247
  return { status: 'persist-failed' };
230
248
  }
249
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
250
+ return { status: 'session-ended' };
251
+ }
231
252
  oxy.setTokens(mint.accessToken);
232
253
  return {
233
254
  status: 'ok',
@@ -266,6 +287,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
266
287
  const identity = deps.identity ?? null;
267
288
  // The shared keychain is never an identity-bound client's recovery path.
268
289
  const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
290
+ const epoch = oxy.httpService.getSessionEpoch();
269
291
  // Resolved per call: a re-established identity session can move the pin, and a
270
292
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
271
293
  // an unpinned mint would adopt whatever account the device switched to).
@@ -278,6 +300,8 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
278
300
  switch (arm1.status) {
279
301
  case 'ok':
280
302
  return arm1.token;
303
+ case 'session-ended':
304
+ return null;
281
305
  case 'transient':
282
306
  logger.debug(
283
307
  'Persisted deviceSecret mint failed (transient) — keeping store',
@@ -327,10 +351,18 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
327
351
  return recoverIdentitySession(oxy, store, identity);
328
352
  }
329
353
 
330
- if (allowSharedKeyFallback) {
354
+ // Never after a sign-out: the shared keychain holds an identity KEY, not a
355
+ // session, and using it here would sign the user straight back in.
356
+ if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
331
357
  try {
332
- const session = await oxy.signInWithSharedIdentity();
358
+ // Planted here, not by the sign-in: a sign-out that lands while the
359
+ // challenge round-trips must win, or the shared keychain signs the user
360
+ // straight back in.
361
+ const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
333
362
  if (session?.accessToken) {
363
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
364
+ return null;
365
+ }
334
366
  // Repopulate the fast device-secret lane from the shared-key re-mint.
335
367
  if (session.deviceId && session.deviceSecret) {
336
368
  await store.save({
@@ -342,6 +374,10 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
342
374
  expiresAt: session.expiresAt,
343
375
  });
344
376
  }
377
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
378
+ return null;
379
+ }
380
+ oxy.setTokens(session.accessToken);
345
381
  return session.accessToken;
346
382
  }
347
383
  } catch (error) {
@@ -121,7 +121,7 @@ const nonEmptyString = (value: unknown): string | undefined =>
121
121
  * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
122
122
  * OAuth token and userinfo endpoints. `error_description` is the human text
123
123
  * and `error` is the machine code, so both survive.
124
- * - `{ message, code }` — e.g. the API's CSRF rejections.
124
+ * - `{ message, code }` — e.g. the API's middleware rejections.
125
125
  * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
126
126
  * `message`/`error_description` the string is the message, not a code: a bare
127
127
  * `error` string is not machine-readable enough to promote to `code`.