@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.
- package/CHANGELOG.md +8 -1
- package/dist/types/auth-storage.d.ts +46 -4
- package/dist/types/context-cap-policy.d.ts +46 -1
- package/dist/types/providers/mock.d.ts +3 -1
- package/dist/types/providers/openai-responses.d.ts +2 -1
- package/dist/types/utils/fallback-transport.d.ts +2 -0
- package/dist/types/utils/idle-iterator.d.ts +1 -0
- package/package.json +2 -2
- package/src/auth-storage.ts +560 -91
- package/src/context-cap-policy.ts +52 -0
- package/src/providers/anthropic.ts +22 -2
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-responses.ts +34 -2
- package/src/utils/fallback-transport.ts +5 -2
- package/src/utils/idle-iterator.ts +6 -0
- package/src/utils/overflow.ts +1 -0
package/src/auth-storage.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
1212
|
-
this.#
|
|
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
|
-
|
|
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
|
|
1369
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1370
|
+
const session = this.#getSessionCredential(storageProvider, sessionId);
|
|
1275
1371
|
if (!session) return undefined;
|
|
1276
|
-
return this.#getStoredCredentials(
|
|
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
|
-
|
|
1291
|
-
this.#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2126
|
+
hasUnblockedApiKey = true;
|
|
1970
2127
|
hasUsableApiKey ||= this.#hasUsableResolvedStoredApiKey(storageProvider, credential.key);
|
|
1971
2128
|
}
|
|
1972
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !==
|
|
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,
|
|
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
|
-
|
|
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
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
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.
|
|
3979
|
-
* 4.
|
|
3980
|
-
* 5.
|
|
3981
|
-
* 6.
|
|
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
|
-
|
|
4009
|
-
this.#
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
this.#
|
|
4013
|
-
|
|
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
|
-
|
|
4018
|
-
|
|
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
|
|
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(
|
|
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(
|
|
4473
|
+
this.#clearSessionCredential(storageProvider, sessionId);
|
|
4124
4474
|
this.#markCredentialBlocked(
|
|
4125
|
-
this.#getProviderTypeKey(
|
|
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(
|
|
4487
|
+
const latestRows = this.#store.listAuthCredentials(storageProvider);
|
|
4138
4488
|
this.#setStoredCredentials(
|
|
4139
|
-
|
|
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
|
|
4248
|
-
//
|
|
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:
|
|
4251
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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[]) => {
|