@oh-my-pi/pi-ai 18.13.0 → 18.13.2

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.
@@ -1,4 +1,4 @@
1
- import { logger, postmortem } from "@oh-my-pi/pi-utils";
1
+ import { isSqliteBusyError, logger, postmortem } from "@oh-my-pi/pi-utils";
2
2
  import * as AIError from "../error";
3
3
  import { getOAuthProvider, normalizeOAuthCredentialExpiry, refreshOAuthToken } from "../registry/oauth";
4
4
  import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
@@ -6,7 +6,12 @@ import type { Provider } from "../types";
6
6
  import { raceSignal } from "./abort";
7
7
  import { authCredentialEquals, type CredentialPool, credentialDisabledEvent } from "./pool";
8
8
  import { resolveCredentialIdentityKey, serializeCredential } from "./sqlite-credential-store";
9
- import { type AuthCredentialStore, type CredentialRefreshLeaseStore, hasRefreshLeases } from "./store";
9
+ import {
10
+ type AuthCredentialStore,
11
+ type CredentialRefreshLeaseHolder,
12
+ type CredentialRefreshLeaseStore,
13
+ hasRefreshLeases,
14
+ } from "./store";
10
15
  import {
11
16
  REMOTE_REFRESH_SENTINEL,
12
17
  type AuthCredentialSnapshotEntry,
@@ -33,13 +38,16 @@ import {
33
38
  */
34
39
  export const OAUTH_REFRESH_SKEW_MS = 60_000;
35
40
  /**
36
- * How long auth recovery may reuse a token this refresher actually minted.
41
+ * How long auth recovery may reuse a token minted moments ago — by this
42
+ * refresher or, through the shared store, by another process.
37
43
  * Re-minting a token that a provider rejected moments ago only rotates the
38
- * refresh token and churns broker snapshots during provider-wide outages.
44
+ * refresh token, revokes the access token every peer holds, and churns broker
45
+ * snapshots during provider-wide outages.
39
46
  */
40
47
  const OAUTH_REMINT_COOLDOWN_MS = 5 * 60_000;
41
48
  const OAUTH_REFRESH_LEASE_TTL_MS = 15_000;
42
49
  const OAUTH_REFRESH_LEASE_POLL_MS = 50;
50
+ const OAUTH_REFRESH_LEASE_WAIT_MAX_MS = 250;
43
51
  const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
44
52
  /** How long a caller waits for an OAuth token exchange before giving up. */
45
53
  const OAUTH_REFRESH_OPERATION_TIMEOUT_MS = 10_000;
@@ -53,6 +61,31 @@ const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
53
61
  * peers waiting on the lease still recover within a minute.
54
62
  */
55
63
  const OAUTH_REFRESH_EXCHANGE_BACKSTOP_MS = 60_000;
64
+ /**
65
+ * How long a refresher keeps retrying a busy store to save a token it already
66
+ * exchanged. The provider has consumed the old refresh token by then, so
67
+ * giving up loses the login for every process sharing the store.
68
+ */
69
+ const OAUTH_REFRESH_PERSIST_TIMEOUT_MS = 60_000;
70
+ const OAUTH_REFRESH_PERSIST_RETRY_MAX_MS = 1_000;
71
+ /**
72
+ * How long after another refresher's lease lapsed that refresher may still be
73
+ * saving a token it exchanged: its exchange backstop plus its save window.
74
+ */
75
+ const OAUTH_REFRESH_STALLED_OWNER_MS = OAUTH_REFRESH_EXCHANGE_BACKSTOP_MS + OAUTH_REFRESH_PERSIST_TIMEOUT_MS;
76
+ /**
77
+ * How long process exit waits for refreshes still in flight: an exchange up to
78
+ * its backstop, then the save window, plus slack for the last save attempt. A
79
+ * shorter wait drops a token the provider already rotated, which leaves the
80
+ * shared store with a spent refresh token.
81
+ */
82
+ const OAUTH_REFRESH_SHUTDOWN_WAIT_MS = OAUTH_REFRESH_STALLED_OWNER_MS + 5_000;
83
+
84
+ /** Short non-secret fingerprint so logs can tell which process minted which token. */
85
+ function tokenFingerprint(token: unknown): string {
86
+ if (typeof token !== "string" || token.length === 0) return "none";
87
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex").slice(0, 8);
88
+ }
56
89
 
57
90
  type LeasedExchangeOutcome<T extends OAuthCredential> = { result: StoredOAuthRefreshResult<T> } | { failure: unknown };
58
91
 
@@ -114,6 +147,11 @@ export class OAuthRefresher {
114
147
  #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
115
148
  /** Access token this process last minted per credential id. */
116
149
  #recentMints = new Map<number, { provider: string; access: string; at: number }>();
150
+ /**
151
+ * Refresh tokens rejected after taking over a stalled refresher's lease, per
152
+ * credential id: that refresher may still save their successor until `until`.
153
+ */
154
+ #stalledRefreshes = new Map<number, { refresh: string; until: number }>();
117
155
  #pendingRefreshes = new Set<Promise<unknown>>();
118
156
  #cancelRefreshCleanup?: () => void;
119
157
  #shuttingDown = false;
@@ -161,6 +199,154 @@ export class OAuthRefresher {
161
199
  : this.#deps.store.refreshOAuthCredential === undefined;
162
200
  }
163
201
 
202
+ /**
203
+ * Whether the stored row holds a token the caller has not seen rejected, so
204
+ * it adopts that token instead of exchanging again. A forced refresh
205
+ * compares tokens only (callers pass an `expires: 0` copy), and compares
206
+ * against the access token a provider 401 rejected when the caller names
207
+ * it: this process may already hold the row a peer rotated, while the token
208
+ * that got the 401 is the one before it. Callers name the credential's own
209
+ * access token, never the API key built from it, which a provider with a
210
+ * structured key format makes a different string.
211
+ */
212
+ #storedIsNewer<T extends OAuthCredential>(current: T, options: StoredOAuthRefreshOptions<T>): boolean {
213
+ if (options.rejectedAccess !== undefined) return current.access !== options.rejectedAccess;
214
+ const observed = options.observedCredential;
215
+ if (!observed) return false;
216
+ return options.forceRefresh
217
+ ? current.access !== observed.access || current.refresh !== observed.refresh
218
+ : !authCredentialEquals(current, observed);
219
+ }
220
+
221
+ /**
222
+ * Hand a taken-over lease back to the refresher that stalled holding it. A busy
223
+ * store is retried within the save window: if this process released the lease
224
+ * instead, the next refresher would find no owner and disable a login that
225
+ * owner may still save. Returns false when the hand-back never landed; the
226
+ * caller then keeps its own lease to expire, which still names a recent owner.
227
+ */
228
+ async #handBackLease(
229
+ leaseStore: CredentialRefreshLeaseStore,
230
+ credentialId: number,
231
+ owner: string,
232
+ holder: CredentialRefreshLeaseHolder,
233
+ ): Promise<boolean> {
234
+ const deadline = Date.now() + OAUTH_REFRESH_PERSIST_TIMEOUT_MS;
235
+ for (let attempt = 0; ; attempt++) {
236
+ try {
237
+ return leaseStore.handBackCredentialRefreshLease?.(credentialId, owner, holder) ?? false;
238
+ } catch (error) {
239
+ if (!isSqliteBusyError(error)) throw error;
240
+ if (Date.now() >= deadline) return false;
241
+ }
242
+ await Bun.sleep(Math.min(OAUTH_REFRESH_PERSIST_RETRY_MAX_MS, OAUTH_REFRESH_LEASE_POLL_MS * 2 ** attempt));
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Give up a lease this refresher holds without having saved a token. A lease
248
+ * taken over from a stalled refresher goes back to it, because that refresher
249
+ * may still be saving: releasing it would leave the next refresher no owner,
250
+ * and it would disable a login that may live. Any other lease is released.
251
+ */
252
+ async #leaveLease(
253
+ leaseStore: CredentialRefreshLeaseStore,
254
+ credentialId: number,
255
+ owner: string,
256
+ takenOverFrom: CredentialRefreshLeaseHolder | undefined,
257
+ ): Promise<void> {
258
+ if (!takenOverFrom) {
259
+ leaseStore.releaseCredentialRefreshLease(credentialId, owner);
260
+ return;
261
+ }
262
+ try {
263
+ await this.#handBackLease(leaseStore, credentialId, owner, takenOverFrom);
264
+ } catch (error) {
265
+ // The lease then expires still naming this refresher, a recent holder.
266
+ logger.warn("OAuth refresh could not hand a taken-over lease back", { credentialId, error: String(error) });
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Save a token this process already exchanged. The provider consumed the old
272
+ * refresh token, so a busy store is retried (re-arming the lease each time:
273
+ * a blocked event loop can outlive its TTL, and an expired lease no peer
274
+ * took is still ours) instead of dropping the only valid token. A refused
275
+ * save is retried too, within the same 60 s window, while the row still
276
+ * holds the spent token: a peer that took over the lapsed lease hands it
277
+ * back once the provider rejects that token, but the next peer can hold it
278
+ * at the moment this save lands.
279
+ */
280
+ async #persistExchangedCredential(
281
+ provider: string,
282
+ credentialId: number,
283
+ expectedData: string,
284
+ credential: OAuthCredential,
285
+ lease: { store: CredentialRefreshLeaseStore; owner: string } | undefined,
286
+ fingerprints: Record<string, unknown>,
287
+ ): Promise<boolean> {
288
+ const deadline = Date.now() + OAUTH_REFRESH_PERSIST_TIMEOUT_MS;
289
+ let refused = false;
290
+ for (let attempt = 0; ; attempt++) {
291
+ if (attempt > 0 && Date.now() >= deadline) {
292
+ if (refused) {
293
+ logger.warn("OAuth exchanged token not saved before the save deadline", fingerprints);
294
+ return false;
295
+ }
296
+ throw new AIError.ConfigurationError("OAuth exchanged token could not be saved: store stayed busy");
297
+ }
298
+ let renewed = true;
299
+ try {
300
+ if (lease) {
301
+ renewed = lease.store.renewCredentialRefreshLease(
302
+ credentialId,
303
+ lease.owner,
304
+ Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
305
+ );
306
+ // An expired lease no peer took is still ours: re-acquire it rather
307
+ // than fail the fence and drop a token the provider already minted.
308
+ if (!renewed) {
309
+ renewed = lease.store.tryAcquireCredentialRefreshLease(
310
+ credentialId,
311
+ lease.owner,
312
+ Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
313
+ );
314
+ }
315
+ }
316
+ const saved =
317
+ this.#deps.store.tryUpdateAuthCredentialIfMatches?.(
318
+ credentialId,
319
+ expectedData,
320
+ credential,
321
+ lease ? { owner: lease.owner, nowMs: Date.now() } : undefined,
322
+ ) ?? false;
323
+ if (saved) return true;
324
+ // A peer that saved first changed the row; otherwise no peer can hold a
325
+ // usable successor of the spent token, so wait for the lease instead.
326
+ const stored = this.#deps.store.listAuthCredentials(provider).find(entry => entry.id === credentialId);
327
+ if (!stored || serializeCredential(provider, stored.credential)?.data !== expectedData) {
328
+ logger.warn("OAuth exchanged token not saved", { ...fingerprints, leaseHeld: renewed });
329
+ return false;
330
+ }
331
+ if (!refused) {
332
+ logger.warn("OAuth exchanged token save refused while a peer holds the lease; retrying", {
333
+ ...fingerprints,
334
+ leaseHeld: renewed,
335
+ });
336
+ }
337
+ refused = true;
338
+ } catch (error) {
339
+ if (!isSqliteBusyError(error)) {
340
+ logger.error("OAuth exchanged token could not be saved", { ...fingerprints, error: String(error) });
341
+ throw error;
342
+ }
343
+ refused = false;
344
+ logger.warn("OAuth exchanged token save hit a busy store; retrying", { ...fingerprints, attempt });
345
+ }
346
+ await Bun.sleep(Math.min(OAUTH_REFRESH_PERSIST_RETRY_MAX_MS, OAUTH_REFRESH_LEASE_POLL_MS * 2 ** attempt));
347
+ }
348
+ }
349
+
164
350
  /**
165
351
  * Refresh one stored OAuth credential under durable row ownership.
166
352
  */
@@ -174,10 +360,10 @@ export class OAuthRefresher {
174
360
 
175
361
  /** Keeps shutdown waiting until `pending` settles, so rotated tokens are persisted before exit. */
176
362
  #track<R>(pending: Promise<R>): Promise<R> {
177
- this.#cancelRefreshCleanup ??= postmortem.register("oauth-refresh", () => this.#drainRefreshes(), {
178
- exitOnly: true,
179
- });
363
+ // Add before registering: a registration made while exit cleanup runs drains at
364
+ // once, and must find this work pending or the process exits without it.
180
365
  this.#pendingRefreshes.add(pending);
366
+ this.#armShutdownDrain();
181
367
  const settled = () => {
182
368
  this.#pendingRefreshes.delete(pending);
183
369
  if (this.#pendingRefreshes.size === 0 && !this.#shuttingDown) {
@@ -189,9 +375,23 @@ export class OAuthRefresher {
189
375
  return pending;
190
376
  }
191
377
 
378
+ /**
379
+ * Registers the shutdown drain if it is not registered yet. A registration made
380
+ * once exit has begun drains at once, which sets `#shuttingDown`.
381
+ */
382
+ #armShutdownDrain(): void {
383
+ this.#cancelRefreshCleanup ??= postmortem.register("oauth-refresh", () => this.#drainRefreshes(), {
384
+ exitOnly: true,
385
+ deadlineMs: OAUTH_REFRESH_SHUTDOWN_WAIT_MS,
386
+ // A quit that waits in silence looks hung, and killing it loses the token anyway.
387
+ waitMessage: "Waiting for the login refresh to finish saving",
388
+ });
389
+ }
390
+
192
391
  async #drainRefreshes(): Promise<void> {
193
392
  this.#shuttingDown = true;
194
- await Promise.allSettled(this.#pendingRefreshes);
393
+ // Work tracked while draining (a lease hand-back a returning caller left behind) is waited for too.
394
+ while (this.#pendingRefreshes.size > 0) await Promise.allSettled([...this.#pendingRefreshes]);
195
395
  }
196
396
 
197
397
  async #refreshStored<T extends OAuthCredential = OAuthCredential>(
@@ -202,6 +402,7 @@ export class OAuthRefresher {
202
402
  const leaseStore = hasRefreshLeases(this.#deps.store) ? this.#deps.store : undefined;
203
403
  const owner = crypto.randomUUID();
204
404
  let leasedCredentialId: number | undefined;
405
+ let takenOverFrom: CredentialRefreshLeaseHolder | undefined;
205
406
  const budgetMs = options.refreshTimeoutMs ?? OAUTH_REFRESH_OPERATION_TIMEOUT_MS;
206
407
  // Waiting on a peer's lease counts against the same budget as the exchange.
207
408
  const waitDeadline = Date.now() + budgetMs;
@@ -231,11 +432,7 @@ export class OAuthRefresher {
231
432
  // Adopt the stored copy only when it is still usable; a stored copy that
232
433
  // is itself expired must fall through to a refresh rather than be handed
233
434
  // back and fail the downstream `getOAuthApiKey` expiry precondition.
234
- if (
235
- options.observedCredential &&
236
- !authCredentialEquals(current, options.observedCredential) &&
237
- currentIsFresh
238
- ) {
435
+ if (currentIsFresh && this.#storedIsNewer(current, options)) {
239
436
  return { credential: current, refreshed: false, removed: false };
240
437
  }
241
438
  if (!options.forceRefresh && currentIsFresh) {
@@ -246,15 +443,46 @@ export class OAuthRefresher {
246
443
  }
247
444
  // A caller whose budget ran out must not start an exchange it will not wait for.
248
445
  if (Date.now() >= waitDeadline) return this.#budgetExceeded(provider, options, current);
446
+ // This process already had this refresh token rejected after taking over
447
+ // a stalled refresher, which may still save its successor: wait for that
448
+ // save instead of replaying the token at the provider.
449
+ const stalled = this.#stalledRefreshes.get(row.id);
450
+ if (stalled) {
451
+ const stalledMs = stalled.until - Date.now();
452
+ if (stalled.refresh === current.refresh && stalledMs > 0) {
453
+ await raceSignal(
454
+ Bun.sleep(Math.min(stalledMs, waitDeadline - Date.now(), OAUTH_REFRESH_LEASE_WAIT_MAX_MS)),
455
+ options.signal,
456
+ "OAuth refresh wait for a stalled refresher aborted by caller",
457
+ );
458
+ continue;
459
+ }
460
+ this.#stalledRefreshes.delete(row.id);
461
+ }
462
+ // A lease row still naming another owner means that owner stalled or died
463
+ // holding it. It may have spent the stored refresh token already and still
464
+ // be retrying the save of its successor, so remember it: a rejection of
465
+ // that token is then not proof the login is dead.
466
+ const priorHolder = leaseStore.getCredentialRefreshLeaseHolder?.(row.id);
249
467
  if (leaseStore.tryAcquireCredentialRefreshLease(row.id, owner, Date.now() + OAUTH_REFRESH_LEASE_TTL_MS)) {
250
468
  leasedCredentialId = row.id;
469
+ if (
470
+ priorHolder &&
471
+ priorHolder.owner !== owner &&
472
+ Date.now() - priorHolder.expiresAtMs < OAUTH_REFRESH_STALLED_OWNER_MS
473
+ ) {
474
+ takenOverFrom = priorHolder;
475
+ }
251
476
  break;
252
477
  }
253
478
  const leaseExpiresAt = leaseStore.getCredentialRefreshLeaseExpiresAt(row.id);
254
479
  const waitMs =
255
480
  leaseExpiresAt === undefined
256
481
  ? OAUTH_REFRESH_LEASE_POLL_MS
257
- : Math.min(Math.max(leaseExpiresAt - Date.now(), OAUTH_REFRESH_LEASE_POLL_MS), 250);
482
+ : Math.min(
483
+ Math.max(leaseExpiresAt - Date.now(), OAUTH_REFRESH_LEASE_POLL_MS),
484
+ OAUTH_REFRESH_LEASE_WAIT_MAX_MS,
485
+ );
258
486
  const remainingMs = waitDeadline - Date.now();
259
487
  if (remainingMs <= 0) return this.#budgetExceeded(provider, options, current);
260
488
  await raceSignal(
@@ -283,13 +511,9 @@ export class OAuthRefresher {
283
511
  return { credential: undefined, refreshed: false, removed: false };
284
512
  }
285
513
  const currentIsFresh = Date.now() + refreshSkewMs < current.expires;
286
- // Re-check after acquiring the lease: only adopt the stored copy on an
287
- // observed mismatch when it is still usable, mirroring the pre-lease guard.
288
- if (
289
- options.observedCredential &&
290
- !authCredentialEquals(current, options.observedCredential) &&
291
- currentIsFresh
292
- ) {
514
+ // Re-check after acquiring the lease: a peer that held it may have just
515
+ // rotated the row, mirroring the pre-lease guard.
516
+ if (currentIsFresh && this.#storedIsNewer(current, options)) {
293
517
  return { credential: current, refreshed: false, removed: false };
294
518
  }
295
519
  if (!options.forceRefresh && currentIsFresh) {
@@ -298,6 +522,9 @@ export class OAuthRefresher {
298
522
  if (options.canRefresh && !options.canRefresh(current)) {
299
523
  return { credential: current, refreshed: false, removed: false };
300
524
  }
525
+ // Arm the drain first: once exit has begun, arming it sets `#shuttingDown`, so a
526
+ // refresh that starts then stops here instead of spending a token nobody saves.
527
+ this.#armShutdownDrain();
301
528
  if (this.#shuttingDown || options.signal?.aborted) {
302
529
  throw new AIError.AbortError("OAuth refresh stopped before token exchange");
303
530
  }
@@ -319,6 +546,7 @@ export class OAuthRefresher {
319
546
  leaseStore,
320
547
  leasedCredentialId,
321
548
  owner,
549
+ takenOverFrom,
322
550
  ),
323
551
  );
324
552
  // Lease release now belongs to the exchange.
@@ -336,7 +564,9 @@ export class OAuthRefresher {
336
564
  return this.#refreshFailed(options, current, outcome.failure);
337
565
  } finally {
338
566
  if (leasedCredentialId !== undefined && leaseStore) {
339
- leaseStore.releaseCredentialRefreshLease(leasedCredentialId, owner);
567
+ // A hand-back retries a busy store for the save window; the caller, which is
568
+ // leaving with its budget spent or its signal aborted, does not wait for it.
569
+ void this.#track(this.#leaveLease(leaseStore, leasedCredentialId, owner, takenOverFrom));
340
570
  }
341
571
  }
342
572
  }
@@ -372,8 +602,9 @@ export class OAuthRefresher {
372
602
 
373
603
  /**
374
604
  * Runs one token exchange under the row lease and persists the rotated
375
- * credential by CAS, then releases the lease. A non-definitive exchange error
376
- * resolves as `failure` so the waiting caller applies its failure policy.
605
+ * credential by CAS, then releases the lease. An exchange error that does not
606
+ * disable the login resolves as `failure` so the waiting caller applies its
607
+ * failure policy.
377
608
  */
378
609
  async #exchangeLeased<T extends OAuthCredential>(
379
610
  provider: string,
@@ -384,7 +615,10 @@ export class OAuthRefresher {
384
615
  leaseStore: CredentialRefreshLeaseStore | undefined,
385
616
  leasedCredentialId: number | undefined,
386
617
  owner: string,
618
+ takenOverFrom: CredentialRefreshLeaseHolder | undefined,
387
619
  ): Promise<LeasedExchangeOutcome<T>> {
620
+ let keepLease = false;
621
+ let saved = false;
388
622
  try {
389
623
  let stopLeaseRenewal = false;
390
624
  let leaseRenewalError: unknown;
@@ -392,14 +626,35 @@ export class OAuthRefresher {
392
626
  const leaseRenewal =
393
627
  leasedCredentialId !== undefined && leaseStore
394
628
  ? (async () => {
629
+ // Renew every interval; after a busy store, retry almost at once,
630
+ // since another full interval would outlive the lease.
631
+ let delayMs = OAUTH_REFRESH_LEASE_RENEW_MS;
395
632
  while (!stopLeaseRenewal) {
396
- await Promise.race([Bun.sleep(OAUTH_REFRESH_LEASE_RENEW_MS), leaseRenewalStopped.promise]);
633
+ await Promise.race([Bun.sleep(delayMs), leaseRenewalStopped.promise]);
397
634
  if (stopLeaseRenewal) return;
398
- const renewed = leaseStore.renewCredentialRefreshLease(
399
- leasedCredentialId,
400
- owner,
401
- Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
402
- );
635
+ let renewed: boolean;
636
+ try {
637
+ renewed = leaseStore.renewCredentialRefreshLease(
638
+ leasedCredentialId,
639
+ owner,
640
+ Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
641
+ );
642
+ // An expired lease no peer took is still ours.
643
+ if (!renewed) {
644
+ renewed = leaseStore.tryAcquireCredentialRefreshLease(
645
+ leasedCredentialId,
646
+ owner,
647
+ Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
648
+ );
649
+ }
650
+ } catch (error) {
651
+ // A busy store only delays this renewal; ownership is lost
652
+ // only once another owner holds the lease row.
653
+ if (!isSqliteBusyError(error)) throw error;
654
+ delayMs = OAUTH_REFRESH_LEASE_POLL_MS;
655
+ continue;
656
+ }
657
+ delayMs = OAUTH_REFRESH_LEASE_RENEW_MS;
403
658
  if (!renewed) {
404
659
  throw new AIError.ConfigurationError("OAuth refresh ownership was lost before persistence");
405
660
  }
@@ -420,10 +675,9 @@ export class OAuthRefresher {
420
675
  // Stop waiting even when the refresh ignores its signal, so the lease is released.
421
676
  backstopReached.reject(error);
422
677
  },
423
- Math.max(
424
- options.refreshTimeoutMs ?? OAUTH_REFRESH_OPERATION_TIMEOUT_MS,
425
- OAUTH_REFRESH_EXCHANGE_BACKSTOP_MS,
426
- ),
678
+ // A fixed ceiling, whatever the caller's budget: a peer that takes over a
679
+ // lapsed lease, and shutdown, both assume no exchange runs longer.
680
+ OAUTH_REFRESH_EXCHANGE_BACKSTOP_MS,
427
681
  );
428
682
 
429
683
  let refreshed: OAuthCredentials;
@@ -431,6 +685,45 @@ export class OAuthRefresher {
431
685
  refreshed = await Promise.race([options.refresh(current, refreshAbort.signal), backstopReached.promise]);
432
686
  } catch (error) {
433
687
  if (!options.isDefinitiveFailure?.(error)) return { failure: error };
688
+ if (takenOverFrom && leaseStore && leasedCredentialId !== undefined) {
689
+ // The owner whose lease lapsed may have spent this token and still be
690
+ // saving its successor. Give the lease back so it can, and fail this
691
+ // refresh as transient instead of disabling a login that may live.
692
+ this.#stalledRefreshes.set(row.id, {
693
+ refresh: current.refresh,
694
+ until: takenOverFrom.expiresAtMs + OAUTH_REFRESH_STALLED_OWNER_MS,
695
+ });
696
+ // Stop renewing first: a renewal after the hand-back would take it back.
697
+ stopLeaseRenewal = true;
698
+ leaseRenewalStopped.resolve();
699
+ await leaseRenewal;
700
+ // Never release this lease afterwards: a hand-back that landed left
701
+ // nothing to release, and one that did not must leave a recent owner.
702
+ keepLease = true;
703
+ const handedBack = await this.#handBackLease(leaseStore, leasedCredentialId, owner, takenOverFrom).catch(
704
+ (error: unknown) => {
705
+ // The lease then expires still naming this refresher, a recent holder.
706
+ logger.warn("OAuth refresh could not hand a taken-over lease back", {
707
+ credentialId: row.id,
708
+ error: String(error),
709
+ });
710
+ return false;
711
+ },
712
+ );
713
+ logger.warn("OAuth refresh token rejected after taking over a stalled refresher; not disabling", {
714
+ provider,
715
+ credentialId: row.id,
716
+ spentRefresh: tokenFingerprint(current.refresh),
717
+ handedBack,
718
+ });
719
+ // A transient failure, so the caller's failure policy applies to it.
720
+ return {
721
+ failure: new AIError.OAuthError(
722
+ `OAuth refresh for ${provider} waits for a stalled refresher that may still save its token`,
723
+ { kind: "token-refresh", provider },
724
+ ),
725
+ };
726
+ }
434
727
  const disabledCause = options.disabledCause?.(error) ?? `oauth refresh failed: ${String(error)}`;
435
728
  const disabled = this.#deps.store.tryDisableAuthCredentialIfMatches(
436
729
  row.id,
@@ -466,20 +759,36 @@ export class OAuthRefresher {
466
759
  await leaseRenewal;
467
760
  clearTimeout(backstop);
468
761
  }
469
- if (leaseRenewalError) throw leaseRenewalError;
470
-
471
762
  const merged: T = options.mergeRefreshedCredential
472
763
  ? options.mergeRefreshedCredential(current, refreshed)
473
764
  : mergeRefreshedCredential(current, refreshed);
765
+ const fingerprints = {
766
+ provider,
767
+ credentialId: row.id,
768
+ spentRefresh: tokenFingerprint(current.refresh),
769
+ access: tokenFingerprint(merged.access),
770
+ refresh: tokenFingerprint(merged.refresh),
771
+ expires: merged.expires,
772
+ };
773
+ logger.info("OAuth token exchange succeeded", fingerprints);
774
+ if (leaseRenewalError) {
775
+ logger.warn("OAuth refresh lost its lease before saving the exchanged token", fingerprints);
776
+ // The provider already consumed the old refresh token. The fenced save
777
+ // below writes only once the lease is ours again and the row is unchanged.
778
+ if (!this.#deps.store.tryUpdateAuthCredentialIfMatches) throw leaseRenewalError;
779
+ }
474
780
  if (this.#deps.store.tryUpdateAuthCredentialIfMatches) {
475
781
  if (
476
- !this.#deps.store.tryUpdateAuthCredentialIfMatches(
782
+ !(await this.#persistExchangedCredential(
783
+ provider,
477
784
  row.id,
478
785
  serializedData,
479
786
  merged,
480
- leasedCredentialId !== undefined ? { owner, nowMs: Date.now() } : undefined,
481
- )
787
+ leasedCredentialId !== undefined && leaseStore ? { store: leaseStore, owner } : undefined,
788
+ fingerprints,
789
+ ))
482
790
  ) {
791
+ logger.warn("OAuth refresh lost its compare-and-set; exchanged token not saved", fingerprints);
483
792
  await this.#deps.pool.reload();
484
793
  const latest = this.#deps.pool.entries(provider).find(entry => entry.id === row.id)?.credential;
485
794
  return {
@@ -493,6 +802,7 @@ export class OAuthRefresher {
493
802
  } else {
494
803
  this.#deps.store.updateAuthCredential(row.id, merged);
495
804
  }
805
+ saved = true;
496
806
  if (this.#mintsLocally()) {
497
807
  this.#rememberMint(provider, row.id, merged);
498
808
  }
@@ -505,8 +815,9 @@ export class OAuthRefresher {
505
815
  );
506
816
  return { result: { credential: merged, refreshed: true, removed: false } };
507
817
  } finally {
508
- if (leasedCredentialId !== undefined && leaseStore) {
509
- leaseStore.releaseCredentialRefreshLease(leasedCredentialId, owner);
818
+ if (leasedCredentialId !== undefined && leaseStore && !keepLease) {
819
+ if (saved) leaseStore.releaseCredentialRefreshLease(leasedCredentialId, owner);
820
+ else await this.#leaveLease(leaseStore, leasedCredentialId, owner, takenOverFrom);
510
821
  }
511
822
  }
512
823
  }
@@ -579,13 +890,15 @@ export class OAuthRefresher {
579
890
  credentialId: number | undefined,
580
891
  signal?: AbortSignal,
581
892
  reason?: OAuthRefreshReason,
893
+ rejectedAccess?: string,
582
894
  ): Promise<OAuthCredentials> {
583
895
  const authRecovery = reason === "auth-recovery";
584
896
  if (authRecovery && credentialId !== undefined && !this.#oauthCredentialRefreshInFlight.has(credentialId)) {
585
897
  const recent = this.#recentMint(credentialId);
586
- if (recent) return recent.credential;
898
+ // A token the provider has just rejected is not a usable recent mint.
899
+ if (recent && recent.credential.access !== rejectedAccess) return recent.credential;
587
900
  }
588
- return this.#refreshSingleFlight(provider, credential, credentialId, signal, reason);
901
+ return this.#refreshSingleFlight(provider, credential, credentialId, signal, reason, rejectedAccess);
589
902
  }
590
903
 
591
904
  /** Refresh without recent-mint reuse; still shares the per-credential in-flight request. */
@@ -595,17 +908,31 @@ export class OAuthRefresher {
595
908
  credentialId: number | undefined,
596
909
  signal?: AbortSignal,
597
910
  reason?: OAuthRefreshReason,
911
+ rejectedAccess?: string,
598
912
  ): Promise<OAuthCredentials> {
599
913
  credential = normalizeOAuthCredentialExpiry(provider, credential);
600
914
  if (credentialId !== undefined) {
601
915
  const existing = this.#oauthCredentialRefreshInFlight.get(credentialId);
602
- if (existing) return raceSignal(existing, signal, "credential refresh aborted");
916
+ if (existing) {
917
+ const joined = await raceSignal(existing, signal, "credential refresh aborted");
918
+ // A refresh started for another request may adopt the very token this
919
+ // request was refused with; refresh again instead of returning it.
920
+ if (rejectedAccess === undefined || joined.access !== rejectedAccess) return joined;
921
+ return this.#refreshSingleFlight(provider, credential, credentialId, signal, reason, rejectedAccess);
922
+ }
603
923
  }
604
924
  if (Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
605
925
  if (credentialId === undefined) {
606
- return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, reason);
926
+ return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, reason, rejectedAccess);
607
927
  }
608
- const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId, signal, reason)
928
+ const promise = this.#refreshOAuthCredentialUnshared(
929
+ provider,
930
+ credential,
931
+ credentialId,
932
+ signal,
933
+ reason,
934
+ rejectedAccess,
935
+ )
609
936
  .then(refreshed => {
610
937
  // A delegated refresh may have returned a broker-cached token rather
611
938
  // than minting one. Only direct local requests can establish mint time.
@@ -627,14 +954,18 @@ export class OAuthRefresher {
627
954
  credentialId: number | undefined,
628
955
  signal?: AbortSignal,
629
956
  reason?: OAuthRefreshReason,
957
+ rejectedAccess?: string,
630
958
  ): Promise<OAuthCredentials> {
631
959
  if (credentialId !== undefined && hasRefreshLeases(this.#deps.store)) {
632
960
  const forceRefresh = credential.expires === 0;
633
961
  const result = await this.refreshStored(provider, {
634
962
  credentialId,
635
- observedCredential: forceRefresh ? undefined : credential,
963
+ // A forced refresh still names the tokens it wants replaced: when the
964
+ // row already holds newer ones, another process refreshed first.
965
+ observedCredential: credential,
636
966
  credentialFromRow: row => row,
637
967
  forceRefresh,
968
+ rejectedAccess: reason === "auth-recovery" ? rejectedAccess : undefined,
638
969
  signal,
639
970
  // `refreshStored` owns the caller budget and the exchange backstop.
640
971
  refresh: (current, refreshSignal) =>
@@ -755,14 +1086,30 @@ export class OAuthRefresher {
755
1086
  options?: OAuthRefreshByIdOptions,
756
1087
  ): Promise<AuthCredentialSnapshotEntry> {
757
1088
  const existing = this.#oauthRefreshInFlight.get(id);
758
- if (existing) return raceSignal(existing, signal, "credential refresh aborted");
1089
+ if (existing) {
1090
+ const joined = await raceSignal(existing, signal, "credential refresh aborted");
1091
+ // A refresh that started earlier can land on the very token this caller's
1092
+ // request was refused with; that is no recovery, so refresh again.
1093
+ if (
1094
+ options?.rejectedAccess === undefined ||
1095
+ joined.credential.type !== "oauth" ||
1096
+ joined.credential.access !== options.rejectedAccess
1097
+ ) {
1098
+ return joined;
1099
+ }
1100
+ // Whatever refresh runs now is checked against the rejected token the same way.
1101
+ return this.refreshById(id, signal, options);
1102
+ }
759
1103
  const recent = options?.reuseRecentMint ? this.#recentMint(id) : undefined;
760
- if (recent) return snapshotEntry(id, recent.provider, recent.credential);
1104
+ // A token the provider has just rejected is not a usable recent mint.
1105
+ if (recent && recent.credential.access !== options?.rejectedAccess) {
1106
+ return snapshotEntry(id, recent.provider, recent.credential);
1107
+ }
761
1108
 
762
1109
  const promise = (async () => {
763
1110
  this.#deps.pool.bump("credential-refresh-start");
764
1111
  try {
765
- return await this.#forceRefreshCredentialByIdUnshared(id, signal, options?.reason);
1112
+ return await this.#forceRefreshCredentialByIdUnshared(id, signal, options?.reason, options?.rejectedAccess);
766
1113
  } catch (error) {
767
1114
  this.#deps.pool.bump("credential-refresh-failure");
768
1115
  throw error;
@@ -778,6 +1125,7 @@ export class OAuthRefresher {
778
1125
  id: number,
779
1126
  signal?: AbortSignal,
780
1127
  reason?: OAuthRefreshReason,
1128
+ rejectedAccess?: string,
781
1129
  ): Promise<AuthCredentialSnapshotEntry> {
782
1130
  for (const provider of this.#deps.pool.providers()) {
783
1131
  const entries = this.#deps.pool.entries(provider);
@@ -794,11 +1142,19 @@ export class OAuthRefresher {
794
1142
  // value we actually attempted (NOT the expires:0 clone below).
795
1143
  const attempted = target.credential;
796
1144
  // Pass a clone with expires=0 and bypass recent-mint reuse so scheduled
797
- // refreshes and direct force-refresh callers always mint.
1145
+ // refreshes and direct force-refresh callers mint, unless the stored row
1146
+ // already holds a token the provider has not rejected.
798
1147
  const stale: OAuthCredential = { ...attempted, expires: 0 };
799
1148
  let refreshed: OAuthCredentials;
800
1149
  try {
801
- refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal, reason);
1150
+ refreshed = await this.#refreshSingleFlight(
1151
+ provider as Provider,
1152
+ stale,
1153
+ id,
1154
+ signal,
1155
+ reason,
1156
+ rejectedAccess,
1157
+ );
802
1158
  } catch (error) {
803
1159
  // A definitively-dead grant tears the row down here, where the
804
1160
  // attempted credential is known. CAS on the persisted credential so a