@oxy.so/core 1.9.0 → 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 (45) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +112 -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 +71 -7
  9. package/dist/cjs/utils/errorUtils.js +1 -1
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/HttpService.js +112 -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 +70 -6
  17. package/dist/esm/utils/errorUtils.js +1 -1
  18. package/dist/types/.tsbuildinfo +1 -1
  19. package/dist/types/HttpService.d.ts +59 -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 +21 -0
  24. package/dist/types/utils/errorUtils.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +116 -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/__tests__/nearExpiryMintStorm.test.ts +165 -0
  33. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  34. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  35. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  36. package/src/boot/sessionColdBoot.ts +3 -0
  37. package/src/mixins/OxyServices.user.ts +2 -5
  38. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  39. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  40. package/src/mixins/__tests__/topics.test.ts +2 -3
  41. package/src/server/cors.ts +0 -1
  42. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  43. package/src/session/__tests__/refresh.test.ts +115 -2
  44. package/src/session/refresh.ts +77 -6
  45. package/src/utils/errorUtils.ts +1 -1
@@ -82,7 +82,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
82
82
  overrides.requestChallenge
83
83
  ?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
84
84
  verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
85
- httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
85
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
86
86
  } as unknown as OxyServices;
87
87
  return { oxy, setTokens };
88
88
  }
@@ -59,8 +59,18 @@ interface RefreshMockOverrides {
59
59
 
60
60
  function makeOxy(
61
61
  overrides: RefreshMockOverrides = {},
62
- ): { oxy: OxyServices; setTokens: jest.Mock; noteRefreshRateLimited: jest.Mock } {
62
+ ): {
63
+ oxy: OxyServices;
64
+ setTokens: jest.Mock;
65
+ noteRefreshRateLimited: jest.Mock;
66
+ /** `HttpService`'s session epoch; bump it to simulate a sign-out mid-refresh. */
67
+ epoch: { current: number };
68
+ /** `HttpService.hasSessionEnded()`: a sign-out with no token planted since. */
69
+ ended: { current: boolean };
70
+ } {
63
71
  const setTokens = jest.fn();
72
+ const epoch = { current: 0 };
73
+ const ended = { current: false };
64
74
  const noteRefreshRateLimited = jest.fn();
65
75
  const oxy = {
66
76
  setTokens,
@@ -71,9 +81,11 @@ function makeOxy(
71
81
  httpService: {
72
82
  runSingleFlightDeviceSecretMint: makeMintSingleFlight(),
73
83
  noteRefreshRateLimited,
84
+ getSessionEpoch: () => epoch.current,
85
+ hasSessionEnded: () => ended.current,
74
86
  },
75
87
  } as unknown as OxyServices;
76
- return { oxy, setTokens, noteRefreshRateLimited };
88
+ return { oxy, setTokens, noteRefreshRateLimited, epoch, ended };
77
89
  }
78
90
 
79
91
  describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
@@ -297,6 +309,107 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
297
309
  });
298
310
  });
299
311
 
312
+ describe('refreshPersistedSession — a sign-out mid-refresh', () => {
313
+ it('arm 1: plants nothing and leaves a cleared store cleared', async () => {
314
+ const store = createMemoryAuthStateStore();
315
+ await store.save(STORED);
316
+ let respond!: () => void;
317
+ const { oxy, setTokens, epoch } = makeOxy({
318
+ mintFromDeviceSecret: () =>
319
+ new Promise((resolve) => {
320
+ respond = () => resolve(MINT);
321
+ }),
322
+ });
323
+
324
+ const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
325
+ await new Promise((resolve) => setTimeout(resolve, 0));
326
+ // The sign-out: the credential is dropped and the session epoch moves.
327
+ await store.clear();
328
+ epoch.current += 1;
329
+ respond();
330
+
331
+ await expect(pending).resolves.toBeNull();
332
+ expect(setTokens).not.toHaveBeenCalled();
333
+ expect(await store.load()).toBeNull();
334
+ });
335
+
336
+ it('arm 1: still persists the rotated secret when the store kept the credential', async () => {
337
+ const store = createMemoryAuthStateStore();
338
+ await store.save(STORED);
339
+ let respond!: () => void;
340
+ const { oxy, setTokens, epoch } = makeOxy({
341
+ mintFromDeviceSecret: () =>
342
+ new Promise((resolve) => {
343
+ respond = () => resolve(MINT);
344
+ }),
345
+ });
346
+
347
+ const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
348
+ await new Promise((resolve) => setTimeout(resolve, 0));
349
+ // A local teardown that keeps the store (so a reload can restore): the
350
+ // server rotated the secret, and the store must not be left holding the old one.
351
+ epoch.current += 1;
352
+ respond();
353
+
354
+ await expect(pending).resolves.toBeNull();
355
+ expect(setTokens).not.toHaveBeenCalled();
356
+ expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
357
+ });
358
+
359
+ it('arm 2: the shared keychain does not sign the user back in', async () => {
360
+ const store = createMemoryAuthStateStore();
361
+ let respond!: () => void;
362
+ const signInWithSharedIdentity = jest.fn(
363
+ (_opts?: { plantTokens?: boolean }) =>
364
+ new Promise<SessionLoginResponse>((resolve) => {
365
+ respond = () =>
366
+ resolve({
367
+ sessionId: 'sess-shared',
368
+ deviceId: 'dev-1',
369
+ deviceSecret: 'ds-shared',
370
+ expiresAt: '2030-01-01T00:00:00.000Z',
371
+ user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
372
+ accessToken: 'access-shared',
373
+ });
374
+ }),
375
+ );
376
+ const { oxy, setTokens, epoch } = makeOxy({ signInWithSharedIdentity });
377
+
378
+ const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
379
+ await new Promise((resolve) => setTimeout(resolve, 0));
380
+ epoch.current += 1;
381
+ respond();
382
+
383
+ await expect(pending).resolves.toBeNull();
384
+ expect(signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
385
+ expect(setTokens).not.toHaveBeenCalled();
386
+ expect(await store.load()).toBeNull();
387
+ });
388
+ });
389
+
390
+ describe('refreshPersistedSession — after a sign-out', () => {
391
+ it('does not re-sign-in with the shared keychain', async () => {
392
+ const store = createMemoryAuthStateStore();
393
+ const signInWithSharedIdentity = jest.fn(async () => null);
394
+ const { oxy, setTokens, ended } = makeOxy({ signInWithSharedIdentity });
395
+ ended.current = true;
396
+
397
+ await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBeNull();
398
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
399
+ expect(setTokens).not.toHaveBeenCalled();
400
+ });
401
+
402
+ it('still mints from a device credential the store holds (a sign-in made in another tab)', async () => {
403
+ const store = createMemoryAuthStateStore();
404
+ await store.save(STORED);
405
+ const { oxy, setTokens, ended } = makeOxy();
406
+ ended.current = true;
407
+
408
+ await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBe('access-new');
409
+ expect(setTokens).toHaveBeenCalledWith('access-new');
410
+ });
411
+ });
412
+
300
413
  describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
301
414
  it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
302
415
  // The server rotates the secret on every mint. Two concurrent lanes must
@@ -63,6 +63,21 @@ const MIN_SCHEDULE_DELAY_MS = 1_000;
63
63
  const MIN_FAILURE_BACKOFF_MS = 5_000;
64
64
  const MAX_FAILURE_BACKOFF_MS = 5 * 60_000;
65
65
 
66
+ /**
67
+ * How long past `exp` to re-mint when a successful refresh handed back a token
68
+ * that is STILL inside the lead window.
69
+ *
70
+ * The device mint returns the session's stored access token until that token
71
+ * has actually expired, so asking again before `exp` only returns the same
72
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
73
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
74
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
75
+ * the 429 that followed straddled the real expiry. Waiting until just past
76
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
77
+ * the server's strict `exp < now` comparison and a little clock skew.
78
+ */
79
+ export const REMINT_AFTER_EXPIRY_MS = 2_000;
80
+
66
81
  export interface RefreshDeps {
67
82
  oxy: OxyServices;
68
83
  store: AuthStateStore;
@@ -99,6 +114,11 @@ export interface RefreshDeps {
99
114
  * The device secret is FINE — it is the identity binding that went stale, so
100
115
  * the caller must re-establish from the local key, never drop the credential.
101
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.
102
122
  * - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
103
123
  * durably persisted. The token is deliberately NOT planted: advertising a
104
124
  * healthy session on a secret that will not survive a reload is exactly the
@@ -111,6 +131,7 @@ export type DeviceSecretMintOutcome =
111
131
  | { status: 'no-session' }
112
132
  | { status: 'account-not-on-device' }
113
133
  | { status: 'transient' }
134
+ | { status: 'session-ended' }
114
135
  | { status: 'persist-failed' };
115
136
 
116
137
  /**
@@ -144,6 +165,7 @@ export async function refreshDeviceSecretArm(deps: {
144
165
  const { oxy, store } = deps;
145
166
  const pin = deps.pin ?? null;
146
167
  return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
168
+ const epoch = oxy.httpService.getSessionEpoch();
147
169
  const persisted = await store.load();
148
170
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
149
171
  return { status: 'no-secret' };
@@ -207,12 +229,26 @@ export async function refreshDeviceSecretArm(deps: {
207
229
  expiresAt: mint.expiresAt,
208
230
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
209
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
+ }
210
243
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
211
244
  // A failed durable persist must NOT plant.
212
245
  const persistedOk = await store.save(next);
213
246
  if (!persistedOk) {
214
247
  return { status: 'persist-failed' };
215
248
  }
249
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
250
+ return { status: 'session-ended' };
251
+ }
216
252
  oxy.setTokens(mint.accessToken);
217
253
  return {
218
254
  status: 'ok',
@@ -251,6 +287,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
251
287
  const identity = deps.identity ?? null;
252
288
  // The shared keychain is never an identity-bound client's recovery path.
253
289
  const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
290
+ const epoch = oxy.httpService.getSessionEpoch();
254
291
  // Resolved per call: a re-established identity session can move the pin, and a
255
292
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
256
293
  // an unpinned mint would adopt whatever account the device switched to).
@@ -263,6 +300,8 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
263
300
  switch (arm1.status) {
264
301
  case 'ok':
265
302
  return arm1.token;
303
+ case 'session-ended':
304
+ return null;
266
305
  case 'transient':
267
306
  logger.debug(
268
307
  'Persisted deviceSecret mint failed (transient) — keeping store',
@@ -312,10 +351,18 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
312
351
  return recoverIdentitySession(oxy, store, identity);
313
352
  }
314
353
 
315
- 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()) {
316
357
  try {
317
- 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 });
318
362
  if (session?.accessToken) {
363
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
364
+ return null;
365
+ }
319
366
  // Repopulate the fast device-secret lane from the shared-key re-mint.
320
367
  if (session.deviceId && session.deviceSecret) {
321
368
  await store.save({
@@ -327,6 +374,10 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
327
374
  expiresAt: session.expiresAt,
328
375
  });
329
376
  }
377
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
378
+ return null;
379
+ }
380
+ oxy.setTokens(session.accessToken);
330
381
  return session.accessToken;
331
382
  }
332
383
  } catch (error) {
@@ -447,13 +498,27 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
447
498
  if (expSeconds === null) {
448
499
  return;
449
500
  }
450
- armTimer(expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS);
501
+ const untilExpiryMs = expSeconds * 1000 - Date.now();
502
+ // The server already answered this token's refresh with this very token:
503
+ // asking again before `exp` only gets it back again. Ask just after expiry,
504
+ // when the server rotates (see REMINT_AFTER_EXPIRY_MS).
505
+ if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
506
+ armTimer(untilExpiryMs + REMINT_AFTER_EXPIRY_MS);
507
+ return;
508
+ }
509
+ armTimer(untilExpiryMs - TOKEN_REFRESH_LEAD_MS);
451
510
  };
452
511
 
453
512
  const runRefresh = (): void => {
454
513
  // Clear any pending timer up front so an out-of-band trigger (focus) plus
455
514
  // a fired timer can never double-run.
456
515
  clearTimer();
516
+ // Another lane (a request-time preflight) already asked, and the server
517
+ // answered with this same token: re-arm for just past expiry instead.
518
+ if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
519
+ scheduleFromExpiry();
520
+ return;
521
+ }
457
522
  void oxy.httpService.refreshAccessToken('preflight')
458
523
  .then((token) => Boolean(token))
459
524
  .catch(() => false)
@@ -498,10 +563,16 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
498
563
  }
499
564
  };
500
565
 
501
- const unsubscribeTokens = oxy.onTokensChanged(() => {
502
- if (!disposed) {
503
- schedule();
566
+ // Re-arm only on a token that actually CHANGED. The mint plants the token it
567
+ // returns even when that is the token already held, and treating the repeat
568
+ // as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
569
+ let lastSeenToken = oxy.getAccessToken();
570
+ const unsubscribeTokens = oxy.onTokensChanged((token) => {
571
+ if (disposed || token === lastSeenToken) {
572
+ return;
504
573
  }
574
+ lastSeenToken = token;
575
+ schedule();
505
576
  });
506
577
 
507
578
  let removeFocusListener: (() => void) | null = null;
@@ -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`.