@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.
- package/CHANGELOG.md +18 -18
- package/dist/types/auth/refresh.d.ts +1 -1
- package/dist/types/auth/sqlite-credential-store.d.ts +3 -1
- package/dist/types/auth/store.d.ts +13 -0
- package/dist/types/auth/types.d.ts +15 -0
- package/dist/types/auth-gateway/dispatch.d.ts +1 -1
- package/dist/types/auth-retry.d.ts +19 -0
- package/dist/types/error/flags.d.ts +1 -0
- package/dist/types/providers/google-types.d.ts +1 -0
- package/dist/types/utils/request-compression.d.ts +2 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +5 -8
- package/package.json +6 -6
- package/src/auth/cascade.ts +27 -18
- package/src/auth/oauth.ts +1 -0
- package/src/auth/refresh.ts +408 -52
- package/src/auth/select.ts +7 -1
- package/src/auth/sqlite-credential-store.ts +29 -1
- package/src/auth/store.ts +14 -0
- package/src/auth/types.ts +15 -0
- package/src/auth-gateway/dispatch.ts +11 -1
- package/src/auth-retry.ts +48 -1
- package/src/error/flags.ts +13 -0
- package/src/error/rate-limit.ts +67 -5
- package/src/error/retryable.ts +4 -1
- package/src/providers/devin.ts +16 -7
- package/src/providers/google-gemini-cli.ts +10 -3
- package/src/providers/google-shared.ts +5 -2
- package/src/providers/google-types.ts +1 -1
- package/src/providers/openai-codex/response-handler.ts +15 -2
- package/src/usage/github-copilot.ts +47 -16
- package/src/usage/openai-codex.ts +18 -5
- package/src/utils/request-compression.ts +1 -1
- package/src/utils/schema/json-schema-validator.ts +5 -8
package/src/auth/refresh.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
287
|
-
//
|
|
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
|
-
|
|
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.
|
|
376
|
-
* resolves as `failure` so the waiting caller applies its
|
|
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(
|
|
633
|
+
await Promise.race([Bun.sleep(delayMs), leaseRenewalStopped.promise]);
|
|
397
634
|
if (stopLeaseRenewal) return;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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.#
|
|
782
|
+
!(await this.#persistExchangedCredential(
|
|
783
|
+
provider,
|
|
477
784
|
row.id,
|
|
478
785
|
serializedData,
|
|
479
786
|
merged,
|
|
480
|
-
leasedCredentialId !== 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
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|