@gajae-code/ai 0.12.21 → 0.13.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.
@@ -301,6 +301,18 @@ export interface AuthCredentialSnapshot {
301
301
  * a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`)
302
302
  * throw because login flows route through the broker, not the client.
303
303
  */
304
+ export type OAuthRefreshLease = {
305
+ credentialId: number;
306
+ owner: string;
307
+ tokenFingerprint: string;
308
+ };
309
+
310
+ export type OAuthRefreshLeaseClaim =
311
+ | { kind: "claimed"; credential: OAuthCredential; lease: OAuthRefreshLease }
312
+ | { kind: "adopted"; credential: OAuthCredential }
313
+ | { kind: "busy"; expiresAt: number }
314
+ | { kind: "missing" };
315
+
304
316
  export interface AuthCredentialStore {
305
317
  close(): void;
306
318
  listAuthCredentials(provider?: string): StoredAuthCredential[];
@@ -339,6 +351,25 @@ export interface AuthCredentialStore {
339
351
  client: MCPOAuthRefreshClient,
340
352
  signal?: AbortSignal,
341
353
  ): Promise<OAuthCredential>;
354
+ /**
355
+ * Atomically adopts a fresh row or claims the current refresh token for one
356
+ * local provider dial. SQLite-backed stores use this to prevent another
357
+ * process from replaying a rotating refresh token between a pre-read and
358
+ * the provider request.
359
+ */
360
+ claimOAuthRefreshLease?(
361
+ credentialId: number,
362
+ expectedRefresh: string,
363
+ force: boolean,
364
+ owner: string,
365
+ nowMs: number,
366
+ leaseMs: number,
367
+ ): OAuthRefreshLeaseClaim;
368
+ /** Atomically persists a successful claimed refresh and releases its lease. */
369
+ completeOAuthRefreshLease?(lease: OAuthRefreshLease, credential: OAuthCredential): boolean;
370
+ /** Releases an uncompleted refresh lease owned by this process. */
371
+ releaseOAuthRefreshLease?(lease: OAuthRefreshLease): void;
372
+
342
373
  /**
343
374
  * Optional async pre-read hook invoked after AuthStorage selects a stored
344
375
  * credential but before it returns that credential for an outbound request.
@@ -722,6 +753,9 @@ const USAGE_FAILURE_BACKOFF_MS = 10_000;
722
753
  // (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
723
754
  const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
724
755
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
756
+ /** Maximum provider ownership window; expiry recovers a crashed process without indefinite blocking. */
757
+ const OAUTH_REFRESH_LEASE_MS = DEFAULT_OAUTH_REFRESH_TIMEOUT_MS + 5_000;
758
+
725
759
  /**
726
760
  * Refresh OAuth access tokens this many ms before their stated expiry. The
727
761
  * skew exists so callers downstream of {@link AuthStorage} (stream providers,
@@ -752,6 +786,52 @@ const MAX_PENDING_DISABLED_EVENTS = 32;
752
786
  */
753
787
  const MAX_OAUTH_RESOLUTION_RELOADS = 3;
754
788
 
789
+ /**
790
+ * How long a locally failed refresh attempt pins its exact (credential id,
791
+ * refresh token) pair as non-replayable. Within this window the memoized
792
+ * failure is rethrown instead of dialing the token endpoint again: after an
793
+ * ambiguous failure (timeout, lost response) the provider may have already
794
+ * consumed the rotating refresh token, and replaying it trips refresh-token
795
+ * reuse detection, which can revoke the whole grant family. A successful
796
+ * rotation changes the token and therefore the key, so recovery after a
797
+ * peer's successful refresh is never blocked by this memo.
798
+ */
799
+ const OAUTH_REFRESH_FAILURE_REPLAY_GUARD_MS = 30_000;
800
+
801
+ /**
802
+ * A refresh result that carries the full authority of the credential that was
803
+ * actually refreshed: rotated tokens plus the identity metadata and MCP
804
+ * binding of the effective (possibly guard-adopted) credential. Callers
805
+ * persist from this shape so an adopted binding or identity is never
806
+ * reconstructed from a stale snapshot.
807
+ */
808
+ type RefreshedOAuthCredentials = OAuthCredentials & { mcpBinding?: MCPOAuthBinding; persistedByLease?: boolean };
809
+
810
+ /**
811
+ * Side-table mapping a refresh failure to the refresh token that was actually
812
+ * sent upstream, so failure recovery can distinguish "a peer rotated the row"
813
+ * from "the guard adopted the persisted token and that token failed". Without
814
+ * it, recovery compares the row against the caller's stale snapshot and
815
+ * misreads its own adoption as a peer rotation. A WeakMap is used instead of
816
+ * mutating the thrown object: providers may throw frozen/sealed errors, and
817
+ * writing a property to those would replace the real failure with a TypeError.
818
+ */
819
+ const attemptedRefreshTokens = new WeakMap<object, string>();
820
+
821
+ function tagRefreshAttempt(error: unknown, refreshToken: string): unknown {
822
+ if (error !== null && typeof error === "object") {
823
+ attemptedRefreshTokens.set(error, refreshToken);
824
+ }
825
+ return error;
826
+ }
827
+
828
+ function getAttemptedRefreshToken(error: unknown): string | undefined {
829
+ if (error !== null && typeof error === "object") {
830
+ return attemptedRefreshTokens.get(error);
831
+ }
832
+ return undefined;
833
+ }
834
+
755
835
  type UsageCacheEntry<T> = {
756
836
  value: T;
757
837
  expiresAt: number;
@@ -1024,7 +1104,14 @@ export class AuthStorage {
1024
1104
  #providerOAuthRefreshGenerations = new Map<string, number>();
1025
1105
  #generationListeners: Set<(generation: number) => void> = new Set();
1026
1106
  #oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
1027
- #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
1107
+ #oauthCredentialRefreshInFlight: Map<number, Promise<RefreshedOAuthCredentials>> = new Map();
1108
+ /**
1109
+ * Locally failed refresh attempts keyed by `${credentialId}:${refreshToken}`.
1110
+ * See {@link OAUTH_REFRESH_FAILURE_REPLAY_GUARD_MS}.
1111
+ */
1112
+ #recentOAuthRefreshFailures = new Map<string, { expiresAt: number; error: unknown }>();
1113
+ #oauthRefreshLeaseOwner = crypto.randomUUID();
1114
+
1028
1115
  #closed = false;
1029
1116
 
1030
1117
  constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
@@ -1208,8 +1295,9 @@ export class AuthStorage {
1208
1295
  * Used for CLI --api-key flag.
1209
1296
  */
1210
1297
  setRuntimeApiKey(provider: string, apiKey: string): void {
1211
- this.#runtimeOverrides.set(provider, apiKey);
1212
- this.#bumpGeneration("set-runtime-api-key", provider);
1298
+ const storageProvider = resolveOAuthStorageProvider(provider);
1299
+ this.#runtimeOverrides.set(storageProvider, apiKey);
1300
+ this.#bumpGeneration("set-runtime-api-key", storageProvider);
1213
1301
  }
1214
1302
 
1215
1303
  /**
@@ -1237,12 +1325,19 @@ export class AuthStorage {
1237
1325
  * Remove a runtime API key override.
1238
1326
  */
1239
1327
  removeRuntimeApiKey(provider: string): void {
1240
- if (this.#runtimeOverrides.delete(provider)) this.#bumpGeneration("remove-runtime-api-key", provider);
1328
+ const storageProvider = resolveOAuthStorageProvider(provider);
1329
+ if (this.#runtimeOverrides.delete(storageProvider))
1330
+ this.#bumpGeneration("remove-runtime-api-key", storageProvider);
1241
1331
  }
1242
1332
 
1243
1333
  /** Whether a provider is currently authenticated by a runtime API-key override. */
1244
1334
  hasRuntimeApiKey(provider: string): boolean {
1245
- return Boolean(this.#runtimeOverrides.get(provider));
1335
+ return Boolean(this.#runtimeOverrides.get(resolveOAuthStorageProvider(provider)));
1336
+ }
1337
+
1338
+ /** Whether a provider is currently authenticated by a config API-key override. */
1339
+ hasConfigApiKey(provider: string): boolean {
1340
+ return Boolean(this.#configOverrides.get(resolveOAuthStorageProvider(provider)));
1246
1341
  }
1247
1342
 
1248
1343
  /**
@@ -1271,9 +1366,10 @@ export class AuthStorage {
1271
1366
  * resolver rather than a stored row.
1272
1367
  */
1273
1368
  getSessionCredentialRowId(provider: string, sessionId?: string): number | undefined {
1274
- const session = this.#getSessionCredential(provider, sessionId);
1369
+ const storageProvider = resolveOAuthStorageProvider(provider);
1370
+ const session = this.#getSessionCredential(storageProvider, sessionId);
1275
1371
  if (!session) return undefined;
1276
- return this.#getStoredCredentials(provider)[session.index]?.id;
1372
+ return this.#getStoredCredentials(storageProvider)[session.index]?.id;
1277
1373
  }
1278
1374
 
1279
1375
  /**
@@ -1287,15 +1383,17 @@ export class AuthStorage {
1287
1383
  * still wins for the duration of a single invocation.
1288
1384
  */
1289
1385
  setConfigApiKey(provider: string, apiKey: string): void {
1290
- this.#configOverrides.set(provider, apiKey);
1291
- this.#bumpGeneration("set-config-api-key", provider);
1386
+ const storageProvider = resolveOAuthStorageProvider(provider);
1387
+ this.#configOverrides.set(storageProvider, apiKey);
1388
+ this.#bumpGeneration("set-config-api-key", storageProvider);
1292
1389
  }
1293
1390
 
1294
1391
  /**
1295
1392
  * Remove a single config-sourced API key override.
1296
1393
  */
1297
1394
  removeConfigApiKey(provider: string): void {
1298
- if (this.#configOverrides.delete(provider)) this.#bumpGeneration("remove-config-api-key", provider);
1395
+ const storageProvider = resolveOAuthStorageProvider(provider);
1396
+ if (this.#configOverrides.delete(storageProvider)) this.#bumpGeneration("remove-config-api-key", storageProvider);
1299
1397
  }
1300
1398
 
1301
1399
  /**
@@ -1537,7 +1635,7 @@ export class AuthStorage {
1537
1635
 
1538
1636
  /** Returns the credential type selected for a provider/session, if one has been recorded. */
1539
1637
  getSessionCredentialType(provider: string, sessionId?: string): AuthCredential["type"] | undefined {
1540
- return this.#getSessionCredential(provider, sessionId)?.type;
1638
+ return this.#getSessionCredential(resolveOAuthStorageProvider(provider), sessionId)?.type;
1541
1639
  }
1542
1640
 
1543
1641
  /** Clears the last credential used by a session for a provider. */
@@ -1626,7 +1724,7 @@ export class AuthStorage {
1626
1724
  provider: string,
1627
1725
  type: T,
1628
1726
  sessionId?: string,
1629
- isUsable?: (credential: Extract<AuthCredential, { type: T }>, index: number) => boolean,
1727
+ isUsable?: (credential: Extract<AuthCredential, { type: T }>, index: number) => boolean | undefined,
1630
1728
  ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
1631
1729
  const credentials = this.#getCredentialsForProvider(provider)
1632
1730
  .map((credential, index) => ({ credential, index }))
@@ -1636,23 +1734,52 @@ export class AuthStorage {
1636
1734
  );
1637
1735
 
1638
1736
  if (credentials.length === 0) return undefined;
1639
- if (credentials.length === 1) return credentials[0];
1640
1737
 
1641
1738
  const providerKey = this.#getProviderTypeKey(provider, type);
1642
1739
  const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
1643
- const fallback = credentials[order[0]];
1644
-
1645
1740
  for (const idx of order) {
1646
1741
  const candidate = credentials[idx];
1647
1742
  if (
1648
1743
  !this.#isCredentialBlocked(providerKey, candidate.index) &&
1649
- (isUsable === undefined || isUsable(candidate.credential, candidate.index))
1744
+ (isUsable === undefined || isUsable(candidate.credential, candidate.index) !== false)
1650
1745
  ) {
1651
1746
  return candidate;
1652
1747
  }
1653
1748
  }
1654
1749
 
1655
- return fallback;
1750
+ return order
1751
+ .map(idx => credentials[idx])
1752
+ .find(candidate => isUsable === undefined || isUsable(candidate.credential, candidate.index) !== false);
1753
+ }
1754
+
1755
+ #selectApiKeyCredential(
1756
+ provider: string,
1757
+ sessionId?: string,
1758
+ excludedIndices: ReadonlySet<number> = new Set(),
1759
+ includeKnownUnusable = false,
1760
+ ): { credential: ApiKeyCredential; index: number } | undefined {
1761
+ const credentials = this.#getCredentialsForProvider(provider)
1762
+ .map((credential, index) => ({ credential, index }))
1763
+ .filter(
1764
+ (entry): entry is { credential: ApiKeyCredential; index: number } => entry.credential.type === "api_key",
1765
+ );
1766
+ if (credentials.length === 0) return undefined;
1767
+
1768
+ const providerKey = this.#getProviderTypeKey(provider, "api_key");
1769
+ const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
1770
+ const ordered = order
1771
+ .map(index => credentials[index])
1772
+ .filter(entry => !excludedIndices.has(entry.index))
1773
+ .filter(
1774
+ entry => includeKnownUnusable || this.#storedApiKeyUsability(provider, entry.credential.key) !== false,
1775
+ );
1776
+ const unblocked = ordered.filter(entry => !this.#isCredentialBlocked(providerKey, entry.index));
1777
+ const candidates = unblocked.length > 0 ? unblocked : ordered;
1778
+ return candidates.sort((left, right) => {
1779
+ const usabilityRank = (credential: ApiKeyCredential): number =>
1780
+ this.#storedApiKeyUsability(provider, credential.key) === true ? 0 : 1;
1781
+ return usabilityRank(left.credential) - usabilityRank(right.credential);
1782
+ })[0];
1656
1783
  }
1657
1784
 
1658
1785
  /**
@@ -1673,12 +1800,12 @@ export class AuthStorage {
1673
1800
  }
1674
1801
  }
1675
1802
 
1676
- /** Updates credential at index in-place (used for OAuth token refresh) */
1677
- #replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
1803
+ /** Updates a credential at index after OAuth token refresh. */
1804
+ #replaceCredentialAt(provider: string, index: number, credential: AuthCredential, persist = true): void {
1678
1805
  const entries = this.#getStoredCredentials(provider);
1679
1806
  if (index < 0 || index >= entries.length) return;
1680
1807
  const target = entries[index];
1681
- this.#store.updateAuthCredential(target.id, credential);
1808
+ if (persist) this.#store.updateAuthCredential(target.id, credential);
1682
1809
  const updated = [...entries];
1683
1810
  updated[index] = { id: target.id, credential };
1684
1811
  this.#setStoredCredentials(provider, updated);
@@ -1934,6 +2061,36 @@ export class AuthStorage {
1934
2061
  return this.#hasConfiguredAuth(storageProvider);
1935
2062
  }
1936
2063
 
2064
+ /**
2065
+ * Credential type that a provider/session will dispatch first without performing I/O.
2066
+ * Mirrors getApiKey selector validation, overrides, session OAuth stickiness,
2067
+ * cached command-key usability, OAuth retry, and environment fallback order.
2068
+ */
2069
+ getEffectiveCredentialType(provider: string, sessionId?: string): AuthCredential["type"] | undefined {
2070
+ const storageProvider = resolveOAuthStorageProvider(provider);
2071
+ let selected: ({ index: number } & StoredCredential) | undefined;
2072
+ try {
2073
+ selected = this.#resolveSelectedStoredCredential(storageProvider);
2074
+ } catch {
2075
+ return undefined;
2076
+ }
2077
+ if (this.hasRuntimeApiKey(storageProvider) || this.#configOverrides.has(storageProvider)) return "api_key";
2078
+ if (selected) return selected.credential.type;
2079
+
2080
+ const credentials = this.#getCredentialsForProvider(storageProvider);
2081
+ const sessionCredential = this.#getSessionCredential(storageProvider, sessionId);
2082
+ if (sessionCredential?.type === "oauth" && credentials[sessionCredential.index]?.type === "oauth") return "oauth";
2083
+
2084
+ const apiKeys = credentials.filter((credential): credential is ApiKeyCredential => credential.type === "api_key");
2085
+ if (apiKeys.some(credential => this.#storedApiKeyUsability(storageProvider, credential.key) !== false)) {
2086
+ return "api_key";
2087
+ }
2088
+ if (credentials.some(credential => credential.type === "oauth")) return "oauth";
2089
+ if (apiKeys.length > 0) return "api_key";
2090
+ if (getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider)) return "api_key";
2091
+ return undefined;
2092
+ }
2093
+
1937
2094
  /**
1938
2095
  * Check whether configured auth is currently usable without resolving credentials.
1939
2096
  */
@@ -1960,16 +2117,17 @@ export class AuthStorage {
1960
2117
 
1961
2118
  const credentials = this.#getCredentialsForProvider(storageProvider);
1962
2119
  let hasStoredApiKey = false;
1963
- let hasSelectableApiKey = false;
2120
+ let hasUnblockedApiKey = false;
1964
2121
  let hasUsableApiKey = false;
1965
2122
  for (const [index, credential] of credentials.entries()) {
1966
2123
  if (credential.type !== "api_key") continue;
1967
2124
  hasStoredApiKey = true;
1968
2125
  if (this.#isCredentialBlocked(this.#getProviderTypeKey(storageProvider, credential.type), index)) continue;
1969
- hasSelectableApiKey = true;
2126
+ hasUnblockedApiKey = true;
1970
2127
  hasUsableApiKey ||= this.#hasUsableResolvedStoredApiKey(storageProvider, credential.key);
1971
2128
  }
1972
- if (hasStoredApiKey) return hasSelectableApiKey && hasUsableApiKey;
2129
+ if (hasUsableApiKey) return true;
2130
+ if (hasStoredApiKey && !hasUnblockedApiKey) return false;
1973
2131
  if (
1974
2132
  this.#getCredentialsForProvider(storageProvider).some(
1975
2133
  (credential, index) =>
@@ -2009,6 +2167,7 @@ export class AuthStorage {
2009
2167
  * Returns `undefined` when no OAuth credential carries an `accountId`.
2010
2168
  */
2011
2169
  getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
2170
+ provider = resolveOAuthStorageProvider(provider);
2012
2171
  const allCredentials = this.#getCredentialsForProvider(provider);
2013
2172
  const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
2014
2173
  if (oauthCredentials.length === 0) return undefined;
@@ -3163,6 +3322,7 @@ export class AuthStorage {
3163
3322
  sessionId: string | undefined,
3164
3323
  options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
3165
3324
  ): Promise<boolean> {
3325
+ provider = resolveOAuthStorageProvider(provider);
3166
3326
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
3167
3327
  if (!sessionCredential) return false;
3168
3328
 
@@ -3470,7 +3630,12 @@ export class AuthStorage {
3470
3630
  type: "oauth",
3471
3631
  };
3472
3632
  candidate.selection.credential = updated;
3473
- this.#replaceCredentialAt(provider, candidate.selection.index, updated);
3633
+ this.#replaceCredentialAt(
3634
+ provider,
3635
+ candidate.selection.index,
3636
+ updated,
3637
+ !refreshedCredentials.persistedByLease,
3638
+ );
3474
3639
  } catch {}
3475
3640
  }),
3476
3641
  );
@@ -3527,16 +3692,25 @@ export class AuthStorage {
3527
3692
  credential: OAuthCredential,
3528
3693
  credentialId: number | undefined,
3529
3694
  signal?: AbortSignal,
3530
- ): Promise<OAuthCredentials> {
3695
+ force = false,
3696
+ mcpClient: MCPOAuthRefreshClient = {},
3697
+ ): Promise<RefreshedOAuthCredentials> {
3531
3698
  if (credentialId !== undefined) {
3532
3699
  const existing = this.#oauthCredentialRefreshInFlight.get(credentialId);
3533
3700
  if (existing) return raceCredentialRefreshWithSignal(existing, signal);
3534
3701
  }
3535
- if (Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
3702
+ if (!force && Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
3536
3703
  if (credentialId === undefined) {
3537
- return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal);
3704
+ return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, force, mcpClient);
3538
3705
  }
3539
- const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId).finally(() => {
3706
+ const promise = this.#refreshOAuthCredentialUnshared(
3707
+ provider,
3708
+ credential,
3709
+ credentialId,
3710
+ undefined,
3711
+ force,
3712
+ mcpClient,
3713
+ ).finally(() => {
3540
3714
  this.#oauthCredentialRefreshInFlight.delete(credentialId);
3541
3715
  });
3542
3716
  this.#oauthCredentialRefreshInFlight.set(credentialId, promise);
@@ -3548,8 +3722,13 @@ export class AuthStorage {
3548
3722
  credential: OAuthCredential,
3549
3723
  credentialId: number | undefined,
3550
3724
  signal?: AbortSignal,
3551
- ): Promise<OAuthCredentials> {
3725
+ force = false,
3726
+ mcpClient: MCPOAuthRefreshClient = {},
3727
+ ): Promise<RefreshedOAuthCredentials> {
3552
3728
  let refreshPromise: Promise<OAuthCredentials>;
3729
+ let localDial = false;
3730
+ let refreshLease: OAuthRefreshLease | undefined;
3731
+
3553
3732
  // Caller override > store-level hook > local per-provider refresh.
3554
3733
  // `RemoteAuthCredentialStore` exposes the hook so a broker-backed gateway
3555
3734
  // routes refresh through the broker without explicit wiring.
@@ -3557,17 +3736,108 @@ export class AuthStorage {
3557
3736
  const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
3558
3737
  if (overrideRefresh && credentialId !== undefined) {
3559
3738
  refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
3560
- } else if (credential.mcpBinding) {
3561
- refreshPromise = refreshBoundMCPOAuthCredential(credential, {}, signal);
3562
3739
  } else {
3563
- const customProvider = getOAuthProvider(provider);
3564
- if (customProvider) {
3565
- if (!customProvider.refreshToken) {
3566
- throw new Error(`OAuth provider "${provider}" does not support token refresh`);
3740
+ // Stale-snapshot guard: before replaying our in-memory refresh token
3741
+ // upstream, re-read the persisted row. With several gjc processes
3742
+ // sharing one store, a peer may have already rotated the token; the
3743
+ // post-failure recovery (catch in #tryOAuthCredential) reloads AFTER
3744
+ // the replay, but by then the damage is upstream — providers with
3745
+ // refresh-token rotation + reuse detection (Anthropic) treat a
3746
+ // replayed rotated token as theft and can revoke the whole grant
3747
+ // family, killing the peer's freshly rotated, still-valid tokens
3748
+ // mid-request (observed as live-session 401 "OAuth access token has
3749
+ // been revoked" plus all-day `invalid_grant` refresh floods). Adopt
3750
+ // the persisted credential instead: skip the upstream call entirely
3751
+ // when it is still fresh (unless the caller demanded a force
3752
+ // refresh), otherwise refresh with the newest refresh token. Broker
3753
+ // snapshots never take this branch (their store exposes the refresh
3754
+ // hook above), so the redacted refresh sentinel cannot confuse the
3755
+ // comparison.
3756
+ if (credentialId !== undefined) {
3757
+ const claimLease = this.#store.claimOAuthRefreshLease?.bind(this.#store);
3758
+ if (claimLease) {
3759
+ const owner = this.#oauthRefreshLeaseOwner;
3760
+
3761
+ const deadline = Date.now() + OAUTH_REFRESH_LEASE_MS;
3762
+ for (;;) {
3763
+ if (signal?.aborted) throw new Error("OAuth token refresh aborted by caller");
3764
+ const claim = claimLease(
3765
+ credentialId,
3766
+ credential.refresh,
3767
+ force,
3768
+ owner,
3769
+ Date.now(),
3770
+ OAUTH_REFRESH_LEASE_MS,
3771
+ );
3772
+ if (claim.kind === "missing") throw new Error("OAuth refresh credential disappeared");
3773
+
3774
+ if (claim.kind === "claimed") {
3775
+ credential = claim.credential;
3776
+ refreshLease = claim.lease;
3777
+ break;
3778
+ }
3779
+ if (claim.kind === "adopted") {
3780
+ return {
3781
+ access: claim.credential.access,
3782
+ refresh: claim.credential.refresh,
3783
+ expires: claim.credential.expires,
3784
+ accountId: claim.credential.accountId,
3785
+ email: claim.credential.email,
3786
+ projectId: claim.credential.projectId,
3787
+ enterpriseUrl: claim.credential.enterpriseUrl,
3788
+ mcpBinding: claim.credential.mcpBinding,
3789
+ persistedByLease: true,
3790
+ };
3791
+ }
3792
+ if (Date.now() >= deadline) {
3793
+ throw new Error("OAuth token refresh ownership remained ambiguous");
3794
+ }
3795
+ await Bun.sleep(Math.min(50, Math.max(1, claim.expiresAt - Date.now())));
3796
+ }
3797
+ } else {
3798
+ const persisted = this.#store
3799
+ .listAuthCredentials(resolveOAuthStorageProvider(provider))
3800
+ .find(row => row.id === credentialId)?.credential;
3801
+ if (persisted?.type === "oauth" && persisted.refresh !== credential.refresh) {
3802
+ if (!force && Date.now() + OAUTH_REFRESH_SKEW_MS < persisted.expires) {
3803
+ return { ...persisted, persistedByLease: true };
3804
+ }
3805
+ credential = persisted;
3806
+ }
3807
+ }
3808
+ }
3809
+ // Replay guard: an attempt with this exact (id, token) pair failed
3810
+ // moments ago. The provider may have consumed the rotating token even
3811
+ // though we saw a failure (timeout, lost response), so replaying it
3812
+ // risks reuse-detection revocation. Surface the memoized failure
3813
+ // instead of dialing again; a peer's successful rotation changes the
3814
+ // token and therefore never hits this memo. Explicit force refreshes
3815
+ // bypass the check (a deliberate operator/broker retry must reach the
3816
+ // endpoint) but their failures are still recorded below.
3817
+ if (!force && credentialId !== undefined) {
3818
+ const memoKey = `${credentialId}:${credential.refresh}`;
3819
+ const memo = this.#recentOAuthRefreshFailures.get(memoKey);
3820
+ if (memo && memo.expiresAt > Date.now()) {
3821
+ throw memo.error;
3567
3822
  }
3568
- refreshPromise = customProvider.refreshToken(credential);
3823
+ }
3824
+ localDial = true;
3825
+ // Re-check the binding AFTER adoption: a persisted row may have
3826
+ // acquired (or always had) an MCP binding the stale snapshot lacked,
3827
+ // and its refresh token must only ever be sent to the bound token
3828
+ // endpoint.
3829
+ if (credential.mcpBinding) {
3830
+ refreshPromise = refreshBoundMCPOAuthCredential(credential, mcpClient, signal);
3569
3831
  } else {
3570
- refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
3832
+ const customProvider = getOAuthProvider(provider);
3833
+ if (customProvider) {
3834
+ if (!customProvider.refreshToken) {
3835
+ throw new Error(`OAuth provider "${provider}" does not support token refresh`);
3836
+ }
3837
+ refreshPromise = customProvider.refreshToken(credential);
3838
+ } else {
3839
+ refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
3840
+ }
3571
3841
  }
3572
3842
  }
3573
3843
  // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
@@ -3589,7 +3859,43 @@ export class AuthStorage {
3589
3859
  }
3590
3860
  }
3591
3861
  try {
3592
- return await Promise.race([refreshPromise, cancellation.promise]);
3862
+ const refreshed = await Promise.race([refreshPromise, cancellation.promise]);
3863
+ // Return the FULL authority of the effective credential: rotated
3864
+ // tokens from upstream plus the identity metadata and MCP binding of
3865
+ // the (possibly guard-adopted) credential that was actually
3866
+ // refreshed. Callers persist from this shape; rebuilding it from
3867
+ // their stale snapshots would strip an adopted binding — sending the
3868
+ // next refresh token to the wrong endpoint — or relabel rotated
3869
+ // tokens with stale identity.
3870
+ const authority: RefreshedOAuthCredentials = {
3871
+ ...refreshed,
3872
+ accountId: refreshed.accountId ?? credential.accountId,
3873
+ email: refreshed.email ?? credential.email,
3874
+ projectId: refreshed.projectId ?? credential.projectId,
3875
+ enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
3876
+ mcpBinding: (refreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
3877
+ };
3878
+ if (refreshLease) {
3879
+ const completeLease = this.#store.completeOAuthRefreshLease?.bind(this.#store);
3880
+ const { persistedByLease: _persistedByLease, ...persistedCredentials } = authority;
3881
+ const persisted: OAuthCredential = { type: "oauth", ...persistedCredentials };
3882
+ if (!completeLease?.(refreshLease, persisted)) {
3883
+ throw new Error("OAuth token refresh ownership was lost before persistence");
3884
+ }
3885
+ authority.persistedByLease = true;
3886
+ }
3887
+ return authority;
3888
+ } catch (error) {
3889
+ if (localDial && credentialId !== undefined) {
3890
+ for (const [key, entry] of this.#recentOAuthRefreshFailures) {
3891
+ if (entry.expiresAt <= Date.now()) this.#recentOAuthRefreshFailures.delete(key);
3892
+ }
3893
+ this.#recentOAuthRefreshFailures.set(`${credentialId}:${credential.refresh}`, {
3894
+ expiresAt: Date.now() + OAUTH_REFRESH_FAILURE_REPLAY_GUARD_MS,
3895
+ error,
3896
+ });
3897
+ }
3898
+ throw tagRefreshAttempt(error, credential.refresh);
3593
3899
  } finally {
3594
3900
  if (timeout) clearTimeout(timeout);
3595
3901
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
@@ -3686,6 +3992,10 @@ export class AuthStorage {
3686
3992
 
3687
3993
  try {
3688
3994
  let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
3995
+ // The refresh result carries the effective (possibly guard-adopted)
3996
+ // credential's binding; `updated` must persist it or the next refresh
3997
+ // of an MCP-bound row would dial the plain provider endpoint.
3998
+ let refreshedAuthority: RefreshedOAuthCredentials;
3689
3999
  const customProvider = getOAuthProvider(provider);
3690
4000
  if (customProvider) {
3691
4001
  const refreshedCredentials = await this.#refreshOAuthCredential(
@@ -3694,6 +4004,7 @@ export class AuthStorage {
3694
4004
  this.#getStoredCredentials(provider)[selection.index]?.id,
3695
4005
  options?.signal,
3696
4006
  );
4007
+ refreshedAuthority = refreshedCredentials;
3697
4008
  const apiKey = customProvider.getApiKey
3698
4009
  ? customProvider.getApiKey(refreshedCredentials)
3699
4010
  : refreshedCredentials.access;
@@ -3710,6 +4021,7 @@ export class AuthStorage {
3710
4021
  this.#getStoredCredentials(provider)[selection.index]?.id,
3711
4022
  options?.signal,
3712
4023
  );
4024
+ refreshedAuthority = refreshedCredentials;
3713
4025
  const oauthCreds: Record<string, OAuthCredentials> = {
3714
4026
  [provider]: refreshedCredentials,
3715
4027
  };
@@ -3725,8 +4037,10 @@ export class AuthStorage {
3725
4037
  email: result.newCredentials.email ?? selection.credential.email,
3726
4038
  projectId: result.newCredentials.projectId ?? selection.credential.projectId,
3727
4039
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
4040
+ mcpBinding: refreshedAuthority.mcpBinding,
3728
4041
  };
3729
- this.#replaceCredentialAt(provider, selection.index, updated);
4042
+ this.#replaceCredentialAt(provider, selection.index, updated, !refreshedAuthority.persistedByLease);
4043
+
3730
4044
  if ((checkUsage && !allowBlocked) || requiresProModel) {
3731
4045
  const sameAccount = selection.credential.accountId === updated.accountId;
3732
4046
  if (!usageChecked || !sameAccount) {
@@ -3765,11 +4079,17 @@ export class AuthStorage {
3765
4079
  // multiple gjc processes sharing the store, the stale-snapshot failure
3766
4080
  // would otherwise be misclassified as transient and the credential
3767
4081
  // temp-blocked on every rotation race.
4082
+ // Compare against the refresh token that was ACTUALLY sent upstream.
4083
+ // The refresh helper may have adopted the persisted row's newer token
4084
+ // (stale-snapshot guard); comparing the row against our even-staler
4085
+ // selection snapshot would misread that adoption as a fresh peer
4086
+ // rotation and loop reload-retry instead of classifying the failure.
4087
+ const attemptedRefreshToken = getAttemptedRefreshToken(error) ?? selection.credential.refresh;
3768
4088
  const attemptedCredentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3769
4089
  if (attemptedCredentialId !== undefined) {
3770
4090
  const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
3771
4091
  const latestCredential = latestRow?.credential;
3772
- if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
4092
+ if (latestCredential?.type === "oauth" && latestCredential.refresh !== attemptedRefreshToken) {
3773
4093
  logger.debug("OAuth refresh race detected; another process rotated token first", {
3774
4094
  provider,
3775
4095
  index: selection.index,
@@ -3817,7 +4137,7 @@ export class AuthStorage {
3817
4137
  // peer rotation to clobber — so apply it directly instead of looping.
3818
4138
  const stillHoldsAttemptedToken =
3819
4139
  attemptedCredentialId !== undefined &&
3820
- this.#credentialRowHoldsRefreshToken(provider, attemptedCredentialId, selection.credential.refresh);
4140
+ this.#credentialRowHoldsRefreshToken(provider, attemptedCredentialId, attemptedRefreshToken);
3821
4141
  if (stillHoldsAttemptedToken && attemptedCredentialId !== undefined) {
3822
4142
  logger.warn("OAuth refresh disable CAS mismatched an unrotated row; disabling by id", {
3823
4143
  provider,
@@ -3908,9 +4228,13 @@ export class AuthStorage {
3908
4228
  })();
3909
4229
  return promise;
3910
4230
  }
4231
+ #storedApiKeyUsability(provider: string, key: string): boolean | undefined {
4232
+ if (!key.startsWith("!")) return true;
4233
+ return this.#resolvedStoredApiKeyValues.get(resolveOAuthStorageProvider(provider))?.get(key)?.usable;
4234
+ }
4235
+
3911
4236
  #hasUsableResolvedStoredApiKey(provider: string, key: string): boolean {
3912
- const resolved = this.#resolvedStoredApiKeyValues.get(resolveOAuthStorageProvider(provider))?.get(key);
3913
- return key.startsWith("!") ? resolved?.usable === true : true;
4237
+ return this.#storedApiKeyUsability(provider, key) === true;
3914
4238
  }
3915
4239
 
3916
4240
  /**
@@ -3920,6 +4244,7 @@ export class AuthStorage {
3920
4244
  * routing metadata so discovery can hit the correct host.
3921
4245
  */
3922
4246
  async peekApiKey(provider: string): Promise<string | undefined> {
4247
+ provider = resolveOAuthStorageProvider(provider);
3923
4248
  const runtimeKey = this.#runtimeOverrides.get(provider);
3924
4249
  if (runtimeKey) return runtimeKey;
3925
4250
 
@@ -3946,11 +4271,13 @@ export class AuthStorage {
3946
4271
  return undefined;
3947
4272
  }
3948
4273
 
3949
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key", undefined, credential =>
3950
- this.#hasUsableResolvedStoredApiKey(provider, credential.key),
3951
- );
3952
- if (apiKeySelection) {
3953
- return this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
4274
+ const attemptedApiKeyIndices = new Set<number>();
4275
+ for (;;) {
4276
+ const apiKeySelection = this.#selectApiKeyCredential(provider, undefined, attemptedApiKeyIndices);
4277
+ if (!apiKeySelection) break;
4278
+ attemptedApiKeyIndices.add(apiKeySelection.index);
4279
+ const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
4280
+ if (resolved) return resolved;
3954
4281
  }
3955
4282
 
3956
4283
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
@@ -3975,19 +4302,20 @@ export class AuthStorage {
3975
4302
  * Priority:
3976
4303
  * 1. Runtime override (CLI --api-key)
3977
4304
  * 2. Config override (models.yml `providers.<name>.apiKey`)
3978
- * 3. API key from storage
3979
- * 4. OAuth token from storage (auto-refreshed)
3980
- * 5. Environment variable
3981
- * 6. Fallback resolver (models.yml custom providers, last-resort)
4305
+ * 3. Session-selected OAuth credential, when present
4306
+ * 4. Usable or unresolved API key from storage
4307
+ * 5. OAuth token from storage (auto-refreshed)
4308
+ * 6. Previously unusable command-backed API key retry
4309
+ * 7. Environment variable
4310
+ * 8. Fallback resolver (models.yml custom providers, last-resort)
3982
4311
  */
3983
4312
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
4313
+ provider = resolveOAuthStorageProvider(provider);
3984
4314
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options);
3985
4315
 
3986
- // Runtime override takes highest priority
4316
+ // Runtime override takes highest priority after selector validation.
3987
4317
  const runtimeKey = this.#runtimeOverrides.get(provider);
3988
- if (runtimeKey) {
3989
- return runtimeKey;
3990
- }
4318
+ if (runtimeKey) return runtimeKey;
3991
4319
 
3992
4320
  // Config override: explicit apiKey pinned in models.yml beats the broker's
3993
4321
  // OAuth credentials. The user redirected a provider at a custom baseUrl
@@ -3995,28 +4323,50 @@ export class AuthStorage {
3995
4323
  // honor it instead of forwarding an upstream OAuth token that the proxy
3996
4324
  // won't accept.
3997
4325
  const configKey = this.#configOverrides.get(provider);
3998
- if (configKey) {
3999
- return configKey;
4000
- }
4326
+ if (configKey) return configKey;
4001
4327
 
4002
4328
  if (selectedCredential?.credential.type === "api_key") {
4003
4329
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
4004
4330
  return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
4005
4331
  }
4006
4332
 
4333
+ let oauthAttempted = false;
4334
+ if (!selectedCredential && this.#getSessionCredential(provider, sessionId)?.type === "oauth") {
4335
+ const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
4336
+ oauthAttempted = true;
4337
+ if (oauthResolved) return oauthResolved.apiKey;
4338
+ }
4339
+
4340
+ const attemptedApiKeyIndices = new Set<number>();
4007
4341
  if (!selectedCredential) {
4008
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId, credential =>
4009
- this.#hasUsableResolvedStoredApiKey(provider, credential.key),
4010
- );
4011
- if (apiKeySelection) {
4012
- this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
4013
- return this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
4342
+ for (;;) {
4343
+ const apiKeySelection = this.#selectApiKeyCredential(provider, sessionId, attemptedApiKeyIndices);
4344
+ if (!apiKeySelection) break;
4345
+ attemptedApiKeyIndices.add(apiKeySelection.index);
4346
+ const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
4347
+ if (resolved) {
4348
+ this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
4349
+ return resolved;
4350
+ }
4014
4351
  }
4015
4352
  }
4016
4353
 
4017
- const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
4018
- if (oauthResolved) {
4019
- return oauthResolved.apiKey;
4354
+ if (!oauthAttempted) {
4355
+ const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
4356
+ if (oauthResolved) return oauthResolved.apiKey;
4357
+ }
4358
+
4359
+ if (!selectedCredential) {
4360
+ for (;;) {
4361
+ const apiKeySelection = this.#selectApiKeyCredential(provider, sessionId, attemptedApiKeyIndices, true);
4362
+ if (!apiKeySelection) break;
4363
+ attemptedApiKeyIndices.add(apiKeySelection.index);
4364
+ const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
4365
+ if (resolved) {
4366
+ this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
4367
+ return resolved;
4368
+ }
4369
+ }
4020
4370
  }
4021
4371
 
4022
4372
  // Fall back to environment variable or custom resolver. If we reach here after
@@ -4026,8 +4376,6 @@ export class AuthStorage {
4026
4376
  if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
4027
4377
  const envKey = getEnvApiKey(provider);
4028
4378
  if (envKey) return envKey;
4029
-
4030
- // Fall back to custom resolver (e.g., models.json custom providers)
4031
4379
  return this.#fallbackResolver?.(provider) ?? undefined;
4032
4380
  }
4033
4381
 
@@ -4051,6 +4399,7 @@ export class AuthStorage {
4051
4399
  sessionId?: string,
4052
4400
  options?: AuthApiKeyOptions,
4053
4401
  ): Promise<OAuthAccess | undefined> {
4402
+ provider = resolveOAuthStorageProvider(provider);
4054
4403
  // Runtime / config overrides intentionally short-circuit OAuth: when the
4055
4404
  // user has pinned an API key, they expect the OAuth identity to be
4056
4405
  // suppressed (same contract as `getOAuthAccountId`).
@@ -4105,11 +4454,12 @@ export class AuthStorage {
4105
4454
  ): Promise<boolean> {
4106
4455
  const signal = isAbortSignalOption(optionsOrSignal) ? optionsOrSignal : optionsOrSignal?.signal;
4107
4456
  const sessionId = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.sessionId;
4108
- const stored = this.#getStoredCredentials(provider);
4457
+ const storageProvider = resolveOAuthStorageProvider(provider);
4458
+ const stored = this.#getStoredCredentials(storageProvider);
4109
4459
  let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
4110
4460
  for (let index = 0; index < stored.length; index++) {
4111
4461
  const entry = stored[index];
4112
- if (entry && (await this.#credentialMatchesApiKey(provider, entry.credential, apiKey))) {
4462
+ if (entry && (await this.#credentialMatchesApiKey(storageProvider, entry.credential, apiKey))) {
4113
4463
  matched = { id: entry.id, type: entry.credential.type, index };
4114
4464
  break;
4115
4465
  }
@@ -4120,9 +4470,9 @@ export class AuthStorage {
4120
4470
  return false;
4121
4471
  }
4122
4472
 
4123
- this.#clearSessionCredential(provider, sessionId);
4473
+ this.#clearSessionCredential(storageProvider, sessionId);
4124
4474
  this.#markCredentialBlocked(
4125
- this.#getProviderTypeKey(provider, matched.type),
4475
+ this.#getProviderTypeKey(storageProvider, matched.type),
4126
4476
  matched.index,
4127
4477
  Date.now() + AuthStorage.#defaultBackoffMs,
4128
4478
  );
@@ -4134,9 +4484,9 @@ export class AuthStorage {
4134
4484
  await this.reload();
4135
4485
  }
4136
4486
 
4137
- const latestRows = this.#store.listAuthCredentials(provider);
4487
+ const latestRows = this.#store.listAuthCredentials(storageProvider);
4138
4488
  this.#setStoredCredentials(
4139
- provider,
4489
+ storageProvider,
4140
4490
  latestRows.map(row => ({ id: row.id, credential: row.credential })),
4141
4491
  );
4142
4492
  return true;
@@ -4244,20 +4594,19 @@ export class AuthStorage {
4244
4594
  if (target.credential.type !== "oauth") {
4245
4595
  throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
4246
4596
  }
4247
- // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
4248
- // in #refreshOAuthCredential doesn't suppress the requested refresh.
4597
+ // Pass a clone with expires=0 plus explicit force intent so neither
4598
+ // the cached not-yet-expired short-circuit in #refreshOAuthCredential
4599
+ // nor the stale-snapshot guard's fresh-row adoption suppresses the
4600
+ // requested refresh. The guard still substitutes the newest persisted
4601
+ // refresh token when a peer rotated the row.
4249
4602
  const stale: OAuthCredential = { ...target.credential, expires: 0 };
4250
- let refreshed: OAuthCredentials;
4251
- if (target.credential.mcpBinding) {
4603
+ let refreshed: RefreshedOAuthCredentials;
4604
+ const remoteRefresh = this.#store.refreshMCPOAuthCredential?.bind(this.#store);
4605
+ if (target.credential.mcpBinding && remoteRefresh) {
4606
+ // Remote (broker) stores refresh MCP-bound rows server-side; the
4607
+ // server runs the guarded local path against its own row.
4252
4608
  assertCanonicalMCPOAuthBinding(target.credential.mcpBinding);
4253
- const remoteRefresh = this.#store.refreshMCPOAuthCredential?.bind(this.#store);
4254
- const refreshedCredential = remoteRefresh
4255
- ? await remoteRefresh(id, stale, mcpClient, signal)
4256
- : {
4257
- type: "oauth" as const,
4258
- ...(await refreshBoundMCPOAuthCredential(stale, mcpClient, signal)),
4259
- mcpBinding: target.credential.mcpBinding,
4260
- };
4609
+ const refreshedCredential = await remoteRefresh(id, stale, mcpClient, signal);
4261
4610
  if (
4262
4611
  refreshedCredential.mcpBinding?.resourceOrigin !== target.credential.mcpBinding.resourceOrigin ||
4263
4612
  refreshedCredential.mcpBinding.tokenEndpoint !== target.credential.mcpBinding.tokenEndpoint
@@ -4265,8 +4614,29 @@ export class AuthStorage {
4265
4614
  throw new Error("Refreshed MCP OAuth credential binding mismatch");
4266
4615
  }
4267
4616
  refreshed = refreshedCredential;
4617
+ } else if (target.credential.mcpBinding) {
4618
+ // Local forced refresh of a bound row dials the unshared path
4619
+ // DIRECTLY so the caller's cancellation signal reaches the bound
4620
+ // token fetch (documented contract) — while still running the
4621
+ // stale-snapshot guard, memo recording, and failure provenance.
4622
+ // The outer #oauthRefreshInFlight map already single-flights
4623
+ // concurrent public force callers per row.
4624
+ assertCanonicalMCPOAuthBinding(target.credential.mcpBinding);
4625
+ refreshed = await this.#refreshOAuthCredentialUnshared(
4626
+ provider as Provider,
4627
+ stale,
4628
+ id,
4629
+ signal,
4630
+ true,
4631
+ mcpClient,
4632
+ );
4268
4633
  } else {
4269
- refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
4634
+ // Plain local force refresh routes through the guarded, memoized,
4635
+ // single-flighted path: the stale-snapshot guard adopts a
4636
+ // peer-rotated row (tokens AND binding) before dispatch, so a
4637
+ // forced refresh never replays a rotated token or dials a stale
4638
+ // endpoint. The returned authority carries the effective binding.
4639
+ refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal, true, mcpClient);
4270
4640
  }
4271
4641
  const updated: OAuthCredential = {
4272
4642
  type: "oauth",
@@ -4277,9 +4647,9 @@ export class AuthStorage {
4277
4647
  email: refreshed.email ?? target.credential.email,
4278
4648
  projectId: refreshed.projectId ?? target.credential.projectId,
4279
4649
  enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
4280
- mcpBinding: target.credential.mcpBinding,
4650
+ mcpBinding: refreshed.mcpBinding,
4281
4651
  };
4282
- this.#replaceCredentialAt(provider, index, updated);
4652
+ this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease);
4283
4653
  return {
4284
4654
  id,
4285
4655
  provider,
@@ -4664,6 +5034,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4664
5034
  expires_at INTEGER NOT NULL
4665
5035
  );
4666
5036
  CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
5037
+ CREATE TABLE IF NOT EXISTS oauth_refresh_leases (
5038
+ credential_id INTEGER PRIMARY KEY,
5039
+ owner TEXT NOT NULL,
5040
+ token_fingerprint TEXT NOT NULL,
5041
+ expires_at INTEGER NOT NULL
5042
+ );
5043
+ CREATE INDEX IF NOT EXISTS idx_oauth_refresh_leases_expires ON oauth_refresh_leases(expires_at);
4667
5044
  `);
4668
5045
 
4669
5046
  if (!this.#authCredentialsTableExists()) {
@@ -4864,6 +5241,98 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4864
5241
  }
4865
5242
  return results;
4866
5243
  }
5244
+ claimOAuthRefreshLease(
5245
+ credentialId: number,
5246
+ expectedRefresh: string,
5247
+ force: boolean,
5248
+ owner: string,
5249
+ nowMs: number,
5250
+ leaseMs: number,
5251
+ ): OAuthRefreshLeaseClaim {
5252
+ const claim = this.#db.transaction((): OAuthRefreshLeaseClaim => {
5253
+ const row = this.#db
5254
+ .prepare(
5255
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
5256
+ )
5257
+ .get(credentialId) as AuthRow | undefined;
5258
+ const credential = row ? deserializeCredential(row) : null;
5259
+ if (credential?.type !== "oauth") return { kind: "missing" };
5260
+ if (!force && credential.refresh !== expectedRefresh && nowMs + OAUTH_REFRESH_SKEW_MS < credential.expires) {
5261
+ return { kind: "adopted", credential };
5262
+ }
5263
+ const active = this.#db
5264
+ .prepare("SELECT owner, expires_at FROM oauth_refresh_leases WHERE credential_id = ?")
5265
+ .get(credentialId) as { owner?: string; expires_at?: number } | undefined;
5266
+ if (typeof active?.expires_at === "number" && active.expires_at > nowMs) {
5267
+ if (active.owner === owner) {
5268
+ this.#db
5269
+ .prepare("UPDATE oauth_refresh_leases SET expires_at = ? WHERE credential_id = ? AND owner = ?")
5270
+ .run(nowMs + leaseMs, credentialId, owner);
5271
+ return {
5272
+ kind: "claimed",
5273
+ credential,
5274
+ lease: {
5275
+ credentialId,
5276
+ owner,
5277
+ tokenFingerprint: crypto.createHash("sha256").update(credential.refresh).digest("hex"),
5278
+ },
5279
+ };
5280
+ }
5281
+ return { kind: "busy", expiresAt: active.expires_at };
5282
+ }
5283
+
5284
+ this.#db.prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ?").run(credentialId);
5285
+ const tokenFingerprint = crypto.createHash("sha256").update(credential.refresh).digest("hex");
5286
+ this.#db
5287
+ .prepare(
5288
+ "INSERT INTO oauth_refresh_leases (credential_id, owner, token_fingerprint, expires_at) VALUES (?, ?, ?, ?)",
5289
+ )
5290
+ .run(credentialId, owner, tokenFingerprint, nowMs + leaseMs);
5291
+ return { kind: "claimed", credential, lease: { credentialId, owner, tokenFingerprint } };
5292
+ });
5293
+ return claim();
5294
+ }
5295
+
5296
+ completeOAuthRefreshLease(lease: OAuthRefreshLease, credential: OAuthCredential): boolean {
5297
+ const serialized = serializeCredential(
5298
+ (
5299
+ this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(lease.credentialId) as
5300
+ | { provider?: string }
5301
+ | undefined
5302
+ )?.provider ?? "",
5303
+ credential,
5304
+ );
5305
+ if (!serialized) return false;
5306
+ const complete = this.#db.transaction(() => {
5307
+ const leaseRow = this.#db
5308
+ .prepare("SELECT owner, token_fingerprint FROM oauth_refresh_leases WHERE credential_id = ?")
5309
+ .get(lease.credentialId) as { owner?: string; token_fingerprint?: string } | undefined;
5310
+ if (leaseRow?.owner !== lease.owner || leaseRow.token_fingerprint !== lease.tokenFingerprint) return false;
5311
+ const row = this.#db
5312
+ .prepare("SELECT data FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL")
5313
+ .get(lease.credentialId) as { data?: string } | undefined;
5314
+ if (
5315
+ !row ||
5316
+ crypto
5317
+ .createHash("sha256")
5318
+ .update((JSON.parse(row.data ?? "{}") as { refresh?: string }).refresh ?? "")
5319
+ .digest("hex") !== lease.tokenFingerprint
5320
+ )
5321
+ return false;
5322
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, lease.credentialId);
5323
+ this.#db
5324
+ .prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?")
5325
+ .run(lease.credentialId, lease.owner);
5326
+ return true;
5327
+ });
5328
+ return complete();
5329
+ }
5330
+
5331
+ releaseOAuthRefreshLease(lease: OAuthRefreshLease): void {
5332
+ this.#db
5333
+ .prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?")
5334
+ .run(lease.credentialId, lease.owner);
5335
+ }
4867
5336
 
4868
5337
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
4869
5338
  const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {