@gajae-code/ai 0.15.5 → 0.15.6
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 +15 -0
- package/dist/types/auth-broker/client.d.ts +6 -2
- package/dist/types/auth-broker/remote-store.d.ts +14 -2
- package/dist/types/auth-broker/types.d.ts +6 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
- package/dist/types/auth-gateway/server.d.ts +39 -5
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/auth-storage.d.ts +92 -24
- package/dist/types/provider-models/openai-compat.d.ts +1 -0
- package/dist/types/providers/register-builtins.d.ts +12 -12
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/types.d.ts +22 -2
- package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
- package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
- package/dist/types/utils/oauth/commandcode.d.ts +1 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/dist/types/utils/retry.d.ts +2 -0
- package/package.json +3 -3
- package/src/auth-broker/client.ts +41 -13
- package/src/auth-broker/redact.ts +25 -1
- package/src/auth-broker/remote-store.ts +374 -115
- package/src/auth-broker/server.ts +131 -91
- package/src/auth-broker/types.ts +6 -0
- package/src/auth-broker/wire-schemas.ts +6 -0
- package/src/auth-gateway/server.ts +447 -79
- package/src/auth-gateway/types.ts +28 -2
- package/src/auth-storage.ts +658 -154
- package/src/cli.ts +1 -0
- package/src/models.json +1023 -0
- package/src/provider-models/descriptors.ts +3 -1
- package/src/provider-models/openai-compat.ts +41 -1
- package/src/providers/anthropic.ts +7 -1
- package/src/providers/azure-openai-responses.ts +4 -1
- package/src/providers/cursor.ts +256 -101
- package/src/providers/gitlab-duo.ts +18 -1
- package/src/providers/google-gemini-cli.ts +3 -0
- package/src/providers/google-shared.ts +3 -0
- package/src/providers/kiro-codewhisperer.ts +24 -8
- package/src/providers/ollama.ts +3 -0
- package/src/providers/openai-codex-responses.ts +20 -6
- package/src/providers/openai-completions.ts +11 -1
- package/src/providers/openai-responses.ts +10 -1
- package/src/providers/pi-native-client.ts +1 -0
- package/src/providers/pi-native-server.ts +24 -0
- package/src/providers/register-builtins.d.ts +12 -12
- package/src/providers/register-builtins.ts +16 -3
- package/src/stream.d.ts +2 -1
- package/src/stream.ts +175 -67
- package/src/types.d.ts +22 -2
- package/src/types.ts +27 -1
- package/src/utils/oauth/api-key-login.ts +13 -2
- package/src/utils/oauth/api-key-validation.ts +242 -41
- package/src/utils/oauth/commandcode.ts +17 -0
- package/src/utils/oauth/index.ts +20 -5
- package/src/utils/oauth/types.d.ts +1 -1
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/retry.d.ts +2 -0
- package/src/utils/retry.ts +15 -2
package/src/auth-storage.ts
CHANGED
|
@@ -47,6 +47,20 @@ export type ApiKeyCredential = {
|
|
|
47
47
|
key: string;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Extracts the bearer token from the structured API-key form used by OAuth
|
|
52
|
+
* providers that need to carry token metadata alongside the access token.
|
|
53
|
+
*/
|
|
54
|
+
export function extractStructuredApiKeyToken(apiKey: string): string | undefined {
|
|
55
|
+
if (!apiKey.startsWith("{")) return undefined;
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(apiKey) as { token?: unknown };
|
|
58
|
+
return typeof parsed.token === "string" ? parsed.token : undefined;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
export interface MCPOAuthBinding {
|
|
51
65
|
/** Exact HTTP(S) origin of the MCP resource endpoint. */
|
|
52
66
|
resourceOrigin: string;
|
|
@@ -369,6 +383,7 @@ export interface AuthCredentialSnapshotEntry {
|
|
|
369
383
|
provider: string;
|
|
370
384
|
credential: SnapshotCredential;
|
|
371
385
|
identityKey: string | null;
|
|
386
|
+
revision?: number;
|
|
372
387
|
}
|
|
373
388
|
|
|
374
389
|
export type AuthCredentialIfAbsentReason =
|
|
@@ -430,8 +445,21 @@ export type OAuthRefreshLeaseClaim =
|
|
|
430
445
|
| { kind: "busy"; expiresAt: number }
|
|
431
446
|
| { kind: "missing" };
|
|
432
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Store-owned ticket that orders a provider admission against remote
|
|
450
|
+
* credential snapshot application. The ticket is intentionally released at
|
|
451
|
+
* provider admission, not response completion.
|
|
452
|
+
*/
|
|
453
|
+
export interface CredentialDispatchTicket {
|
|
454
|
+
release(): void;
|
|
455
|
+
}
|
|
456
|
+
|
|
433
457
|
export interface AuthCredentialStore {
|
|
434
458
|
close(): void;
|
|
459
|
+
refreshSnapshot?(signal?: AbortSignal): Promise<unknown>;
|
|
460
|
+
onSnapshotChanged?(listener: () => void): () => void;
|
|
461
|
+
/** Order provider admission with remote snapshot authority application. */
|
|
462
|
+
acquireCredentialDispatchTicket?(provider: Provider, signal?: AbortSignal): Promise<CredentialDispatchTicket>;
|
|
435
463
|
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
|
436
464
|
/** Payload-free account inventory; active and soft-disabled rows are included. */
|
|
437
465
|
listCredentialInventory?(provider?: string): CredentialInventoryRecord[];
|
|
@@ -445,12 +473,15 @@ export interface AuthCredentialStore {
|
|
|
445
473
|
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
446
474
|
deleteAuthCredential(id: number, disabledCause: string): void;
|
|
447
475
|
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
|
|
476
|
+
tryDisableAuthCredentialIfRevision?(id: number, expectedRevision: number, disabledCause: string): boolean;
|
|
448
477
|
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
|
|
449
478
|
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
|
|
450
479
|
upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
|
|
451
480
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
|
452
481
|
getCache(key: string, options?: { includeExpired?: boolean }): string | null;
|
|
453
482
|
setCache(key: string, value: string, expiresAtSec: number): void;
|
|
483
|
+
/** Atomically allocate a durable sequence for broker restart epochs. */
|
|
484
|
+
allocateMonotonicSequence(key: string, expiresAtSec: number): number;
|
|
454
485
|
deleteCachePrefix?(prefix: string): void;
|
|
455
486
|
cleanExpiredCache(): void;
|
|
456
487
|
/**
|
|
@@ -514,6 +545,7 @@ export interface AuthCredentialStore {
|
|
|
514
545
|
* `signal` propagates the agent's cancel down to the broker fetch.
|
|
515
546
|
*/
|
|
516
547
|
fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
|
|
548
|
+
fetchUsageReportsForProvider?(provider: Provider, signal?: AbortSignal): Promise<UsageReport[] | null>;
|
|
517
549
|
/** Synchronous, zero-network usage presentation peek. */
|
|
518
550
|
peekCachedUsagePresentation?(provider: Provider, credentialId: number): CachedUsagePresentation | undefined;
|
|
519
551
|
/** Record a safe usage observation after an explicit fetch/check. */
|
|
@@ -552,6 +584,21 @@ export interface AuthCredentialStore {
|
|
|
552
584
|
* {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`.
|
|
553
585
|
*/
|
|
554
586
|
markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
587
|
+
/**
|
|
588
|
+
* Optional async write hook to disable one credential through an authoritative
|
|
589
|
+
* remote store. Remote clients MUST use this hook instead of the synchronous
|
|
590
|
+
* local delete methods when an OAuth refresh fails definitively.
|
|
591
|
+
*
|
|
592
|
+
* Returns `false` when the row is already absent (for example, a peer
|
|
593
|
+
* disabled it first). Implementations MUST NOT treat a failed remote write as
|
|
594
|
+
* a successful local deletion.
|
|
595
|
+
*/
|
|
596
|
+
disableAuthCredentialRemote?(
|
|
597
|
+
credentialId: number,
|
|
598
|
+
disabledCause: string,
|
|
599
|
+
signal?: AbortSignal,
|
|
600
|
+
expectedRevision?: number,
|
|
601
|
+
): Promise<boolean>;
|
|
555
602
|
/**
|
|
556
603
|
* Optional async write hook for upserting a single credential. When present,
|
|
557
604
|
* `AuthStorage.#upsertOAuthCredential` routes through this instead of the
|
|
@@ -675,6 +722,7 @@ export type AuthStorageOptions = {
|
|
|
675
722
|
* AuthStorage caller surfaces that to its own consumer unchanged.
|
|
676
723
|
*/
|
|
677
724
|
fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
|
|
725
|
+
fetchUsageReportsForProvider?: (provider: Provider, signal?: AbortSignal) => Promise<UsageReport[] | null>;
|
|
678
726
|
};
|
|
679
727
|
|
|
680
728
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -995,9 +1043,11 @@ type UsageRequestDescriptor = {
|
|
|
995
1043
|
baseUrl?: string;
|
|
996
1044
|
};
|
|
997
1045
|
|
|
998
|
-
type AuthApiKeyOptions = {
|
|
1046
|
+
export type AuthApiKeyOptions = {
|
|
999
1047
|
baseUrl?: string;
|
|
1000
1048
|
modelId?: string;
|
|
1049
|
+
/** Select config registrations owned by one caller (for example a ModelRegistry). */
|
|
1050
|
+
owner?: object;
|
|
1001
1051
|
/**
|
|
1002
1052
|
* Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
|
|
1003
1053
|
* `ESC` / request abort actually kills a hung broker fetch instead of
|
|
@@ -1036,6 +1086,7 @@ export interface OAuthAccess {
|
|
|
1036
1086
|
export interface InvalidateCredentialMatchingOptions {
|
|
1037
1087
|
signal?: AbortSignal;
|
|
1038
1088
|
sessionId?: string;
|
|
1089
|
+
owner?: object;
|
|
1039
1090
|
}
|
|
1040
1091
|
|
|
1041
1092
|
function isAbortSignalOption(
|
|
@@ -1054,17 +1105,40 @@ function safeUsageReport(report: UsageReport): SafeUsageReport {
|
|
|
1054
1105
|
}
|
|
1055
1106
|
|
|
1056
1107
|
function scrubHealthReason(reason: unknown, secrets: readonly string[] = []): string {
|
|
1057
|
-
let value = reason instanceof Error ? reason.message : String(reason);
|
|
1108
|
+
let value = (reason instanceof Error ? reason.message : String(reason)).replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "");
|
|
1058
1109
|
for (const secret of secrets) {
|
|
1059
|
-
if (secret.length > 0)
|
|
1110
|
+
if (secret.length > 0) {
|
|
1111
|
+
value = value.split(secret).join("[redacted]");
|
|
1112
|
+
const escaped = [...secret].map(char => char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"));
|
|
1113
|
+
value = value.replace(new RegExp(escaped.join("\\s*"), "gu"), "[redacted]");
|
|
1114
|
+
}
|
|
1060
1115
|
}
|
|
1061
1116
|
value = value.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
|
|
1062
1117
|
value = value.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
|
|
1063
|
-
value = value
|
|
1118
|
+
value = value
|
|
1119
|
+
.replace(/[\x00-\x1f\x7f-\x9f]+/gu, " ")
|
|
1120
|
+
.replace(/[\r\n\t ]+/g, " ")
|
|
1121
|
+
.trim();
|
|
1064
1122
|
if (value.length > 256) value = `${value.slice(0, 253)}...`;
|
|
1065
1123
|
return value || "credential check failed";
|
|
1066
1124
|
}
|
|
1067
1125
|
|
|
1126
|
+
/** Read optional broker error detail without allowing hostile objects to escape classification. */
|
|
1127
|
+
/** @internal Tested directly because hostile accessors must preserve the original error identity. */
|
|
1128
|
+
export function readBrokerErrorBody(error: unknown): string | undefined {
|
|
1129
|
+
if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
|
|
1130
|
+
try {
|
|
1131
|
+
if (!("body" in error)) return undefined;
|
|
1132
|
+
const body = (error as { body?: unknown }).body;
|
|
1133
|
+
return typeof body === "string" ? body : undefined;
|
|
1134
|
+
} catch {
|
|
1135
|
+
// Preserve the original provider error and its terminal behavior. A proxy or
|
|
1136
|
+
// throwing accessor is untrusted provider data, not a reason to replace the
|
|
1137
|
+
// refresh failure with an inspection error.
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1068
1142
|
function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
|
|
1069
1143
|
return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
|
|
1070
1144
|
}
|
|
@@ -1213,7 +1287,15 @@ class AuthStorageUsageCache implements UsageCache {
|
|
|
1213
1287
|
// In-memory representation
|
|
1214
1288
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1215
1289
|
|
|
1216
|
-
type StoredCredential = { id: number; credential: AuthCredential };
|
|
1290
|
+
type StoredCredential = { id: number; credential: AuthCredential; revision?: number };
|
|
1291
|
+
type IndexedStoredCredential<T extends AuthCredential = AuthCredential> = {
|
|
1292
|
+
id: number;
|
|
1293
|
+
credential: T;
|
|
1294
|
+
index: number;
|
|
1295
|
+
revision?: number;
|
|
1296
|
+
};
|
|
1297
|
+
type OAuthCredentialSelection = IndexedStoredCredential<OAuthCredential>;
|
|
1298
|
+
type ConfigApiKeyRegistration = { apiKey: string; envSourced: boolean; order: number };
|
|
1217
1299
|
|
|
1218
1300
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1219
1301
|
// AuthStorage Class
|
|
@@ -1231,6 +1313,10 @@ export class AuthStorage {
|
|
|
1231
1313
|
#data: Map<string, StoredCredential[]> = new Map();
|
|
1232
1314
|
#runtimeOverrides: Map<string, string> = new Map();
|
|
1233
1315
|
#configOverrides: Map<string, string> = new Map();
|
|
1316
|
+
/** Effective config override registrations, including owner-scoped model registries. */
|
|
1317
|
+
#configOverrideRegistrations: Map<string, Map<object, ConfigApiKeyRegistration>> = new Map();
|
|
1318
|
+
#unownedConfigOverrides: Map<string, ConfigApiKeyRegistration> = new Map();
|
|
1319
|
+
#configOverrideOrder = 0;
|
|
1234
1320
|
/**
|
|
1235
1321
|
* Providers whose config override was resolved from a models.yml `apiKeyEnv`
|
|
1236
1322
|
* indirection rather than a literal `apiKey` pin. An env pointer is not a
|
|
@@ -1264,12 +1350,14 @@ export class AuthStorage {
|
|
|
1264
1350
|
#credentialRankingMode: CredentialRankingMode = "balanced";
|
|
1265
1351
|
#usageLogger?: UsageLogger;
|
|
1266
1352
|
#fallbackResolver?: (provider: string) => string | undefined;
|
|
1353
|
+
#ownedFallbackResolvers: Map<object, (provider: string) => string | undefined> = new Map();
|
|
1267
1354
|
#store: AuthCredentialStore;
|
|
1268
1355
|
#configValueResolver: (config: string, cacheScope?: string) => Promise<string | undefined>;
|
|
1269
1356
|
#resolvedStoredApiKeyValues: Map<string, Map<string, { fingerprint: string; usable: boolean }>> = new Map();
|
|
1270
1357
|
#storedApiKeyResolutionInFlight: Map<string, Map<string, Promise<string | undefined>>> = new Map();
|
|
1271
1358
|
#refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
|
|
1272
1359
|
#fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"];
|
|
1360
|
+
#fetchUsageReportsForProviderOverride?: AuthStorageOptions["fetchUsageReportsForProvider"];
|
|
1273
1361
|
#sourceLabel?: string;
|
|
1274
1362
|
#credentialDisabledListeners: Set<(event: CredentialDisabledEvent) => void | Promise<void>> = new Set();
|
|
1275
1363
|
/**
|
|
@@ -1299,6 +1387,10 @@ export class AuthStorage {
|
|
|
1299
1387
|
|
|
1300
1388
|
constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
|
|
1301
1389
|
this.#store = store;
|
|
1390
|
+
store.onSnapshotChanged?.(() => {
|
|
1391
|
+
this.#reloadCredentialRowsFromStore();
|
|
1392
|
+
void this.reload();
|
|
1393
|
+
});
|
|
1302
1394
|
this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
|
|
1303
1395
|
this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
|
|
1304
1396
|
this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
|
|
@@ -1308,6 +1400,7 @@ export class AuthStorage {
|
|
|
1308
1400
|
this.#credentialRankingMode = options.credentialRankingMode ?? "balanced";
|
|
1309
1401
|
this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
|
|
1310
1402
|
this.#fetchUsageReportsOverride = options.fetchUsageReports;
|
|
1403
|
+
this.#fetchUsageReportsForProviderOverride = options.fetchUsageReportsForProvider;
|
|
1311
1404
|
this.#sourceLabel = options.sourceLabel;
|
|
1312
1405
|
if (options.onCredentialDisabled) {
|
|
1313
1406
|
// Constructor-registered subscribers are permanent for this AuthStorage's lifetime;
|
|
@@ -1352,6 +1445,15 @@ export class AuthStorage {
|
|
|
1352
1445
|
getGeneration(): number {
|
|
1353
1446
|
return this.#generation;
|
|
1354
1447
|
}
|
|
1448
|
+
getCache(key: string, options?: { includeExpired?: boolean }): string | null {
|
|
1449
|
+
return this.#store.getCache(key, options);
|
|
1450
|
+
}
|
|
1451
|
+
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
1452
|
+
this.#store.setCache(key, value, expiresAtSec);
|
|
1453
|
+
}
|
|
1454
|
+
allocateMonotonicSequence(key: string, expiresAtSec: number): number {
|
|
1455
|
+
return this.#store.allocateMonotonicSequence(key, expiresAtSec);
|
|
1456
|
+
}
|
|
1355
1457
|
getProviderConfigurationGeneration(provider: string): number {
|
|
1356
1458
|
return this.#getProviderConfigurationGeneration(provider);
|
|
1357
1459
|
}
|
|
@@ -1364,13 +1466,29 @@ export class AuthStorage {
|
|
|
1364
1466
|
#getProviderConfigurationGeneration(provider: string): number {
|
|
1365
1467
|
return this.#providerConfigurationGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1;
|
|
1366
1468
|
}
|
|
1367
|
-
|
|
1469
|
+
#configOverrideRegistration(provider: string, owner?: object): ConfigApiKeyRegistration | undefined {
|
|
1470
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1471
|
+
if (owner) {
|
|
1472
|
+
return (
|
|
1473
|
+
this.#configOverrideRegistrations.get(storageProvider)?.get(owner) ??
|
|
1474
|
+
this.#unownedConfigOverrides.get(storageProvider)
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
const apiKey = this.#configOverrides.get(storageProvider);
|
|
1478
|
+
return apiKey === undefined
|
|
1479
|
+
? undefined
|
|
1480
|
+
: { apiKey, envSourced: this.#configOverrideEnvSourced.has(storageProvider), order: 0 };
|
|
1481
|
+
}
|
|
1482
|
+
#hasConfigOverride(provider: string, owner?: object): boolean {
|
|
1483
|
+
return this.#configOverrideRegistration(provider, owner) !== undefined;
|
|
1484
|
+
}
|
|
1485
|
+
getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string, owner?: object): string {
|
|
1368
1486
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1487
|
+
provider = storageProvider;
|
|
1369
1488
|
const evidenceApiKey = resolvedApiKey;
|
|
1489
|
+
const configOverride = this.#configOverrideRegistration(storageProvider, owner);
|
|
1370
1490
|
const storedLiteral =
|
|
1371
|
-
evidenceApiKey === undefined ||
|
|
1372
|
-
this.#runtimeOverrides.has(storageProvider) ||
|
|
1373
|
-
this.#configOverrides.has(storageProvider)
|
|
1491
|
+
evidenceApiKey === undefined || this.#runtimeOverrides.has(storageProvider) || configOverride !== undefined
|
|
1374
1492
|
? undefined
|
|
1375
1493
|
: this.#getCredentialsForProvider(storageProvider).find(
|
|
1376
1494
|
(credential): credential is Extract<AuthCredential, { type: "api_key" }> =>
|
|
@@ -1387,7 +1505,7 @@ export class AuthStorage {
|
|
|
1387
1505
|
}
|
|
1388
1506
|
let selectedCredential: ({ index: number } & StoredCredential) | undefined;
|
|
1389
1507
|
try {
|
|
1390
|
-
selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
|
|
1508
|
+
selectedCredential = this.#resolveSelectedStoredCredential(provider, owner ? { owner } : undefined, undefined);
|
|
1391
1509
|
} catch {
|
|
1392
1510
|
return crypto
|
|
1393
1511
|
.createHash("sha256")
|
|
@@ -1403,7 +1521,7 @@ export class AuthStorage {
|
|
|
1403
1521
|
credential.type === "oauth" && Number.isFinite(credential.expires) && credential.expires > Date.now(),
|
|
1404
1522
|
);
|
|
1405
1523
|
const effectiveEnvKey =
|
|
1406
|
-
this.#runtimeOverrides.get(provider) ||
|
|
1524
|
+
this.#runtimeOverrides.get(provider) || configOverride?.apiKey || hasApiKey || hasUsableOAuth
|
|
1407
1525
|
? undefined
|
|
1408
1526
|
: getEnvApiKey(provider);
|
|
1409
1527
|
const storedApiKeyFingerprint = credentials
|
|
@@ -1551,12 +1669,21 @@ export class AuthStorage {
|
|
|
1551
1669
|
}
|
|
1552
1670
|
}
|
|
1553
1671
|
|
|
1554
|
-
/**
|
|
1555
|
-
|
|
1672
|
+
/**
|
|
1673
|
+
* Set the selector derived from a durable session pin or a session seed.
|
|
1674
|
+
* `owner` scopes config-override validation to one ModelRegistry; omitted
|
|
1675
|
+
* owners retain process-wide caller semantics.
|
|
1676
|
+
*/
|
|
1677
|
+
setSessionCredentialSelector(
|
|
1678
|
+
scopeId: string,
|
|
1679
|
+
provider: string,
|
|
1680
|
+
selector: AuthCredentialSelector,
|
|
1681
|
+
owner?: object,
|
|
1682
|
+
): void {
|
|
1556
1683
|
const scope = scopeId.trim();
|
|
1557
1684
|
if (!scope) throw new Error("Credential scope id must not be empty");
|
|
1558
1685
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1559
|
-
this.#assertCredentialSelectorUsable(storageProvider, selector);
|
|
1686
|
+
this.#assertCredentialSelectorUsable(storageProvider, selector, owner);
|
|
1560
1687
|
const selectors = this.#sessionCredentialSelectors.get(scope) ?? new Map<string, AuthCredentialSelector>();
|
|
1561
1688
|
selectors.set(storageProvider, selector);
|
|
1562
1689
|
this.#sessionCredentialSelectors.set(scope, selectors);
|
|
@@ -1615,23 +1742,32 @@ export class AuthStorage {
|
|
|
1615
1742
|
}
|
|
1616
1743
|
|
|
1617
1744
|
/** @internal Return cache provenance for an exact stored literal API-key row without resolving its value. */
|
|
1618
|
-
getStoredLiteralApiKeyEvidenceGeneration(
|
|
1745
|
+
getStoredLiteralApiKeyEvidenceGeneration(
|
|
1746
|
+
provider: string,
|
|
1747
|
+
selector: AuthCredentialSelector,
|
|
1748
|
+
owner?: object,
|
|
1749
|
+
): string | undefined {
|
|
1619
1750
|
if (selector.kind !== "id") return undefined;
|
|
1620
1751
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1621
|
-
if (this.#runtimeOverrides.has(storageProvider) || this.#
|
|
1752
|
+
if (this.#runtimeOverrides.has(storageProvider) || this.#hasConfigOverride(storageProvider, owner))
|
|
1753
|
+
return undefined;
|
|
1622
1754
|
const selected = this.#findCredentialBySelector(storageProvider, selector);
|
|
1623
1755
|
if (selected?.credential.type !== "api_key") return undefined;
|
|
1624
1756
|
const key = selected.credential.key;
|
|
1625
1757
|
if (!key || key.startsWith("!") || process.env[key] !== undefined) return undefined;
|
|
1626
|
-
return this.getProviderEvidenceGeneration(storageProvider, key);
|
|
1758
|
+
return this.getProviderEvidenceGeneration(storageProvider, key, owner);
|
|
1627
1759
|
}
|
|
1628
1760
|
|
|
1629
|
-
/**
|
|
1630
|
-
|
|
1761
|
+
/**
|
|
1762
|
+
* Validate and canonicalize an OAuth-only selector for account pinning.
|
|
1763
|
+
* `owner` scopes config-override checks to one ModelRegistry; omitted owners
|
|
1764
|
+
* retain process-wide caller semantics.
|
|
1765
|
+
*/
|
|
1766
|
+
resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector, owner?: object): OAuthPinTarget {
|
|
1631
1767
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1632
1768
|
if (
|
|
1633
1769
|
this.#runtimeOverrides.has(storageProvider) ||
|
|
1634
|
-
this.#
|
|
1770
|
+
this.#hasConfigOverride(storageProvider, owner) ||
|
|
1635
1771
|
getEnvApiKey(storageProvider)
|
|
1636
1772
|
) {
|
|
1637
1773
|
throw new OAuthCredentialSelectorError(
|
|
@@ -1828,8 +1964,8 @@ export class AuthStorage {
|
|
|
1828
1964
|
}
|
|
1829
1965
|
|
|
1830
1966
|
/** Whether a provider is currently authenticated by a config API-key override. */
|
|
1831
|
-
hasConfigApiKey(provider: string): boolean {
|
|
1832
|
-
return Boolean(this.#
|
|
1967
|
+
hasConfigApiKey(provider: string, owner?: object): boolean {
|
|
1968
|
+
return Boolean(this.#configOverrideRegistration(provider, owner)?.apiKey);
|
|
1833
1969
|
}
|
|
1834
1970
|
|
|
1835
1971
|
/**
|
|
@@ -1890,7 +2026,8 @@ export class AuthStorage {
|
|
|
1890
2026
|
* runtime API-key override (`--api-key`), or a config-sourced API key
|
|
1891
2027
|
* (`models.yml` `apiKey`) would each re-decide the credential on the very
|
|
1892
2028
|
* next {@link AuthStorage.getApiKey} call and make this switch appear to
|
|
1893
|
-
* silently do nothing.
|
|
2029
|
+
* silently do nothing. `owner` scopes the config-override check to one
|
|
2030
|
+
* ModelRegistry; omitted owners retain process-wide caller semantics.
|
|
1894
2031
|
*
|
|
1895
2032
|
* Deliberately does not touch credential-blocked state: if the target row
|
|
1896
2033
|
* is still backoff-blocked from a prior quota failure, the existing
|
|
@@ -1898,14 +2035,19 @@ export class AuthStorage {
|
|
|
1898
2035
|
* falls back to a usable account instead of re-issuing a request that would
|
|
1899
2036
|
* just draw another 429/quota error.
|
|
1900
2037
|
*/
|
|
1901
|
-
switchSessionCredential(
|
|
2038
|
+
switchSessionCredential(
|
|
2039
|
+
provider: string,
|
|
2040
|
+
sessionId: string,
|
|
2041
|
+
selector: AuthCredentialSelector,
|
|
2042
|
+
owner?: object,
|
|
2043
|
+
): void {
|
|
1902
2044
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1903
2045
|
if (this.#runtimeOverrides.has(storageProvider)) {
|
|
1904
2046
|
throw new Error(
|
|
1905
2047
|
`Cannot switch credential for ${provider}: a runtime API key override (--api-key) is active and always wins`,
|
|
1906
2048
|
);
|
|
1907
2049
|
}
|
|
1908
|
-
if (this.#
|
|
2050
|
+
if (this.#hasConfigOverride(storageProvider, owner)) {
|
|
1909
2051
|
throw new Error(
|
|
1910
2052
|
`Cannot switch credential for ${provider}: a config API key override (models.yml) is active and always wins`,
|
|
1911
2053
|
);
|
|
@@ -1935,6 +2077,9 @@ export class AuthStorage {
|
|
|
1935
2077
|
* Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key`
|
|
1936
2078
|
* still wins for the duration of a single invocation.
|
|
1937
2079
|
*
|
|
2080
|
+
* `options.owner` scopes the override to one registry or other caller. The
|
|
2081
|
+
* unscoped form is process-wide and is retained for standalone callers.
|
|
2082
|
+
*
|
|
1938
2083
|
* `options.envSourced` marks the value as resolved from a models.yml
|
|
1939
2084
|
* `apiKeyEnv` indirection. Unlike a literal pin, an env pointer only says
|
|
1940
2085
|
* where to look for a key; when the user has since run `auth login`, the
|
|
@@ -1942,44 +2087,111 @@ export class AuthStorage {
|
|
|
1942
2087
|
* wins over the indirection (stored OAuth credentials still yield, so a
|
|
1943
2088
|
* custom-endpoint bearer is never replaced by an upstream OAuth token).
|
|
1944
2089
|
*/
|
|
1945
|
-
setConfigApiKey(provider: string, apiKey: string, options: { envSourced?: boolean } = {}): void {
|
|
2090
|
+
setConfigApiKey(provider: string, apiKey: string, options: { envSourced?: boolean; owner?: object } = {}): void {
|
|
1946
2091
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2092
|
+
const registration = {
|
|
2093
|
+
apiKey,
|
|
2094
|
+
envSourced: options.envSourced === true,
|
|
2095
|
+
order: ++this.#configOverrideOrder,
|
|
2096
|
+
};
|
|
2097
|
+
if (options.owner) {
|
|
2098
|
+
const registrations = this.#configOverrideRegistrations.get(storageProvider) ?? new Map();
|
|
2099
|
+
registrations.set(options.owner, registration);
|
|
2100
|
+
this.#configOverrideRegistrations.set(storageProvider, registrations);
|
|
1950
2101
|
} else {
|
|
1951
|
-
this.#
|
|
2102
|
+
this.#unownedConfigOverrides.set(storageProvider, registration);
|
|
1952
2103
|
}
|
|
1953
|
-
this.#
|
|
2104
|
+
this.#reconcileConfigApiKey(storageProvider, "set-config-api-key", true);
|
|
1954
2105
|
}
|
|
1955
2106
|
|
|
1956
2107
|
/**
|
|
1957
2108
|
* Remove a single config-sourced API key override.
|
|
1958
2109
|
*/
|
|
1959
|
-
removeConfigApiKey(provider: string): void {
|
|
2110
|
+
removeConfigApiKey(provider: string, owner?: object): void {
|
|
1960
2111
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1961
|
-
|
|
1962
|
-
|
|
2112
|
+
if (owner) {
|
|
2113
|
+
const registrations = this.#configOverrideRegistrations.get(storageProvider);
|
|
2114
|
+
if (!registrations?.delete(owner)) return;
|
|
2115
|
+
if (registrations.size === 0) this.#configOverrideRegistrations.delete(storageProvider);
|
|
2116
|
+
} else {
|
|
2117
|
+
if (!this.#unownedConfigOverrides.delete(storageProvider)) return;
|
|
2118
|
+
}
|
|
2119
|
+
this.#reconcileConfigApiKey(storageProvider, "remove-config-api-key", true);
|
|
1963
2120
|
}
|
|
1964
2121
|
|
|
1965
2122
|
/**
|
|
1966
|
-
* Drop
|
|
1967
|
-
*
|
|
2123
|
+
* Drop config-sourced API keys. An owner removes only its own registrations;
|
|
2124
|
+
* the unscoped form remains an explicit global reset for callers that own the
|
|
2125
|
+
* entire AuthStorage instance.
|
|
1968
2126
|
*/
|
|
1969
|
-
clearConfigApiKeys(): void {
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2127
|
+
clearConfigApiKeys(owner?: object): void {
|
|
2128
|
+
if (owner) {
|
|
2129
|
+
const providers = [...this.#configOverrideRegistrations.entries()]
|
|
2130
|
+
.filter(([, registrations]) => registrations.has(owner))
|
|
2131
|
+
.map(([provider]) => provider);
|
|
2132
|
+
for (const provider of providers) this.removeConfigApiKey(provider, owner);
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
const providers = new Set([
|
|
2136
|
+
...this.#configOverrides.keys(),
|
|
2137
|
+
...this.#unownedConfigOverrides.keys(),
|
|
2138
|
+
...this.#configOverrideRegistrations.keys(),
|
|
2139
|
+
]);
|
|
2140
|
+
this.#unownedConfigOverrides.clear();
|
|
2141
|
+
this.#configOverrideRegistrations.clear();
|
|
2142
|
+
for (const provider of providers) this.#reconcileConfigApiKey(provider, "clear-config-api-keys", true);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
#reconcileConfigApiKey(provider: string, reason: string, forceGeneration = false): void {
|
|
2146
|
+
const previous = this.#configOverrides.get(provider);
|
|
2147
|
+
const previousEnvSourced = this.#configOverrideEnvSourced.has(provider);
|
|
2148
|
+
let winner: { apiKey: string; envSourced: boolean; order: number } | undefined =
|
|
2149
|
+
this.#unownedConfigOverrides.get(provider);
|
|
2150
|
+
for (const registration of this.#configOverrideRegistrations.get(provider)?.values() ?? []) {
|
|
2151
|
+
if (!winner || registration.order > winner.order) winner = registration;
|
|
2152
|
+
}
|
|
2153
|
+
if (!winner) {
|
|
2154
|
+
this.#configOverrides.delete(provider);
|
|
2155
|
+
this.#configOverrideEnvSourced.delete(provider);
|
|
2156
|
+
} else {
|
|
2157
|
+
this.#configOverrides.set(provider, winner.apiKey);
|
|
2158
|
+
if (winner.envSourced) this.#configOverrideEnvSourced.add(provider);
|
|
2159
|
+
else this.#configOverrideEnvSourced.delete(provider);
|
|
2160
|
+
}
|
|
2161
|
+
const current = this.#configOverrides.get(provider);
|
|
2162
|
+
const currentEnvSourced = this.#configOverrideEnvSourced.has(provider);
|
|
2163
|
+
if (forceGeneration || previous !== current || previousEnvSourced !== currentEnvSourced) {
|
|
2164
|
+
this.#bumpGeneration(reason, provider);
|
|
2165
|
+
}
|
|
1975
2166
|
}
|
|
1976
2167
|
|
|
1977
2168
|
/**
|
|
1978
2169
|
* Set a fallback resolver for API keys not found in storage or env vars.
|
|
1979
2170
|
* Used for custom provider keys from models.json.
|
|
1980
2171
|
*/
|
|
1981
|
-
setFallbackResolver(resolver: (provider: string) => string | undefined): void {
|
|
2172
|
+
setFallbackResolver(resolver: (provider: string) => string | undefined, owner?: object): () => void {
|
|
2173
|
+
if (owner) {
|
|
2174
|
+
this.#ownedFallbackResolvers.set(owner, resolver);
|
|
2175
|
+
return () => {
|
|
2176
|
+
if (this.#ownedFallbackResolvers.get(owner) !== resolver) return;
|
|
2177
|
+
this.#ownedFallbackResolvers.delete(owner);
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
1982
2180
|
this.#fallbackResolver = resolver;
|
|
2181
|
+
return () => {
|
|
2182
|
+
if (this.#fallbackResolver === resolver) this.#fallbackResolver = undefined;
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
#resolveFallback(provider: string, owner?: object): string | undefined {
|
|
2187
|
+
if (owner) {
|
|
2188
|
+
return this.#ownedFallbackResolvers.get(owner)?.(provider) ?? this.#fallbackResolver?.(provider);
|
|
2189
|
+
}
|
|
2190
|
+
for (const resolver of [...this.#ownedFallbackResolvers.values()].reverse()) {
|
|
2191
|
+
const value = resolver(provider);
|
|
2192
|
+
if (value !== undefined) return value;
|
|
2193
|
+
}
|
|
2194
|
+
return this.#fallbackResolver?.(provider);
|
|
1983
2195
|
}
|
|
1984
2196
|
|
|
1985
2197
|
/**
|
|
@@ -1987,11 +2199,27 @@ export class AuthStorage {
|
|
|
1987
2199
|
*/
|
|
1988
2200
|
async reload(): Promise<void> {
|
|
1989
2201
|
await this.#store.waitForReady?.();
|
|
2202
|
+
this.#reloadCredentialRowsFromStore();
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Acquire a store-owned provider-admission ticket when the backing store
|
|
2207
|
+
* provides one (for example, a remote broker snapshot store). Local stores
|
|
2208
|
+
* need no additional ordering and return `undefined`.
|
|
2209
|
+
*/
|
|
2210
|
+
async acquireCredentialDispatchTicket(
|
|
2211
|
+
provider: Provider,
|
|
2212
|
+
signal?: AbortSignal,
|
|
2213
|
+
): Promise<CredentialDispatchTicket | undefined> {
|
|
2214
|
+
return this.#store.acquireCredentialDispatchTicket?.(resolveOAuthStorageProvider(provider), signal);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
#reloadCredentialRowsFromStore(): void {
|
|
1990
2218
|
const records = this.#store.listAuthCredentials();
|
|
1991
2219
|
const grouped = new Map<string, StoredCredential[]>();
|
|
1992
2220
|
for (const record of records) {
|
|
1993
2221
|
const list = grouped.get(record.provider) ?? [];
|
|
1994
|
-
list.push({ id: record.id, credential: record.credential });
|
|
2222
|
+
list.push({ id: record.id, credential: record.credential, revision: record.revision });
|
|
1995
2223
|
grouped.set(record.provider, list);
|
|
1996
2224
|
}
|
|
1997
2225
|
|
|
@@ -2032,6 +2260,12 @@ export class AuthStorage {
|
|
|
2032
2260
|
#setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
|
|
2033
2261
|
const current = this.#data.get(provider) ?? [];
|
|
2034
2262
|
if (storedCredentialArraysEqual(current, credentials)) return;
|
|
2263
|
+
const identityOrderChanged =
|
|
2264
|
+
current.length !== credentials.length ||
|
|
2265
|
+
current.some(
|
|
2266
|
+
(entry, index) =>
|
|
2267
|
+
entry.id !== credentials[index]?.id || entry.credential.type !== credentials[index]?.credential.type,
|
|
2268
|
+
);
|
|
2035
2269
|
this.#resolvedStoredApiKeyValues.delete(provider);
|
|
2036
2270
|
this.#storedApiKeyResolutionInFlight.delete(provider);
|
|
2037
2271
|
if (credentials.length === 0) {
|
|
@@ -2039,6 +2273,7 @@ export class AuthStorage {
|
|
|
2039
2273
|
} else {
|
|
2040
2274
|
this.#data.set(provider, credentials);
|
|
2041
2275
|
}
|
|
2276
|
+
if (identityOrderChanged) this.#resetProviderAssignments(resolveOAuthStorageProvider(provider));
|
|
2042
2277
|
this.#bumpGeneration("credentials", provider);
|
|
2043
2278
|
}
|
|
2044
2279
|
|
|
@@ -2239,10 +2474,7 @@ export class AuthStorage {
|
|
|
2239
2474
|
}
|
|
2240
2475
|
}
|
|
2241
2476
|
|
|
2242
|
-
#findCredentialBySelector(
|
|
2243
|
-
provider: string,
|
|
2244
|
-
selector: AuthCredentialSelector,
|
|
2245
|
-
): ({ index: number } & StoredCredential) | undefined {
|
|
2477
|
+
#findCredentialBySelector(provider: string, selector: AuthCredentialSelector): IndexedStoredCredential | undefined {
|
|
2246
2478
|
const stored = this.#getStoredCredentials(provider);
|
|
2247
2479
|
for (let index = 0; index < stored.length; index++) {
|
|
2248
2480
|
const entry = stored[index];
|
|
@@ -2285,13 +2517,13 @@ export class AuthStorage {
|
|
|
2285
2517
|
);
|
|
2286
2518
|
}
|
|
2287
2519
|
|
|
2288
|
-
#assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
|
|
2520
|
+
#assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
|
|
2289
2521
|
if (this.#runtimeOverrides.has(provider)) {
|
|
2290
2522
|
throw new Error(
|
|
2291
2523
|
`Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a runtime API key override is active`,
|
|
2292
2524
|
);
|
|
2293
2525
|
}
|
|
2294
|
-
if (this.#
|
|
2526
|
+
if (this.#hasConfigOverride(provider, owner)) {
|
|
2295
2527
|
throw new Error(
|
|
2296
2528
|
`Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a config API key override is active`,
|
|
2297
2529
|
);
|
|
@@ -2307,8 +2539,8 @@ export class AuthStorage {
|
|
|
2307
2539
|
* to an OAuth row specifically — the soft-preference/quota-fallback path is
|
|
2308
2540
|
* meaningless for a single static API key.
|
|
2309
2541
|
*/
|
|
2310
|
-
#assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
|
|
2311
|
-
if (this.#runtimeOverrides.has(provider) || this.#
|
|
2542
|
+
#assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
|
|
2543
|
+
if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, owner)) {
|
|
2312
2544
|
throw new Error(
|
|
2313
2545
|
`Preferred credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while an API key override is active`,
|
|
2314
2546
|
);
|
|
@@ -2325,10 +2557,10 @@ export class AuthStorage {
|
|
|
2325
2557
|
provider: string,
|
|
2326
2558
|
options?: AuthApiKeyOptions,
|
|
2327
2559
|
sessionId?: string,
|
|
2328
|
-
):
|
|
2560
|
+
): IndexedStoredCredential | undefined {
|
|
2329
2561
|
const selector = this.#getCredentialSelector(provider, options, sessionId);
|
|
2330
2562
|
if (!selector) return undefined;
|
|
2331
|
-
this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector);
|
|
2563
|
+
this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector, options?.owner);
|
|
2332
2564
|
const selected = this.#findCredentialBySelector(provider, selector);
|
|
2333
2565
|
if (!selected) {
|
|
2334
2566
|
throw new Error(`No credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`);
|
|
@@ -2345,13 +2577,23 @@ export class AuthStorage {
|
|
|
2345
2577
|
type: T,
|
|
2346
2578
|
sessionId?: string,
|
|
2347
2579
|
isUsable?: (credential: Extract<AuthCredential, { type: T }>, index: number) => boolean | undefined,
|
|
2348
|
-
):
|
|
2349
|
-
const credentials = this.#
|
|
2350
|
-
.map((
|
|
2580
|
+
): IndexedStoredCredential<Extract<AuthCredential, { type: T }>> | undefined {
|
|
2581
|
+
const credentials = this.#getStoredCredentials(provider)
|
|
2582
|
+
.map((entry, index) => ({ entry, index }))
|
|
2351
2583
|
.filter(
|
|
2352
|
-
(
|
|
2353
|
-
|
|
2354
|
-
|
|
2584
|
+
(
|
|
2585
|
+
item,
|
|
2586
|
+
): item is {
|
|
2587
|
+
entry: StoredCredential & { credential: Extract<AuthCredential, { type: T }> };
|
|
2588
|
+
index: number;
|
|
2589
|
+
} => item.entry.credential.type === type,
|
|
2590
|
+
)
|
|
2591
|
+
.map(({ entry, index }) => ({
|
|
2592
|
+
id: entry.id,
|
|
2593
|
+
credential: entry.credential,
|
|
2594
|
+
index,
|
|
2595
|
+
revision: entry.revision,
|
|
2596
|
+
}));
|
|
2355
2597
|
|
|
2356
2598
|
if (credentials.length === 0) return undefined;
|
|
2357
2599
|
|
|
@@ -2377,12 +2619,14 @@ export class AuthStorage {
|
|
|
2377
2619
|
sessionId?: string,
|
|
2378
2620
|
excludedIndices: ReadonlySet<number> = new Set(),
|
|
2379
2621
|
includeKnownUnusable = false,
|
|
2380
|
-
):
|
|
2381
|
-
const credentials = this.#
|
|
2382
|
-
.map((
|
|
2622
|
+
): IndexedStoredCredential<ApiKeyCredential> | undefined {
|
|
2623
|
+
const credentials = this.#getStoredCredentials(provider)
|
|
2624
|
+
.map((entry, index) => ({ entry, index }))
|
|
2383
2625
|
.filter(
|
|
2384
|
-
(
|
|
2385
|
-
|
|
2626
|
+
(item): item is { entry: StoredCredential & { credential: ApiKeyCredential }; index: number } =>
|
|
2627
|
+
item.entry.credential.type === "api_key",
|
|
2628
|
+
)
|
|
2629
|
+
.map(({ entry, index }) => ({ id: entry.id, credential: entry.credential, index, revision: entry.revision }));
|
|
2386
2630
|
if (credentials.length === 0) return undefined;
|
|
2387
2631
|
|
|
2388
2632
|
const providerKey = this.#getProviderTypeKey(provider, "api_key");
|
|
@@ -2421,11 +2665,20 @@ export class AuthStorage {
|
|
|
2421
2665
|
}
|
|
2422
2666
|
|
|
2423
2667
|
/** Updates a credential at index after OAuth token refresh. */
|
|
2424
|
-
#replaceCredentialAt(
|
|
2668
|
+
#replaceCredentialAt(
|
|
2669
|
+
provider: string,
|
|
2670
|
+
index: number,
|
|
2671
|
+
credential: AuthCredential,
|
|
2672
|
+
persist = true,
|
|
2673
|
+
expectedId?: number,
|
|
2674
|
+
): void {
|
|
2425
2675
|
const entries = this.#getStoredCredentials(provider);
|
|
2426
2676
|
if (index < 0 || index >= entries.length) return;
|
|
2427
2677
|
const target = entries[index];
|
|
2428
|
-
if (
|
|
2678
|
+
if (expectedId !== undefined && target.id !== expectedId) {
|
|
2679
|
+
throw new Error("Credential authority changed during refresh");
|
|
2680
|
+
}
|
|
2681
|
+
if (persist && !this.#store.refreshSnapshot) this.#store.updateAuthCredential(target.id, credential);
|
|
2429
2682
|
const updated = [...entries];
|
|
2430
2683
|
updated[index] = { id: target.id, credential };
|
|
2431
2684
|
this.#setStoredCredentials(provider, updated);
|
|
@@ -2456,15 +2709,18 @@ export class AuthStorage {
|
|
|
2456
2709
|
index: number,
|
|
2457
2710
|
expectedCredential: AuthCredential,
|
|
2458
2711
|
disabledCause: string,
|
|
2712
|
+
expectedId?: number,
|
|
2459
2713
|
): boolean {
|
|
2460
2714
|
const entries = this.#getStoredCredentials(provider);
|
|
2461
|
-
|
|
2462
|
-
|
|
2715
|
+
const targetIndex = expectedId === undefined ? index : entries.findIndex(entry => entry.id === expectedId);
|
|
2716
|
+
if (targetIndex < 0 || targetIndex >= entries.length) return false;
|
|
2717
|
+
const target = entries[targetIndex];
|
|
2718
|
+
if (expectedId !== undefined && target.id !== expectedId) return false;
|
|
2463
2719
|
const serialized = serializeCredential(provider, expectedCredential);
|
|
2464
2720
|
if (!serialized) return false;
|
|
2465
2721
|
const disabled = this.#store.tryDisableAuthCredentialIfMatches(target.id, serialized.data, disabledCause);
|
|
2466
2722
|
if (!disabled) return false;
|
|
2467
|
-
const updated = entries.filter((_value, idx) => idx !==
|
|
2723
|
+
const updated = entries.filter((_value, idx) => idx !== targetIndex);
|
|
2468
2724
|
this.#setStoredCredentials(provider, updated);
|
|
2469
2725
|
this.#clearSelectorsForRemovedCredential(provider, new Set([target.id]), entries);
|
|
2470
2726
|
this.#resetProviderAssignments(provider);
|
|
@@ -2501,6 +2757,35 @@ export class AuthStorage {
|
|
|
2501
2757
|
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
2502
2758
|
}
|
|
2503
2759
|
|
|
2760
|
+
/**
|
|
2761
|
+
* Disable one credential through an authoritative remote store and reconcile
|
|
2762
|
+
* this AuthStorage instance only after that write succeeds. Remote stores
|
|
2763
|
+
* must never be mutated through the synchronous local delete path because
|
|
2764
|
+
* their snapshots do not own persistence authority.
|
|
2765
|
+
*/
|
|
2766
|
+
async #disableCredentialRemotely(
|
|
2767
|
+
provider: string,
|
|
2768
|
+
credentialId: number,
|
|
2769
|
+
disabledCause: string,
|
|
2770
|
+
signal?: AbortSignal,
|
|
2771
|
+
expectedRevision?: number,
|
|
2772
|
+
): Promise<boolean> {
|
|
2773
|
+
const disable = this.#store.disableAuthCredentialRemote?.bind(this.#store);
|
|
2774
|
+
if (!disable) return false;
|
|
2775
|
+
const disabled = await disable(credentialId, disabledCause, signal, expectedRevision);
|
|
2776
|
+
if (!disabled) return false;
|
|
2777
|
+
const entries = this.#getStoredCredentials(provider);
|
|
2778
|
+
if (!entries.some(entry => entry.id === credentialId)) return true;
|
|
2779
|
+
this.#setStoredCredentials(
|
|
2780
|
+
provider,
|
|
2781
|
+
entries.filter(entry => entry.id !== credentialId),
|
|
2782
|
+
);
|
|
2783
|
+
this.#clearSelectorsForRemovedCredential(provider, new Set([credentialId]), entries);
|
|
2784
|
+
this.#resetProviderAssignments(provider);
|
|
2785
|
+
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
2786
|
+
return true;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2504
2789
|
/** Clear every selector whose durable/in-memory target was just removed. */
|
|
2505
2790
|
#clearSelectorsForRemovedCredential(
|
|
2506
2791
|
provider: string,
|
|
@@ -2575,7 +2860,7 @@ export class AuthStorage {
|
|
|
2575
2860
|
: this.#store.replaceAuthCredentialsForProvider(storageProvider, deduped);
|
|
2576
2861
|
this.#setStoredCredentials(
|
|
2577
2862
|
storageProvider,
|
|
2578
|
-
stored.map(record => ({ id: record.id, credential: record.credential })),
|
|
2863
|
+
stored.map(record => ({ id: record.id, credential: record.credential, revision: record.revision })),
|
|
2579
2864
|
);
|
|
2580
2865
|
this.#resetProviderAssignments(storageProvider);
|
|
2581
2866
|
}
|
|
@@ -2590,6 +2875,7 @@ export class AuthStorage {
|
|
|
2590
2875
|
provider: entry.provider,
|
|
2591
2876
|
credential: redacted,
|
|
2592
2877
|
identityKey: resolveCredentialIdentityKey(provider, persisted),
|
|
2878
|
+
...(entry.revision === undefined ? {} : { revision: entry.revision }),
|
|
2593
2879
|
};
|
|
2594
2880
|
});
|
|
2595
2881
|
}
|
|
@@ -2606,14 +2892,15 @@ export class AuthStorage {
|
|
|
2606
2892
|
async importCredentialIfAbsent(
|
|
2607
2893
|
provider: string,
|
|
2608
2894
|
credential: AuthCredential,
|
|
2895
|
+
owner?: object,
|
|
2609
2896
|
): Promise<AuthCredentialIfAbsentSnapshotResult> {
|
|
2610
2897
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2611
2898
|
if (this.#runtimeOverrides.has(storageProvider))
|
|
2612
2899
|
return this.#snapshotSkipResult(storageProvider, "skipped-existing-runtime");
|
|
2613
|
-
if (this.#
|
|
2900
|
+
if (this.#hasConfigOverride(storageProvider, owner))
|
|
2614
2901
|
return this.#snapshotSkipResult(storageProvider, "skipped-existing-config");
|
|
2615
2902
|
if (getEnvApiKey(storageProvider)) return this.#snapshotSkipResult(storageProvider, "skipped-existing-env");
|
|
2616
|
-
if (this.#
|
|
2903
|
+
if (this.#resolveFallback(storageProvider, owner))
|
|
2617
2904
|
return this.#snapshotSkipResult(storageProvider, "skipped-existing-fallback");
|
|
2618
2905
|
|
|
2619
2906
|
const result = this.#store.upsertAuthCredentialRemoteIfAbsent
|
|
@@ -2621,7 +2908,7 @@ export class AuthStorage {
|
|
|
2621
2908
|
: this.#store.upsertAuthCredentialForProviderIfAbsent(storageProvider, credential);
|
|
2622
2909
|
this.#setStoredCredentials(
|
|
2623
2910
|
storageProvider,
|
|
2624
|
-
result.entries.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
2911
|
+
result.entries.map(entry => ({ id: entry.id, credential: entry.credential, revision: entry.revision })),
|
|
2625
2912
|
);
|
|
2626
2913
|
this.#resetProviderAssignments(storageProvider);
|
|
2627
2914
|
if (result.inserted) this.#invalidateUsageCacheForProvider(storageProvider);
|
|
@@ -2639,7 +2926,7 @@ export class AuthStorage {
|
|
|
2639
2926
|
: this.#store.upsertAuthCredentialForProvider(provider, credential);
|
|
2640
2927
|
this.#setStoredCredentials(
|
|
2641
2928
|
provider,
|
|
2642
|
-
stored.map(record => ({ id: record.id, credential: record.credential })),
|
|
2929
|
+
stored.map(record => ({ id: record.id, credential: record.credential, revision: record.revision })),
|
|
2643
2930
|
);
|
|
2644
2931
|
this.#resetProviderAssignments(provider);
|
|
2645
2932
|
this.#invalidateUsageCacheForProvider(provider);
|
|
@@ -2689,23 +2976,44 @@ export class AuthStorage {
|
|
|
2689
2976
|
* Check if any form of auth is configured for a provider.
|
|
2690
2977
|
* Unlike getApiKey(), this doesn't refresh OAuth tokens.
|
|
2691
2978
|
*/
|
|
2692
|
-
#hasConfiguredAuth(storageProvider: string): boolean {
|
|
2979
|
+
#hasConfiguredAuth(storageProvider: string, owner?: object): boolean {
|
|
2693
2980
|
if (this.hasRuntimeApiKey(storageProvider)) return true;
|
|
2694
|
-
if (this.#
|
|
2981
|
+
if (this.#hasConfigOverride(storageProvider, owner)) return true;
|
|
2695
2982
|
if (this.#getCredentialsForProvider(storageProvider).length > 0) return true;
|
|
2696
2983
|
if (getEnvApiKey(storageProvider)) return true;
|
|
2697
|
-
if (this.#
|
|
2984
|
+
if (this.#resolveFallback(storageProvider, owner)) return true;
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
disableCredentialByIdIfRevision(id: number, expectedRevision: number, disabledCause: string): boolean {
|
|
2989
|
+
const cause = normalizeDisabledCause(disabledCause);
|
|
2990
|
+
if (this.#store.tryDisableAuthCredentialIfRevision?.(id, expectedRevision, cause) !== true) return false;
|
|
2991
|
+
for (const [provider, entries] of this.#data) {
|
|
2992
|
+
if (!entries.some(entry => entry.id === id)) continue;
|
|
2993
|
+
this.#setStoredCredentials(
|
|
2994
|
+
provider,
|
|
2995
|
+
entries.filter(entry => entry.id !== id),
|
|
2996
|
+
);
|
|
2997
|
+
this.#clearSelectorsForRemovedCredential(provider, new Set([id]), entries);
|
|
2998
|
+
this.#resetProviderAssignments(provider);
|
|
2999
|
+
this.#emitCredentialDisabled({ provider, disabledCause: cause });
|
|
3000
|
+
return true;
|
|
3001
|
+
}
|
|
2698
3002
|
return false;
|
|
2699
3003
|
}
|
|
2700
3004
|
|
|
2701
|
-
hasAuth(provider: string, sessionId?: string): boolean {
|
|
3005
|
+
hasAuth(provider: string, sessionId?: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
|
|
2702
3006
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2703
3007
|
try {
|
|
2704
|
-
this.#resolveSelectedStoredCredential(
|
|
3008
|
+
this.#resolveSelectedStoredCredential(
|
|
3009
|
+
storageProvider,
|
|
3010
|
+
options?.owner ? { owner: options.owner } : undefined,
|
|
3011
|
+
sessionId,
|
|
3012
|
+
);
|
|
2705
3013
|
} catch {
|
|
2706
3014
|
return false;
|
|
2707
3015
|
}
|
|
2708
|
-
return this.#hasConfiguredAuth(storageProvider);
|
|
3016
|
+
return this.#hasConfiguredAuth(storageProvider, options?.owner);
|
|
2709
3017
|
}
|
|
2710
3018
|
|
|
2711
3019
|
/**
|
|
@@ -2713,15 +3021,24 @@ export class AuthStorage {
|
|
|
2713
3021
|
* Mirrors getApiKey selector validation, overrides, session OAuth stickiness,
|
|
2714
3022
|
* cached command-key usability, OAuth retry, and environment fallback order.
|
|
2715
3023
|
*/
|
|
2716
|
-
getEffectiveCredentialType(
|
|
3024
|
+
getEffectiveCredentialType(
|
|
3025
|
+
provider: string,
|
|
3026
|
+
sessionId?: string,
|
|
3027
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
3028
|
+
): AuthCredential["type"] | undefined {
|
|
2717
3029
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2718
3030
|
let selected: ({ index: number } & StoredCredential) | undefined;
|
|
2719
3031
|
try {
|
|
2720
|
-
selected = this.#resolveSelectedStoredCredential(
|
|
3032
|
+
selected = this.#resolveSelectedStoredCredential(
|
|
3033
|
+
storageProvider,
|
|
3034
|
+
options?.owner ? { owner: options.owner } : undefined,
|
|
3035
|
+
sessionId,
|
|
3036
|
+
);
|
|
2721
3037
|
} catch {
|
|
2722
3038
|
return undefined;
|
|
2723
3039
|
}
|
|
2724
|
-
if (this.hasRuntimeApiKey(storageProvider) || this.#
|
|
3040
|
+
if (this.hasRuntimeApiKey(storageProvider) || this.#hasConfigOverride(storageProvider, options?.owner))
|
|
3041
|
+
return "api_key";
|
|
2725
3042
|
if (selected) return selected.credential.type;
|
|
2726
3043
|
|
|
2727
3044
|
const credentials = this.#getCredentialsForProvider(storageProvider);
|
|
@@ -2734,19 +3051,23 @@ export class AuthStorage {
|
|
|
2734
3051
|
}
|
|
2735
3052
|
if (credentials.some(credential => credential.type === "oauth")) return "oauth";
|
|
2736
3053
|
if (apiKeys.length > 0) return "api_key";
|
|
2737
|
-
if (getEnvApiKey(storageProvider) || this.#
|
|
3054
|
+
if (getEnvApiKey(storageProvider) || this.#resolveFallback(storageProvider, options?.owner)) return "api_key";
|
|
2738
3055
|
return undefined;
|
|
2739
3056
|
}
|
|
2740
3057
|
|
|
2741
3058
|
/**
|
|
2742
3059
|
* Check whether configured auth is currently usable without resolving credentials.
|
|
2743
3060
|
*/
|
|
2744
|
-
hasUsableAuth(provider: string): boolean {
|
|
3061
|
+
hasUsableAuth(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
|
|
2745
3062
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2746
3063
|
try {
|
|
2747
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(
|
|
3064
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(
|
|
3065
|
+
storageProvider,
|
|
3066
|
+
options?.owner ? { owner: options.owner } : undefined,
|
|
3067
|
+
undefined,
|
|
3068
|
+
);
|
|
2748
3069
|
if (this.hasRuntimeApiKey(storageProvider)) return true;
|
|
2749
|
-
if (this.#
|
|
3070
|
+
if (this.#hasConfigOverride(storageProvider, options?.owner)) return true;
|
|
2750
3071
|
if (selectedCredential) {
|
|
2751
3072
|
if (selectedCredential.credential.type === "api_key") {
|
|
2752
3073
|
return (
|
|
@@ -2787,7 +3108,7 @@ export class AuthStorage {
|
|
|
2787
3108
|
} catch {
|
|
2788
3109
|
return false;
|
|
2789
3110
|
}
|
|
2790
|
-
return Boolean(getEnvApiKey(storageProvider) || this.#
|
|
3111
|
+
return Boolean(getEnvApiKey(storageProvider) || this.#resolveFallback(storageProvider, options?.owner));
|
|
2791
3112
|
}
|
|
2792
3113
|
|
|
2793
3114
|
/**
|
|
@@ -2800,10 +3121,14 @@ export class AuthStorage {
|
|
|
2800
3121
|
/**
|
|
2801
3122
|
* Get OAuth credentials for a provider.
|
|
2802
3123
|
*/
|
|
2803
|
-
getOAuthCredential(
|
|
3124
|
+
getOAuthCredential(
|
|
3125
|
+
provider: string,
|
|
3126
|
+
sessionId?: string,
|
|
3127
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
3128
|
+
): OAuthCredential | undefined {
|
|
2804
3129
|
const selected = this.#resolveSelectedStoredCredential(
|
|
2805
3130
|
resolveOAuthStorageProvider(provider),
|
|
2806
|
-
undefined,
|
|
3131
|
+
options?.owner ? { owner: options.owner } : undefined,
|
|
2807
3132
|
sessionId,
|
|
2808
3133
|
);
|
|
2809
3134
|
if (selected?.credential.type === "oauth") return selected.credential;
|
|
@@ -2820,7 +3145,11 @@ export class AuthStorage {
|
|
|
2820
3145
|
* first call before any `getApiKey` has been issued, or single-credential setups).
|
|
2821
3146
|
* Returns `undefined` when no OAuth credential carries an `accountId`.
|
|
2822
3147
|
*/
|
|
2823
|
-
getOAuthAccountId(
|
|
3148
|
+
getOAuthAccountId(
|
|
3149
|
+
provider: string,
|
|
3150
|
+
sessionId?: string,
|
|
3151
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
3152
|
+
): string | undefined {
|
|
2824
3153
|
provider = resolveOAuthStorageProvider(provider);
|
|
2825
3154
|
const allCredentials = this.#getCredentialsForProvider(provider);
|
|
2826
3155
|
const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
|
|
@@ -2828,11 +3157,15 @@ export class AuthStorage {
|
|
|
2828
3157
|
|
|
2829
3158
|
// Runtime / config overrides bypass OAuth account_uuid attribution — the
|
|
2830
3159
|
// caller is authenticating with an explicit key, not the broker's OAuth.
|
|
2831
|
-
if (this.#runtimeOverrides.has(provider) || this.#
|
|
3160
|
+
if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, options?.owner)) return undefined;
|
|
2832
3161
|
|
|
2833
3162
|
// Prefer the session-sticky credential when available.
|
|
2834
3163
|
|
|
2835
|
-
const scopedSelection = this.#resolveSelectedStoredCredential(
|
|
3164
|
+
const scopedSelection = this.#resolveSelectedStoredCredential(
|
|
3165
|
+
provider,
|
|
3166
|
+
options?.owner ? { owner: options.owner } : undefined,
|
|
3167
|
+
sessionId,
|
|
3168
|
+
);
|
|
2836
3169
|
if (scopedSelection?.credential.type === "api_key") return undefined;
|
|
2837
3170
|
if (scopedSelection?.credential.type === "oauth") {
|
|
2838
3171
|
const accountId = scopedSelection.credential.accountId;
|
|
@@ -2848,7 +3181,7 @@ export class AuthStorage {
|
|
|
2848
3181
|
// account_uuid injection would misattribute traffic. Only apply this guard when
|
|
2849
3182
|
// sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
|
|
2850
3183
|
// NOT be blocked even if an env key also happens to exist.
|
|
2851
|
-
if (!sessionPref && (getEnvApiKey(provider) || this.#
|
|
3184
|
+
if (!sessionPref && (getEnvApiKey(provider) || this.#resolveFallback(provider, options?.owner))) return undefined;
|
|
2852
3185
|
// Resolve the sticky index against the full credential list — the index is
|
|
2853
3186
|
// recorded against the unfiltered provider array (by #recordSessionCredential /
|
|
2854
3187
|
// #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
|
|
@@ -2973,6 +3306,16 @@ export class AuthStorage {
|
|
|
2973
3306
|
credentials = await loginKimi(ctrl);
|
|
2974
3307
|
break;
|
|
2975
3308
|
}
|
|
3309
|
+
case "kiro": {
|
|
3310
|
+
const { loginKiro } = await import("./utils/oauth/kiro");
|
|
3311
|
+
credentials = await loginKiro({
|
|
3312
|
+
onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
|
|
3313
|
+
onPrompt: ctrl.onPrompt,
|
|
3314
|
+
onProgress: ctrl.onProgress,
|
|
3315
|
+
signal: ctrl.signal,
|
|
3316
|
+
});
|
|
3317
|
+
break;
|
|
3318
|
+
}
|
|
2976
3319
|
case "kilo": {
|
|
2977
3320
|
const { loginKilo } = await import("./utils/oauth/kilo");
|
|
2978
3321
|
credentials = await loginKilo(ctrl);
|
|
@@ -3004,6 +3347,12 @@ export class AuthStorage {
|
|
|
3004
3347
|
await saveApiKeyCredential(apiKey);
|
|
3005
3348
|
return;
|
|
3006
3349
|
}
|
|
3350
|
+
case "commandcode-goat": {
|
|
3351
|
+
const { loginCommandCode } = await import("./utils/oauth/commandcode");
|
|
3352
|
+
const apiKey = await loginCommandCode(ctrl);
|
|
3353
|
+
await saveApiKeyCredential(apiKey);
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3007
3356
|
case "lm-studio": {
|
|
3008
3357
|
const { loginLmStudio } = await import("./utils/oauth/lm-studio");
|
|
3009
3358
|
const apiKey = await loginLmStudio(ctrl);
|
|
@@ -3547,7 +3896,9 @@ export class AuthStorage {
|
|
|
3547
3896
|
}
|
|
3548
3897
|
return lastGood;
|
|
3549
3898
|
})().finally(() => {
|
|
3550
|
-
this.#usageRequestInFlight.
|
|
3899
|
+
if (this.#usageRequestInFlight.get(cacheKey) === promise) {
|
|
3900
|
+
this.#usageRequestInFlight.delete(cacheKey);
|
|
3901
|
+
}
|
|
3551
3902
|
});
|
|
3552
3903
|
|
|
3553
3904
|
this.#usageRequestInFlight.set(cacheKey, promise);
|
|
@@ -3555,6 +3906,7 @@ export class AuthStorage {
|
|
|
3555
3906
|
}
|
|
3556
3907
|
|
|
3557
3908
|
#collectUsageRequests(options?: {
|
|
3909
|
+
provider?: Provider;
|
|
3558
3910
|
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
3559
3911
|
}): UsageRequestDescriptor[] {
|
|
3560
3912
|
const resolver = this.#usageProviderResolver;
|
|
@@ -3568,6 +3920,7 @@ export class AuthStorage {
|
|
|
3568
3920
|
|
|
3569
3921
|
for (const providerId of providers) {
|
|
3570
3922
|
const provider = providerId as Provider;
|
|
3923
|
+
if (options?.provider && options.provider !== provider) continue;
|
|
3571
3924
|
const providerImpl = resolver(provider);
|
|
3572
3925
|
if (!providerImpl) continue;
|
|
3573
3926
|
const baseUrl = options?.baseUrlResolver?.(provider);
|
|
@@ -3767,6 +4120,7 @@ export class AuthStorage {
|
|
|
3767
4120
|
}
|
|
3768
4121
|
|
|
3769
4122
|
async fetchUsageReports(options?: {
|
|
4123
|
+
provider?: Provider;
|
|
3770
4124
|
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
3771
4125
|
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
|
|
3772
4126
|
signal?: AbortSignal;
|
|
@@ -3777,6 +4131,15 @@ export class AuthStorage {
|
|
|
3777
4131
|
// `RemoteAuthCredentialStore` implements the store hook so a gateway
|
|
3778
4132
|
// backed by a broker automatically routes usage to the broker without
|
|
3779
4133
|
// needing the caller to wire it explicitly.
|
|
4134
|
+
const scopedStoreFetch = options?.provider
|
|
4135
|
+
? (this.#fetchUsageReportsForProviderOverride ?? this.#store.fetchUsageReportsForProvider?.bind(this.#store))
|
|
4136
|
+
: undefined;
|
|
4137
|
+
if (scopedStoreFetch && options?.provider) {
|
|
4138
|
+
return raceUsageWithSignal(scopedStoreFetch(options.provider), options.signal);
|
|
4139
|
+
}
|
|
4140
|
+
if (options?.provider && (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports)) {
|
|
4141
|
+
throw new Error("Provider-scoped usage fetch is unavailable");
|
|
4142
|
+
}
|
|
3780
4143
|
const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
|
|
3781
4144
|
if (override) {
|
|
3782
4145
|
// Reuse the in-flight map so concurrent callers (widget poll + format
|
|
@@ -3789,7 +4152,9 @@ export class AuthStorage {
|
|
|
3789
4152
|
// Don't forward the caller signal into the shared fetch — first caller's
|
|
3790
4153
|
// abort would otherwise cancel the upstream for every peer.
|
|
3791
4154
|
shared = override().finally(() => {
|
|
3792
|
-
this.#usageReportsInFlight.
|
|
4155
|
+
if (this.#usageReportsInFlight.get(OVERRIDE_KEY) === shared) {
|
|
4156
|
+
this.#usageReportsInFlight.delete(OVERRIDE_KEY);
|
|
4157
|
+
}
|
|
3793
4158
|
});
|
|
3794
4159
|
this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
|
|
3795
4160
|
}
|
|
@@ -3858,7 +4223,9 @@ export class AuthStorage {
|
|
|
3858
4223
|
}
|
|
3859
4224
|
return resolved;
|
|
3860
4225
|
})().finally(() => {
|
|
3861
|
-
this.#usageReportsInFlight.
|
|
4226
|
+
if (this.#usageReportsInFlight.get(cacheKey) === promise) {
|
|
4227
|
+
this.#usageReportsInFlight.delete(cacheKey);
|
|
4228
|
+
}
|
|
3862
4229
|
});
|
|
3863
4230
|
|
|
3864
4231
|
this.#usageReportsInFlight.set(cacheKey, promise);
|
|
@@ -4192,9 +4559,11 @@ export class AuthStorage {
|
|
|
4192
4559
|
async markUsageLimitReached(
|
|
4193
4560
|
provider: string,
|
|
4194
4561
|
sessionId: string | undefined,
|
|
4195
|
-
options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
|
|
4562
|
+
options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object },
|
|
4196
4563
|
): Promise<boolean> {
|
|
4197
4564
|
provider = resolveOAuthStorageProvider(provider);
|
|
4565
|
+
const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
|
|
4566
|
+
if (ownerOverride && !ownerOverride.envSourced) return false;
|
|
4198
4567
|
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
|
4199
4568
|
if (!sessionCredential) return false;
|
|
4200
4569
|
|
|
@@ -4294,12 +4663,12 @@ export class AuthStorage {
|
|
|
4294
4663
|
providerKey: string;
|
|
4295
4664
|
provider: string;
|
|
4296
4665
|
order: number[];
|
|
4297
|
-
credentials:
|
|
4666
|
+
credentials: OAuthCredentialSelection[];
|
|
4298
4667
|
options?: AuthApiKeyOptions;
|
|
4299
4668
|
strategy: CredentialRankingStrategy;
|
|
4300
4669
|
}): Promise<
|
|
4301
4670
|
Array<{
|
|
4302
|
-
selection:
|
|
4671
|
+
selection: OAuthCredentialSelection;
|
|
4303
4672
|
usage: UsageReport | null;
|
|
4304
4673
|
usageChecked: boolean;
|
|
4305
4674
|
}>
|
|
@@ -4307,7 +4676,7 @@ export class AuthStorage {
|
|
|
4307
4676
|
const nowMs = Date.now();
|
|
4308
4677
|
const { strategy } = args;
|
|
4309
4678
|
const ranked: Array<{
|
|
4310
|
-
selection:
|
|
4679
|
+
selection: OAuthCredentialSelection;
|
|
4311
4680
|
usage: UsageReport | null;
|
|
4312
4681
|
usageChecked: boolean;
|
|
4313
4682
|
blocked: boolean;
|
|
@@ -4455,18 +4824,29 @@ export class AuthStorage {
|
|
|
4455
4824
|
return undefined;
|
|
4456
4825
|
}
|
|
4457
4826
|
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
|
|
4458
|
-
const selectedOAuthCredential =
|
|
4827
|
+
const selectedOAuthCredential: OAuthCredentialSelection | undefined =
|
|
4459
4828
|
selectedCredential?.credential.type === "oauth"
|
|
4460
|
-
? {
|
|
4829
|
+
? {
|
|
4830
|
+
id: selectedCredential.id,
|
|
4831
|
+
credential: selectedCredential.credential,
|
|
4832
|
+
index: selectedCredential.index,
|
|
4833
|
+
revision: selectedCredential.revision,
|
|
4834
|
+
}
|
|
4461
4835
|
: undefined;
|
|
4462
4836
|
if (selectedCredential && !selectedOAuthCredential) return undefined;
|
|
4463
4837
|
const credentials = selectedOAuthCredential
|
|
4464
4838
|
? [selectedOAuthCredential]
|
|
4465
|
-
: this.#
|
|
4466
|
-
.map((credential, index) => ({ credential, index }))
|
|
4839
|
+
: this.#getStoredCredentials(provider)
|
|
4467
4840
|
.filter(
|
|
4468
|
-
(entry): entry is { credential: OAuthCredential
|
|
4469
|
-
|
|
4841
|
+
(entry): entry is StoredCredential & { credential: OAuthCredential } =>
|
|
4842
|
+
entry.credential.type === "oauth",
|
|
4843
|
+
)
|
|
4844
|
+
.map((entry, index) => ({
|
|
4845
|
+
id: entry.id,
|
|
4846
|
+
credential: entry.credential,
|
|
4847
|
+
index,
|
|
4848
|
+
revision: entry.revision,
|
|
4849
|
+
}));
|
|
4470
4850
|
|
|
4471
4851
|
if (credentials.length === 0) return undefined;
|
|
4472
4852
|
|
|
@@ -4489,7 +4869,7 @@ export class AuthStorage {
|
|
|
4489
4869
|
? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
|
|
4490
4870
|
: order
|
|
4491
4871
|
.map(idx => credentials[idx])
|
|
4492
|
-
.filter((selection): selection is
|
|
4872
|
+
.filter((selection): selection is OAuthCredentialSelection => Boolean(selection))
|
|
4493
4873
|
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
|
4494
4874
|
|
|
4495
4875
|
// Soft `--prefer-credential` preference: reorder the preferred row to the
|
|
@@ -4499,7 +4879,11 @@ export class AuthStorage {
|
|
|
4499
4879
|
if (!selectedCredential) {
|
|
4500
4880
|
const preferredSelector = this.#getPreferredCredentialSelector(provider, options);
|
|
4501
4881
|
if (preferredSelector) {
|
|
4502
|
-
this.#assertPreferredCredentialSelectorUsable(
|
|
4882
|
+
this.#assertPreferredCredentialSelectorUsable(
|
|
4883
|
+
resolveOAuthStorageProvider(provider),
|
|
4884
|
+
preferredSelector,
|
|
4885
|
+
options?.owner,
|
|
4886
|
+
);
|
|
4503
4887
|
}
|
|
4504
4888
|
const preferredSelection = preferredSelector
|
|
4505
4889
|
? this.#findCredentialBySelector(provider, preferredSelector)
|
|
@@ -4531,14 +4915,15 @@ export class AuthStorage {
|
|
|
4531
4915
|
}
|
|
4532
4916
|
await Promise.all(
|
|
4533
4917
|
candidates.map(async candidate => {
|
|
4918
|
+
if (!this.#reconcileOAuthCredentialSelection(provider, candidate.selection)) return;
|
|
4534
4919
|
if (Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires) return;
|
|
4535
|
-
const latestCredential =
|
|
4920
|
+
const latestCredential = candidate.selection.credential;
|
|
4536
4921
|
if (latestCredential?.type === "oauth" && Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires) {
|
|
4537
4922
|
candidate.selection.credential = latestCredential;
|
|
4538
4923
|
return;
|
|
4539
4924
|
}
|
|
4540
4925
|
try {
|
|
4541
|
-
const credentialId =
|
|
4926
|
+
const credentialId = candidate.selection.id;
|
|
4542
4927
|
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
4543
4928
|
provider,
|
|
4544
4929
|
candidate.selection.credential,
|
|
@@ -4550,12 +4935,14 @@ export class AuthStorage {
|
|
|
4550
4935
|
...refreshedCredentials,
|
|
4551
4936
|
type: "oauth",
|
|
4552
4937
|
};
|
|
4938
|
+
if (!this.#reconcileOAuthCredentialSelection(provider, candidate.selection)) return;
|
|
4553
4939
|
candidate.selection.credential = updated;
|
|
4554
4940
|
this.#replaceCredentialAt(
|
|
4555
4941
|
provider,
|
|
4556
4942
|
candidate.selection.index,
|
|
4557
4943
|
updated,
|
|
4558
4944
|
!refreshedCredentials.persistedByLease,
|
|
4945
|
+
credentialId,
|
|
4559
4946
|
);
|
|
4560
4947
|
} catch {}
|
|
4561
4948
|
}),
|
|
@@ -4781,6 +5168,15 @@ export class AuthStorage {
|
|
|
4781
5168
|
}
|
|
4782
5169
|
try {
|
|
4783
5170
|
const refreshed = await Promise.race([refreshPromise, cancellation.promise]);
|
|
5171
|
+
let effectiveRefreshed = refreshed;
|
|
5172
|
+
if (this.#refreshOAuthCredentialOverride && this.#store.refreshSnapshot) {
|
|
5173
|
+
await this.#store.refreshSnapshot();
|
|
5174
|
+
const accepted = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId)?.credential;
|
|
5175
|
+
if (accepted?.type !== "oauth") {
|
|
5176
|
+
throw new Error("Credential authority changed during refresh");
|
|
5177
|
+
}
|
|
5178
|
+
effectiveRefreshed = accepted;
|
|
5179
|
+
}
|
|
4784
5180
|
// Return the FULL authority of the effective credential: rotated
|
|
4785
5181
|
// tokens from upstream plus the identity metadata and MCP binding of
|
|
4786
5182
|
// the (possibly guard-adopted) credential that was actually
|
|
@@ -4789,12 +5185,12 @@ export class AuthStorage {
|
|
|
4789
5185
|
// next refresh token to the wrong endpoint — or relabel rotated
|
|
4790
5186
|
// tokens with stale identity.
|
|
4791
5187
|
const authority: RefreshedOAuthCredentials = {
|
|
4792
|
-
...
|
|
4793
|
-
accountId:
|
|
4794
|
-
email:
|
|
4795
|
-
projectId:
|
|
4796
|
-
enterpriseUrl:
|
|
4797
|
-
mcpBinding: (
|
|
5188
|
+
...effectiveRefreshed,
|
|
5189
|
+
accountId: effectiveRefreshed.accountId ?? credential.accountId,
|
|
5190
|
+
email: effectiveRefreshed.email ?? credential.email,
|
|
5191
|
+
projectId: effectiveRefreshed.projectId ?? credential.projectId,
|
|
5192
|
+
enterpriseUrl: effectiveRefreshed.enterpriseUrl ?? credential.enterpriseUrl,
|
|
5193
|
+
mcpBinding: (effectiveRefreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
|
|
4798
5194
|
};
|
|
4799
5195
|
if (refreshLease) {
|
|
4800
5196
|
const completeLease = this.#store.completeOAuthRefreshLease?.bind(this.#store);
|
|
@@ -4823,15 +5219,28 @@ export class AuthStorage {
|
|
|
4823
5219
|
}
|
|
4824
5220
|
}
|
|
4825
5221
|
|
|
5222
|
+
#reconcileOAuthCredentialSelection(provider: string, selection: OAuthCredentialSelection): boolean {
|
|
5223
|
+
const entries = this.#getStoredCredentials(provider);
|
|
5224
|
+
const index = entries.findIndex(entry => entry.id === selection.id);
|
|
5225
|
+
if (index === -1) return false;
|
|
5226
|
+
const current = entries[index];
|
|
5227
|
+
if (current?.credential.type !== "oauth") return false;
|
|
5228
|
+
selection.index = index;
|
|
5229
|
+
selection.credential = current.credential;
|
|
5230
|
+
selection.revision = current.revision;
|
|
5231
|
+
return true;
|
|
5232
|
+
}
|
|
5233
|
+
|
|
4826
5234
|
async #prepareOAuthCredentialForRequest(
|
|
4827
5235
|
provider: string,
|
|
4828
|
-
selection:
|
|
5236
|
+
selection: OAuthCredentialSelection,
|
|
4829
5237
|
options: AuthApiKeyOptions | undefined,
|
|
4830
5238
|
): Promise<boolean> {
|
|
5239
|
+
if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return false;
|
|
4831
5240
|
const prepare = this.#store.prepareForRequest?.bind(this.#store);
|
|
4832
5241
|
if (!prepare) return true;
|
|
4833
5242
|
const stored = this.#getStoredCredentials(provider);
|
|
4834
|
-
const selected = stored
|
|
5243
|
+
const selected = stored.find(entry => entry.id === selection.id);
|
|
4835
5244
|
if (selected?.credential.type !== "oauth") return false;
|
|
4836
5245
|
|
|
4837
5246
|
const prepared = await prepare(selected.id, { signal: options?.signal });
|
|
@@ -4839,21 +5248,22 @@ export class AuthStorage {
|
|
|
4839
5248
|
const latestRows = this.#store.listAuthCredentials(provider);
|
|
4840
5249
|
this.#setStoredCredentials(
|
|
4841
5250
|
provider,
|
|
4842
|
-
latestRows.map(row => ({ id: row.id, credential: row.credential })),
|
|
5251
|
+
latestRows.map(row => ({ id: row.id, credential: row.credential, revision: row.revision })),
|
|
4843
5252
|
);
|
|
4844
|
-
const latestIndex = latestRows.findIndex(row => row.id ===
|
|
5253
|
+
const latestIndex = latestRows.findIndex(row => row.id === selection.id);
|
|
4845
5254
|
if (latestIndex === -1) return false;
|
|
4846
5255
|
const latest = latestRows[latestIndex];
|
|
4847
5256
|
if (latest?.credential.type !== "oauth") return false;
|
|
4848
5257
|
selection.index = latestIndex;
|
|
4849
5258
|
selection.credential = latest.credential;
|
|
5259
|
+
selection.revision = latest.revision;
|
|
4850
5260
|
return true;
|
|
4851
5261
|
}
|
|
4852
5262
|
|
|
4853
5263
|
/** Attempts to use a single OAuth credential, checking usage and refreshing token. */
|
|
4854
5264
|
async #tryOAuthCredential(
|
|
4855
5265
|
provider: Provider,
|
|
4856
|
-
selection:
|
|
5266
|
+
selection: OAuthCredentialSelection,
|
|
4857
5267
|
providerKey: string,
|
|
4858
5268
|
sessionId: string | undefined,
|
|
4859
5269
|
options: AuthApiKeyOptions | undefined,
|
|
@@ -4873,6 +5283,7 @@ export class AuthStorage {
|
|
|
4873
5283
|
usagePrechecked = false,
|
|
4874
5284
|
enforceProRequirement,
|
|
4875
5285
|
} = usageOptions;
|
|
5286
|
+
if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
|
|
4876
5287
|
if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
|
|
4877
5288
|
return undefined;
|
|
4878
5289
|
}
|
|
@@ -4912,6 +5323,8 @@ export class AuthStorage {
|
|
|
4912
5323
|
}
|
|
4913
5324
|
|
|
4914
5325
|
try {
|
|
5326
|
+
if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
|
|
5327
|
+
const selectionCredentialId = selection.id;
|
|
4915
5328
|
let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
|
|
4916
5329
|
// The refresh result carries the effective (possibly guard-adopted)
|
|
4917
5330
|
// credential's binding; `updated` must persist it or the next refresh
|
|
@@ -4922,7 +5335,7 @@ export class AuthStorage {
|
|
|
4922
5335
|
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
4923
5336
|
provider,
|
|
4924
5337
|
selection.credential,
|
|
4925
|
-
|
|
5338
|
+
selectionCredentialId,
|
|
4926
5339
|
options?.signal,
|
|
4927
5340
|
);
|
|
4928
5341
|
refreshedAuthority = refreshedCredentials;
|
|
@@ -4939,7 +5352,7 @@ export class AuthStorage {
|
|
|
4939
5352
|
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
4940
5353
|
provider,
|
|
4941
5354
|
selection.credential,
|
|
4942
|
-
|
|
5355
|
+
selectionCredentialId,
|
|
4943
5356
|
options?.signal,
|
|
4944
5357
|
);
|
|
4945
5358
|
refreshedAuthority = refreshedCredentials;
|
|
@@ -4960,7 +5373,13 @@ export class AuthStorage {
|
|
|
4960
5373
|
enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
|
|
4961
5374
|
mcpBinding: refreshedAuthority.mcpBinding,
|
|
4962
5375
|
};
|
|
4963
|
-
this.#replaceCredentialAt(
|
|
5376
|
+
this.#replaceCredentialAt(
|
|
5377
|
+
provider,
|
|
5378
|
+
selection.index,
|
|
5379
|
+
updated,
|
|
5380
|
+
!refreshedAuthority.persistedByLease,
|
|
5381
|
+
selectionCredentialId,
|
|
5382
|
+
);
|
|
4964
5383
|
|
|
4965
5384
|
if ((checkUsage && !allowBlocked) || requiresProModel) {
|
|
4966
5385
|
const sameAccount = selection.credential.accountId === updated.accountId;
|
|
@@ -4984,10 +5403,17 @@ export class AuthStorage {
|
|
|
4984
5403
|
return undefined;
|
|
4985
5404
|
}
|
|
4986
5405
|
}
|
|
5406
|
+
if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
|
|
5407
|
+
if (!authCredentialEquals(selection.credential, updated)) return undefined;
|
|
4987
5408
|
this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
|
|
4988
5409
|
return { apiKey: result.apiKey, credential: updated };
|
|
4989
5410
|
} catch (error) {
|
|
4990
|
-
|
|
5411
|
+
// Auth-broker errors retain the sanitized upstream body separately from
|
|
5412
|
+
// their transport message. Include that body for failure classification
|
|
5413
|
+
// (the broker's 500 envelope otherwise hides `invalid_grant`) while
|
|
5414
|
+
// preserving the original error object for callers and diagnostics.
|
|
5415
|
+
const brokerBody = readBrokerErrorBody(error);
|
|
5416
|
+
const errorMsg = [String(error), brokerBody].filter((part): part is string => Boolean(part)).join(" ");
|
|
4991
5417
|
// Peer-rotation recovery runs before ANY failure classification: a
|
|
4992
5418
|
// concurrent process may have rotated the refresh token, which
|
|
4993
5419
|
// invalidates the snapshot token we just attempted. Re-read the row —
|
|
@@ -5006,7 +5432,7 @@ export class AuthStorage {
|
|
|
5006
5432
|
// selection snapshot would misread that adoption as a fresh peer
|
|
5007
5433
|
// rotation and loop reload-retry instead of classifying the failure.
|
|
5008
5434
|
const attemptedRefreshToken = getAttemptedRefreshToken(error) ?? selection.credential.refresh;
|
|
5009
|
-
const attemptedCredentialId =
|
|
5435
|
+
const attemptedCredentialId = selection.id;
|
|
5010
5436
|
if (attemptedCredentialId !== undefined) {
|
|
5011
5437
|
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
|
|
5012
5438
|
const latestCredential = latestRow?.credential;
|
|
@@ -5062,6 +5488,7 @@ export class AuthStorage {
|
|
|
5062
5488
|
selection.index,
|
|
5063
5489
|
selection.credential,
|
|
5064
5490
|
`oauth refresh failed: ${errorMsg}`,
|
|
5491
|
+
attemptedCredentialId,
|
|
5065
5492
|
);
|
|
5066
5493
|
if (!disabled) {
|
|
5067
5494
|
// The CAS predicate compares the row's serialized `data`, so it also
|
|
@@ -5082,7 +5509,35 @@ export class AuthStorage {
|
|
|
5082
5509
|
index: selection.index,
|
|
5083
5510
|
credentialId: attemptedCredentialId,
|
|
5084
5511
|
});
|
|
5085
|
-
|
|
5512
|
+
const disabledCause = `oauth refresh failed: ${errorMsg}`;
|
|
5513
|
+
if (this.#store.disableAuthCredentialRemote) {
|
|
5514
|
+
try {
|
|
5515
|
+
const disabled = await this.#disableCredentialRemotely(
|
|
5516
|
+
provider,
|
|
5517
|
+
attemptedCredentialId,
|
|
5518
|
+
disabledCause,
|
|
5519
|
+
options?.signal,
|
|
5520
|
+
selection.revision,
|
|
5521
|
+
);
|
|
5522
|
+
if (!disabled) {
|
|
5523
|
+
await this.reload();
|
|
5524
|
+
return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
|
|
5525
|
+
}
|
|
5526
|
+
} catch (disableError) {
|
|
5527
|
+
// A failed broker mutation must not replace the provider's
|
|
5528
|
+
// original authentication failure. We also deliberately do
|
|
5529
|
+
// not remove the row locally: remote authority may still be
|
|
5530
|
+
// active and can only be changed by its broker.
|
|
5531
|
+
logger.warn("OAuth refresh remote disable failed", {
|
|
5532
|
+
provider,
|
|
5533
|
+
credentialId: attemptedCredentialId,
|
|
5534
|
+
error: String(disableError),
|
|
5535
|
+
});
|
|
5536
|
+
throw error;
|
|
5537
|
+
}
|
|
5538
|
+
} else {
|
|
5539
|
+
this.#disableCredentialById(provider, attemptedCredentialId, disabledCause);
|
|
5540
|
+
}
|
|
5086
5541
|
} else {
|
|
5087
5542
|
logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
|
|
5088
5543
|
provider,
|
|
@@ -5181,15 +5636,20 @@ export class AuthStorage {
|
|
|
5181
5636
|
* and get a best-effort token. For GitHub Copilot we preserve enterprise
|
|
5182
5637
|
* routing metadata so discovery can hit the correct host.
|
|
5183
5638
|
*/
|
|
5184
|
-
async peekApiKey(provider: string): Promise<string | undefined> {
|
|
5639
|
+
async peekApiKey(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): Promise<string | undefined> {
|
|
5185
5640
|
provider = resolveOAuthStorageProvider(provider);
|
|
5186
5641
|
const runtimeKey = this.#runtimeOverrides.get(provider);
|
|
5187
5642
|
if (runtimeKey) return runtimeKey;
|
|
5188
5643
|
|
|
5189
|
-
const
|
|
5190
|
-
|
|
5644
|
+
const configOverride = this.#configOverrideRegistration(provider, options?.owner);
|
|
5645
|
+
const configKey = configOverride?.apiKey;
|
|
5646
|
+
if (configKey && !configOverride?.envSourced) return configKey;
|
|
5191
5647
|
|
|
5192
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(
|
|
5648
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(
|
|
5649
|
+
provider,
|
|
5650
|
+
options?.owner ? { owner: options.owner } : undefined,
|
|
5651
|
+
undefined,
|
|
5652
|
+
);
|
|
5193
5653
|
if (configKey) {
|
|
5194
5654
|
// Env-sourced (`apiKeyEnv`) override: same precedence as getApiKey —
|
|
5195
5655
|
// a stored api_key credential from `auth login` wins, stored OAuth
|
|
@@ -5239,7 +5699,7 @@ export class AuthStorage {
|
|
|
5239
5699
|
}
|
|
5240
5700
|
}
|
|
5241
5701
|
|
|
5242
|
-
return getEnvApiKey(provider) || this.#
|
|
5702
|
+
return getEnvApiKey(provider) || this.#resolveFallback(provider, options?.owner);
|
|
5243
5703
|
}
|
|
5244
5704
|
|
|
5245
5705
|
/**
|
|
@@ -5270,9 +5730,10 @@ export class AuthStorage {
|
|
|
5270
5730
|
// (e.g. an auth-gateway) and supplied the bearer for that endpoint —
|
|
5271
5731
|
// honor it instead of forwarding an upstream OAuth token that the proxy
|
|
5272
5732
|
// won't accept.
|
|
5273
|
-
const
|
|
5733
|
+
const configOverride = this.#configOverrideRegistration(provider, options?.owner);
|
|
5734
|
+
const configKey = configOverride?.apiKey;
|
|
5274
5735
|
if (configKey) {
|
|
5275
|
-
if (!
|
|
5736
|
+
if (!configOverride?.envSourced) return configKey;
|
|
5276
5737
|
// The override is an `apiKeyEnv` indirection, not a pinned value. A
|
|
5277
5738
|
// stored api_key credential from `auth login` is actively managed
|
|
5278
5739
|
// (validated at login, rotated on 401), while the pointed-to env
|
|
@@ -5334,7 +5795,7 @@ export class AuthStorage {
|
|
|
5334
5795
|
if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
|
|
5335
5796
|
const envKey = getEnvApiKey(provider);
|
|
5336
5797
|
if (envKey) return envKey;
|
|
5337
|
-
return this.#
|
|
5798
|
+
return this.#resolveFallback(provider, options?.owner) ?? undefined;
|
|
5338
5799
|
}
|
|
5339
5800
|
|
|
5340
5801
|
/**
|
|
@@ -5393,7 +5854,7 @@ export class AuthStorage {
|
|
|
5393
5854
|
// Runtime / config overrides intentionally short-circuit OAuth: when the
|
|
5394
5855
|
// user has pinned an API key, they expect the OAuth identity to be
|
|
5395
5856
|
// suppressed (same contract as `getOAuthAccountId`).
|
|
5396
|
-
if (this.#runtimeOverrides.has(provider) || this.#
|
|
5857
|
+
if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, options?.owner)) {
|
|
5397
5858
|
return undefined;
|
|
5398
5859
|
}
|
|
5399
5860
|
const resolved = await this.#resolveOAuthSelection(provider, sessionId, options);
|
|
@@ -5409,13 +5870,7 @@ export class AuthStorage {
|
|
|
5409
5870
|
}
|
|
5410
5871
|
|
|
5411
5872
|
#extractStructuredApiKeyToken(apiKey: string): string | undefined {
|
|
5412
|
-
|
|
5413
|
-
try {
|
|
5414
|
-
const parsed = JSON.parse(apiKey) as { token?: unknown };
|
|
5415
|
-
return typeof parsed.token === "string" ? parsed.token : undefined;
|
|
5416
|
-
} catch {
|
|
5417
|
-
return undefined;
|
|
5418
|
-
}
|
|
5873
|
+
return extractStructuredApiKeyToken(apiKey);
|
|
5419
5874
|
}
|
|
5420
5875
|
|
|
5421
5876
|
async #credentialMatchesApiKey(provider: string, credential: AuthCredential, apiKey: string): Promise<boolean> {
|
|
@@ -5445,6 +5900,9 @@ export class AuthStorage {
|
|
|
5445
5900
|
const signal = isAbortSignalOption(optionsOrSignal) ? optionsOrSignal : optionsOrSignal?.signal;
|
|
5446
5901
|
const sessionId = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.sessionId;
|
|
5447
5902
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
5903
|
+
const owner = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.owner;
|
|
5904
|
+
const ownerOverride = this.#configOverrideRegistration(storageProvider, owner);
|
|
5905
|
+
if (ownerOverride && !ownerOverride.envSourced && ownerOverride.apiKey === apiKey) return false;
|
|
5448
5906
|
const stored = this.#getStoredCredentials(storageProvider);
|
|
5449
5907
|
let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
|
|
5450
5908
|
for (let index = 0; index < stored.length; index++) {
|
|
@@ -5477,7 +5935,7 @@ export class AuthStorage {
|
|
|
5477
5935
|
const latestRows = this.#store.listAuthCredentials(storageProvider);
|
|
5478
5936
|
this.#setStoredCredentials(
|
|
5479
5937
|
storageProvider,
|
|
5480
|
-
latestRows.map(row => ({ id: row.id, credential: row.credential })),
|
|
5938
|
+
latestRows.map(row => ({ id: row.id, credential: row.credential, revision: row.revision })),
|
|
5481
5939
|
);
|
|
5482
5940
|
return true;
|
|
5483
5941
|
}
|
|
@@ -5504,6 +5962,7 @@ export class AuthStorage {
|
|
|
5504
5962
|
provider,
|
|
5505
5963
|
credential: redacted,
|
|
5506
5964
|
identityKey: resolveCredentialIdentityKey(provider, credential),
|
|
5965
|
+
...(entry.revision === undefined ? {} : { revision: entry.revision }),
|
|
5507
5966
|
});
|
|
5508
5967
|
}
|
|
5509
5968
|
}
|
|
@@ -5639,12 +6098,14 @@ export class AuthStorage {
|
|
|
5639
6098
|
enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
|
|
5640
6099
|
mcpBinding: refreshed.mcpBinding,
|
|
5641
6100
|
};
|
|
5642
|
-
this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease);
|
|
6101
|
+
this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease, id);
|
|
6102
|
+
const persisted = this.#store.listAuthCredentials(provider).find(entry => entry.id === id);
|
|
5643
6103
|
return {
|
|
5644
6104
|
id,
|
|
5645
6105
|
provider,
|
|
5646
6106
|
credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
|
|
5647
6107
|
identityKey: resolveCredentialIdentityKey(provider, updated),
|
|
6108
|
+
...(persisted?.revision === undefined ? {} : { revision: persisted.revision }),
|
|
5648
6109
|
};
|
|
5649
6110
|
}
|
|
5650
6111
|
throw new Error(`No credential with id=${id}`);
|
|
@@ -5684,7 +6145,7 @@ export class AuthStorage {
|
|
|
5684
6145
|
const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
|
|
5685
6146
|
this.#setStoredCredentials(
|
|
5686
6147
|
provider,
|
|
5687
|
-
stored.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
6148
|
+
stored.map(entry => ({ id: entry.id, credential: entry.credential, revision: entry.revision })),
|
|
5688
6149
|
);
|
|
5689
6150
|
this.#resetProviderAssignments(provider);
|
|
5690
6151
|
return this.#toSnapshotEntries(provider, stored);
|
|
@@ -5704,15 +6165,21 @@ export class AuthStorage {
|
|
|
5704
6165
|
*
|
|
5705
6166
|
* The string is purely informational; consumers must not parse it.
|
|
5706
6167
|
*/
|
|
5707
|
-
describeCredentialSource(
|
|
6168
|
+
describeCredentialSource(
|
|
6169
|
+
provider: string,
|
|
6170
|
+
sessionId?: string,
|
|
6171
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
6172
|
+
): string | undefined {
|
|
6173
|
+
provider = resolveOAuthStorageProvider(provider);
|
|
5708
6174
|
if (this.#runtimeOverrides.has(provider)) {
|
|
5709
6175
|
return "runtime override (--api-key)";
|
|
5710
6176
|
}
|
|
5711
|
-
|
|
6177
|
+
const configOverride = this.#configOverrideRegistration(provider, options?.owner);
|
|
6178
|
+
if (configOverride) {
|
|
5712
6179
|
// An `apiKeyEnv` indirection loses to a stored api_key credential
|
|
5713
6180
|
// (see getApiKey); describe the credential that actually wins.
|
|
5714
6181
|
const shadowed = this.#getStoredCredentials(provider).some(entry => entry.credential.type === "api_key");
|
|
5715
|
-
if (!
|
|
6182
|
+
if (!configOverride.envSourced || !shadowed) {
|
|
5716
6183
|
return "config override (models.yml)";
|
|
5717
6184
|
}
|
|
5718
6185
|
}
|
|
@@ -5721,7 +6188,7 @@ export class AuthStorage {
|
|
|
5721
6188
|
const stored = this.#getStoredCredentials(provider);
|
|
5722
6189
|
if (stored.length === 0) {
|
|
5723
6190
|
if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
|
|
5724
|
-
if (this.#
|
|
6191
|
+
if (this.#resolveFallback(provider, options?.owner) !== undefined) return `fallback resolver`;
|
|
5725
6192
|
return undefined;
|
|
5726
6193
|
}
|
|
5727
6194
|
|
|
@@ -5955,6 +6422,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5955
6422
|
#updateStmt: Statement;
|
|
5956
6423
|
#deleteStmt: Statement;
|
|
5957
6424
|
#deleteIfMatchesStmt: Statement;
|
|
6425
|
+
#deleteIfRevisionStmt: Statement;
|
|
5958
6426
|
#deleteByProviderStmt: Statement;
|
|
5959
6427
|
#hardDeleteStmt: Statement;
|
|
5960
6428
|
#getCacheStmt: Statement;
|
|
@@ -5995,6 +6463,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5995
6463
|
this.#deleteIfMatchesStmt = this.#db.prepare(
|
|
5996
6464
|
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
5997
6465
|
);
|
|
6466
|
+
this.#deleteIfRevisionStmt = this.#db.prepare(
|
|
6467
|
+
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND revision = ? AND disabled_cause IS NULL`,
|
|
6468
|
+
);
|
|
5998
6469
|
this.#deleteByProviderStmt = this.#db.prepare(
|
|
5999
6470
|
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
6000
6471
|
);
|
|
@@ -6431,6 +6902,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6431
6902
|
id: row.id,
|
|
6432
6903
|
credential: deserializeCredential(row),
|
|
6433
6904
|
identityKey: resolveRowCredentialIdentityKey(providerName, row),
|
|
6905
|
+
revision: row.revision,
|
|
6434
6906
|
}));
|
|
6435
6907
|
|
|
6436
6908
|
const result: StoredAuthCredential[] = [];
|
|
@@ -6447,7 +6919,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6447
6919
|
if (match) {
|
|
6448
6920
|
matchedExistingIds.add(match.id);
|
|
6449
6921
|
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
|
|
6450
|
-
result.push({
|
|
6922
|
+
result.push({
|
|
6923
|
+
id: match.id,
|
|
6924
|
+
provider: providerName,
|
|
6925
|
+
credential,
|
|
6926
|
+
disabledCause: null,
|
|
6927
|
+
revision: match.revision + 1,
|
|
6928
|
+
});
|
|
6451
6929
|
} else {
|
|
6452
6930
|
const row = this.#insertStmt.get(
|
|
6453
6931
|
providerName,
|
|
@@ -6456,7 +6934,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6456
6934
|
serialized.identityKey,
|
|
6457
6935
|
) as { id?: number } | undefined;
|
|
6458
6936
|
if (row?.id) {
|
|
6459
|
-
result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
|
|
6937
|
+
result.push({ id: row.id, provider: providerName, credential, disabledCause: null, revision: 1 });
|
|
6460
6938
|
}
|
|
6461
6939
|
}
|
|
6462
6940
|
}
|
|
@@ -6549,6 +7027,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6549
7027
|
id: number;
|
|
6550
7028
|
credential: AuthCredential;
|
|
6551
7029
|
identityKey: string | null;
|
|
7030
|
+
revision: number;
|
|
6552
7031
|
}> = [];
|
|
6553
7032
|
for (const row of existingRows) {
|
|
6554
7033
|
const activeCredential = deserializeCredential(row);
|
|
@@ -6557,6 +7036,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6557
7036
|
id: row.id,
|
|
6558
7037
|
credential: activeCredential,
|
|
6559
7038
|
identityKey: resolveRowCredentialIdentityKey(providerName, row),
|
|
7039
|
+
revision: row.revision,
|
|
6560
7040
|
});
|
|
6561
7041
|
}
|
|
6562
7042
|
if (existing.length > 0) {
|
|
@@ -6589,6 +7069,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6589
7069
|
provider: providerName,
|
|
6590
7070
|
credential: row.credential,
|
|
6591
7071
|
disabledCause: null,
|
|
7072
|
+
revision: row.revision,
|
|
6592
7073
|
})),
|
|
6593
7074
|
};
|
|
6594
7075
|
}
|
|
@@ -6676,6 +7157,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6676
7157
|
}
|
|
6677
7158
|
}
|
|
6678
7159
|
|
|
7160
|
+
tryDisableAuthCredentialIfRevision(id: number, expectedRevision: number, disabledCause: string): boolean {
|
|
7161
|
+
try {
|
|
7162
|
+
const result = this.#deleteIfRevisionStmt.run(normalizeDisabledCause(disabledCause), id, expectedRevision) as {
|
|
7163
|
+
changes: number;
|
|
7164
|
+
};
|
|
7165
|
+
return result.changes === 1;
|
|
7166
|
+
} catch {
|
|
7167
|
+
return false;
|
|
7168
|
+
}
|
|
7169
|
+
}
|
|
7170
|
+
|
|
6679
7171
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
|
|
6680
7172
|
try {
|
|
6681
7173
|
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
|
|
@@ -6702,6 +7194,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6702
7194
|
}
|
|
6703
7195
|
}
|
|
6704
7196
|
|
|
7197
|
+
allocateMonotonicSequence(key: string, expiresAtSec: number): number {
|
|
7198
|
+
const allocate = this.#db.transaction(() => {
|
|
7199
|
+
const row = this.#getCacheIncludingExpiredStmt.get(key) as { value?: string } | undefined;
|
|
7200
|
+
const current = Number(row?.value);
|
|
7201
|
+
const next = Number.isSafeInteger(current) && current >= 0 ? current + 1 : 1;
|
|
7202
|
+
this.#upsertCacheStmt.run(key, String(next), expiresAtSec);
|
|
7203
|
+
return next;
|
|
7204
|
+
});
|
|
7205
|
+
return allocate();
|
|
7206
|
+
}
|
|
7207
|
+
|
|
6705
7208
|
deleteCachePrefix(prefix: string): void {
|
|
6706
7209
|
if (prefix.length === 0) return;
|
|
6707
7210
|
try {
|
|
@@ -6796,6 +7299,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
6796
7299
|
this.#updateStmt.finalize();
|
|
6797
7300
|
this.#deleteStmt.finalize();
|
|
6798
7301
|
this.#deleteIfMatchesStmt.finalize();
|
|
7302
|
+
this.#deleteIfRevisionStmt.finalize();
|
|
6799
7303
|
this.#deleteByProviderStmt.finalize();
|
|
6800
7304
|
this.#hardDeleteStmt.finalize();
|
|
6801
7305
|
this.#getCacheStmt.finalize();
|