@f5-sales-demo/pi-ai 21.21.0 → 21.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +2 -2
- package/src/auth-storage.ts +509 -97
- package/src/providers/google-gemini-cli.ts +8 -3
- package/src/usage/gemini.ts +5 -1
- package/src/usage/google-antigravity.ts +5 -1
- package/src/usage/kimi.ts +4 -1
- package/src/utils/oauth/anthropic.ts +46 -6
- package/src/utils/oauth/cursor.ts +2 -1
- package/src/utils/oauth/github-copilot.ts +5 -1
- package/src/utils/oauth/gitlab-duo.ts +5 -1
- package/src/utils/oauth/google-antigravity.ts +8 -1
- package/src/utils/oauth/google-gemini-cli.ts +6 -1
- package/src/utils/oauth/index.ts +14 -11
- package/src/utils/oauth/kimi.ts +2 -1
- package/src/utils/oauth/openai-codex.ts +2 -2
- package/src/utils/oauth/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
### Fixed
|
|
15
15
|
|
|
16
|
+
- Coordinated stored OAuth refreshes across processes with renewable SQLite leases, canonical
|
|
17
|
+
re-reads, and fenced writes. This prevents stale refresh attempts from overwriting rotated tokens
|
|
18
|
+
or disabling a peer's winner. Adapted from the upstream Pi
|
|
19
|
+
[lease and compare-and-swap design](https://github.com/can1357/oh-my-pi/commit/591f1c5765fa42cfcd68e020f875dd56b93b8963),
|
|
20
|
+
including its later renewable-lease and fenced-write hardening, prompted by
|
|
21
|
+
[anthropics/claude-code#24317](https://github.com/anthropics/claude-code/issues/24317) and
|
|
22
|
+
[#54443](https://github.com/anthropics/claude-code/issues/54443)
|
|
23
|
+
([#3796](https://github.com/f5-sales-demo/xcsh/issues/3796)).
|
|
16
24
|
- Removed output-only lifecycle statuses before replaying persisted OpenAI Responses items,
|
|
17
25
|
including remote compaction and compaction summaries. Semantically ported from
|
|
18
26
|
[can1357/oh-my-pi#7743](https://github.com/can1357/oh-my-pi/pull/7743) and
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-ai",
|
|
4
|
-
"version": "21.21.
|
|
4
|
+
"version": "21.21.2",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@anthropic-ai/sdk": "^0.115",
|
|
47
47
|
"@aws-sdk/client-bedrock-runtime": "^3",
|
|
48
48
|
"@bufbuild/protobuf": "^2.11",
|
|
49
|
-
"@f5-sales-demo/pi-utils": "21.21.
|
|
49
|
+
"@f5-sales-demo/pi-utils": "21.21.2",
|
|
50
50
|
"@google/genai": "^2.15",
|
|
51
51
|
"@sinclair/typebox": "^0.34",
|
|
52
52
|
"@smithy/node-http-handler": "^4.4",
|
package/src/auth-storage.ts
CHANGED
|
@@ -177,6 +177,74 @@ const USAGE_CACHE_PREFIX = "usage_cache:";
|
|
|
177
177
|
const USAGE_REPORT_TTL_MS = 30_000;
|
|
178
178
|
const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 3_000;
|
|
179
179
|
const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
|
180
|
+
const DEFAULT_OAUTH_REFRESH_SKEW_MS = 0;
|
|
181
|
+
const GOOGLE_OAUTH_REFRESH_SKEW_MS = 60_000;
|
|
182
|
+
const OAUTH_REFRESH_LEASE_TTL_MS = 15_000;
|
|
183
|
+
const OAUTH_REFRESH_LEASE_POLL_MS = 50;
|
|
184
|
+
const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
|
|
185
|
+
|
|
186
|
+
export interface CredentialRefreshLeaseFence {
|
|
187
|
+
owner: string;
|
|
188
|
+
nowMs: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
|
|
192
|
+
credentialId?: number;
|
|
193
|
+
observedCredential?: T;
|
|
194
|
+
credentialFromRow: (credential: OAuthCredential) => T | undefined;
|
|
195
|
+
forceRefresh?: boolean;
|
|
196
|
+
canRefresh?: (credential: T) => boolean;
|
|
197
|
+
refreshSkewMs?: number;
|
|
198
|
+
signal?: AbortSignal;
|
|
199
|
+
keepCredentialOnRefreshFailure?: boolean | ((error: unknown) => boolean);
|
|
200
|
+
onRefreshFailure?: (error: unknown) => void;
|
|
201
|
+
refreshTimeoutMs?: number;
|
|
202
|
+
refresh: (credential: T, signal: AbortSignal) => Promise<OAuthCredentials>;
|
|
203
|
+
mergeRefreshedCredential?: (credential: T, refreshed: OAuthCredentials) => T;
|
|
204
|
+
isDefinitiveFailure?: (error: unknown) => boolean;
|
|
205
|
+
disabledCause?: (error: unknown) => string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface StoredOAuthRefreshResult<T extends OAuthCredential = OAuthCredential> {
|
|
209
|
+
credential: T | undefined;
|
|
210
|
+
refreshed: boolean;
|
|
211
|
+
removed: boolean;
|
|
212
|
+
reauthenticationRequired: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function isDefinitiveOAuthFailure(error: unknown): boolean {
|
|
216
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
217
|
+
return (
|
|
218
|
+
/invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(message) ||
|
|
219
|
+
(/\b(401|403)\b/.test(message) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(message))
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function authCredentialEquals(left: AuthCredential, right: AuthCredential): boolean {
|
|
224
|
+
if (left.type !== right.type) return false;
|
|
225
|
+
if (left.type === "api_key") return right.type === "api_key" && left.key === right.key;
|
|
226
|
+
if (right.type !== "oauth") return false;
|
|
227
|
+
return (
|
|
228
|
+
left.access === right.access &&
|
|
229
|
+
left.refresh === right.refresh &&
|
|
230
|
+
left.expires === right.expires &&
|
|
231
|
+
left.enterpriseUrl === right.enterpriseUrl &&
|
|
232
|
+
left.projectId === right.projectId &&
|
|
233
|
+
left.tierId === right.tierId &&
|
|
234
|
+
left.email === right.email &&
|
|
235
|
+
left.accountId === right.accountId
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function raceRefreshWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
240
|
+
if (!signal) return promise;
|
|
241
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("OAuth refresh aborted"));
|
|
242
|
+
return new Promise<T>((resolve, reject) => {
|
|
243
|
+
const onAbort = (): void => reject(signal.reason ?? new Error("OAuth refresh aborted"));
|
|
244
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
245
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
180
248
|
|
|
181
249
|
/** Exact marker written by #3210 when valid ChatGPT credentials were regression-disabled. */
|
|
182
250
|
export const OPENAI_CODEX_REGRESSION_DISABLED_CAUSE = "unsupported OpenAI Codex OAuth; remove manually with logout";
|
|
@@ -299,6 +367,7 @@ export class AuthStorage {
|
|
|
299
367
|
#usageCache: UsageCache;
|
|
300
368
|
#usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
|
|
301
369
|
#usageReportsInFlight: Map<string, Promise<UsageReport[]>> = new Map();
|
|
370
|
+
#oauthRefreshInFlight: Map<number, Promise<StoredOAuthRefreshResult<OAuthCredential>>> = new Map();
|
|
302
371
|
#usageFetch: typeof fetch;
|
|
303
372
|
#usageRequestTimeoutMs: number;
|
|
304
373
|
#usageLogger?: UsageLogger;
|
|
@@ -623,31 +692,6 @@ export class AuthStorage {
|
|
|
623
692
|
}
|
|
624
693
|
}
|
|
625
694
|
|
|
626
|
-
/** Updates credential at index in-place (used for OAuth token refresh) */
|
|
627
|
-
#replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
|
|
628
|
-
const entries = this.#getStoredCredentials(provider);
|
|
629
|
-
if (index < 0 || index >= entries.length) return;
|
|
630
|
-
const target = entries[index];
|
|
631
|
-
this.#store.updateAuthCredential(target.id, credential);
|
|
632
|
-
const updated = [...entries];
|
|
633
|
-
updated[index] = { id: target.id, credential };
|
|
634
|
-
this.#setStoredCredentials(provider, updated);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Disables credential at index (used when OAuth refresh fails).
|
|
639
|
-
* The credential remains in the database but is excluded from active queries.
|
|
640
|
-
* Cleans up provider entry if last credential disabled.
|
|
641
|
-
*/
|
|
642
|
-
#disableCredentialAt(provider: string, index: number, disabledCause: string): void {
|
|
643
|
-
const entries = this.#getStoredCredentials(provider);
|
|
644
|
-
if (index < 0 || index >= entries.length) return;
|
|
645
|
-
this.#store.deleteAuthCredential(entries[index].id, disabledCause);
|
|
646
|
-
const updated = entries.filter((_value, idx) => idx !== index);
|
|
647
|
-
this.#setStoredCredentials(provider, updated);
|
|
648
|
-
this.#resetProviderAssignments(provider);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
695
|
/**
|
|
652
696
|
* Get credential for a provider (first entry if multiple).
|
|
653
697
|
*/
|
|
@@ -670,6 +714,233 @@ export class AuthStorage {
|
|
|
670
714
|
this.#resetProviderAssignments(provider);
|
|
671
715
|
}
|
|
672
716
|
|
|
717
|
+
/** List active stored credential rows, optionally scoped to one provider. */
|
|
718
|
+
listStoredCredentials(provider?: string): StoredAuthCredential[] {
|
|
719
|
+
if (provider !== undefined) {
|
|
720
|
+
const canonicalProvider = canonicalizeOAuthProviderId(provider);
|
|
721
|
+
return this.#getStoredCredentials(canonicalProvider).map(entry => ({
|
|
722
|
+
id: entry.id,
|
|
723
|
+
provider: canonicalProvider,
|
|
724
|
+
credential: entry.credential,
|
|
725
|
+
disabledCause: null,
|
|
726
|
+
}));
|
|
727
|
+
}
|
|
728
|
+
return [...this.#data.entries()].flatMap(([storedProvider, entries]) =>
|
|
729
|
+
entries.map(entry => ({
|
|
730
|
+
id: entry.id,
|
|
731
|
+
provider: storedProvider,
|
|
732
|
+
credential: entry.credential,
|
|
733
|
+
disabledCause: null,
|
|
734
|
+
})),
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Refresh one SQLite-backed OAuth row under renewable, fenced ownership. */
|
|
739
|
+
async refreshStoredOAuthCredential<T extends OAuthCredential = OAuthCredential>(
|
|
740
|
+
provider: string,
|
|
741
|
+
options: StoredOAuthRefreshOptions<T>,
|
|
742
|
+
): Promise<StoredOAuthRefreshResult<T>> {
|
|
743
|
+
provider = canonicalizeOAuthProviderId(provider);
|
|
744
|
+
if (options.credentialId !== undefined) {
|
|
745
|
+
const existing = this.#oauthRefreshInFlight.get(options.credentialId);
|
|
746
|
+
if (existing) {
|
|
747
|
+
const outcome = (await raceRefreshWithSignal(existing, options.signal)) as StoredOAuthRefreshResult<T>;
|
|
748
|
+
const unchangedObserved =
|
|
749
|
+
outcome.credential !== undefined &&
|
|
750
|
+
(options.observedCredential === undefined ||
|
|
751
|
+
authCredentialEquals(outcome.credential, options.observedCredential));
|
|
752
|
+
if (options.forceRefresh && !outcome.refreshed && unchangedObserved) {
|
|
753
|
+
return this.refreshStoredOAuthCredential(provider, options);
|
|
754
|
+
}
|
|
755
|
+
return outcome;
|
|
756
|
+
}
|
|
757
|
+
const promise = this.#refreshStoredOAuthCredentialUnshared(provider, options).finally(() => {
|
|
758
|
+
this.#oauthRefreshInFlight.delete(options.credentialId!);
|
|
759
|
+
}) as Promise<StoredOAuthRefreshResult<OAuthCredential>>;
|
|
760
|
+
this.#oauthRefreshInFlight.set(options.credentialId, promise);
|
|
761
|
+
return raceRefreshWithSignal(promise, options.signal) as Promise<StoredOAuthRefreshResult<T>>;
|
|
762
|
+
}
|
|
763
|
+
return this.#refreshStoredOAuthCredentialUnshared(provider, options);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async #refreshStoredOAuthCredentialUnshared<T extends OAuthCredential>(
|
|
767
|
+
provider: string,
|
|
768
|
+
options: StoredOAuthRefreshOptions<T>,
|
|
769
|
+
): Promise<StoredOAuthRefreshResult<T>> {
|
|
770
|
+
const result = (
|
|
771
|
+
credential: T | undefined,
|
|
772
|
+
refreshed = false,
|
|
773
|
+
removed = false,
|
|
774
|
+
reauthenticationRequired = false,
|
|
775
|
+
): StoredOAuthRefreshResult<T> => ({ credential, refreshed, removed, reauthenticationRequired });
|
|
776
|
+
const owner = crypto.randomUUID();
|
|
777
|
+
const refreshSkewMs = options.refreshSkewMs ?? DEFAULT_OAUTH_REFRESH_SKEW_MS;
|
|
778
|
+
let leasedCredentialId: number | undefined;
|
|
779
|
+
|
|
780
|
+
const readRow = (): { id: number; credential: T } | undefined => {
|
|
781
|
+
const rows = this.#store.listAuthCredentials(provider);
|
|
782
|
+
this.#setStoredCredentials(
|
|
783
|
+
provider,
|
|
784
|
+
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
785
|
+
);
|
|
786
|
+
const row = rows.find(
|
|
787
|
+
entry =>
|
|
788
|
+
entry.credential.type === "oauth" &&
|
|
789
|
+
(options.credentialId === undefined || entry.id === options.credentialId),
|
|
790
|
+
);
|
|
791
|
+
if (row?.credential.type !== "oauth") return undefined;
|
|
792
|
+
const credential = options.credentialFromRow(row.credential);
|
|
793
|
+
return credential ? { id: row.id, credential } : undefined;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const canAdopt = (credential: T): boolean => Date.now() + refreshSkewMs < credential.expires;
|
|
797
|
+
const shouldReturn = (credential: T): boolean => {
|
|
798
|
+
if (
|
|
799
|
+
options.observedCredential &&
|
|
800
|
+
!authCredentialEquals(credential, options.observedCredential) &&
|
|
801
|
+
canAdopt(credential)
|
|
802
|
+
) {
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
return !options.forceRefresh && canAdopt(credential);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
while (true) {
|
|
809
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("OAuth refresh ownership aborted");
|
|
810
|
+
const row = readRow();
|
|
811
|
+
if (!row) return result(undefined);
|
|
812
|
+
if (shouldReturn(row.credential) || (options.canRefresh && !options.canRefresh(row.credential))) {
|
|
813
|
+
return result(row.credential);
|
|
814
|
+
}
|
|
815
|
+
if (this.#store.tryAcquireCredentialRefreshLease(row.id, owner, Date.now() + OAUTH_REFRESH_LEASE_TTL_MS)) {
|
|
816
|
+
leasedCredentialId = row.id;
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
const leaseExpiresAt = this.#store.getCredentialRefreshLeaseExpiresAt(row.id);
|
|
820
|
+
const waitMs =
|
|
821
|
+
leaseExpiresAt === undefined
|
|
822
|
+
? OAUTH_REFRESH_LEASE_POLL_MS
|
|
823
|
+
: Math.min(Math.max(leaseExpiresAt - Date.now(), OAUTH_REFRESH_LEASE_POLL_MS), 250);
|
|
824
|
+
await raceRefreshWithSignal(Bun.sleep(waitMs), options.signal);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
let stopRenewal = false;
|
|
828
|
+
let leaseLost = false;
|
|
829
|
+
let renewalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
830
|
+
let wakeRenewal: (() => void) | undefined;
|
|
831
|
+
const renewal = (async () => {
|
|
832
|
+
while (!stopRenewal) {
|
|
833
|
+
await new Promise<void>(resolve => {
|
|
834
|
+
wakeRenewal = resolve;
|
|
835
|
+
renewalTimer = setTimeout(resolve, OAUTH_REFRESH_LEASE_RENEW_MS);
|
|
836
|
+
});
|
|
837
|
+
if (renewalTimer) clearTimeout(renewalTimer);
|
|
838
|
+
renewalTimer = undefined;
|
|
839
|
+
wakeRenewal = undefined;
|
|
840
|
+
if (stopRenewal) return;
|
|
841
|
+
if (
|
|
842
|
+
leasedCredentialId === undefined ||
|
|
843
|
+
!this.#store.renewCredentialRefreshLease(
|
|
844
|
+
leasedCredentialId,
|
|
845
|
+
owner,
|
|
846
|
+
Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
|
|
847
|
+
)
|
|
848
|
+
) {
|
|
849
|
+
leaseLost = true;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
})();
|
|
854
|
+
|
|
855
|
+
const refreshAbort = new AbortController();
|
|
856
|
+
const abortFromCaller = (): void =>
|
|
857
|
+
refreshAbort.abort(options.signal?.reason ?? new Error("OAuth refresh aborted by caller"));
|
|
858
|
+
if (options.signal?.aborted) abortFromCaller();
|
|
859
|
+
else options.signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
860
|
+
const timeout = setTimeout(
|
|
861
|
+
() => refreshAbort.abort(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
|
|
862
|
+
options.refreshTimeoutMs ?? DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const row = readRow();
|
|
867
|
+
if (!row || row.id !== leasedCredentialId) return result(undefined);
|
|
868
|
+
const current = row.credential;
|
|
869
|
+
if (shouldReturn(current) || (options.canRefresh && !options.canRefresh(current))) return result(current);
|
|
870
|
+
const serialized = serializeCredential(provider, current);
|
|
871
|
+
if (!serialized) return result(current);
|
|
872
|
+
|
|
873
|
+
let refreshed: OAuthCredentials;
|
|
874
|
+
try {
|
|
875
|
+
refreshed = await options.refresh(current, refreshAbort.signal);
|
|
876
|
+
if (refreshAbort.signal.aborted) {
|
|
877
|
+
throw refreshAbort.signal.reason ?? new Error("OAuth refresh aborted");
|
|
878
|
+
}
|
|
879
|
+
} catch (error) {
|
|
880
|
+
if (refreshAbort.signal.aborted) {
|
|
881
|
+
throw refreshAbort.signal.reason ?? error;
|
|
882
|
+
}
|
|
883
|
+
if (options.isDefinitiveFailure?.(error)) {
|
|
884
|
+
const disabledCause = options.disabledCause?.(error) ?? `oauth refresh failed: ${String(error)}`;
|
|
885
|
+
const disabled = this.#store.tryDisableAuthCredentialIfMatches(row.id, serialized.data, disabledCause, {
|
|
886
|
+
owner,
|
|
887
|
+
nowMs: Date.now(),
|
|
888
|
+
});
|
|
889
|
+
if (disabled) {
|
|
890
|
+
const latest = this.#store.listAuthCredentials(provider);
|
|
891
|
+
this.#setStoredCredentials(
|
|
892
|
+
provider,
|
|
893
|
+
latest.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
894
|
+
);
|
|
895
|
+
this.#resetProviderAssignments(provider);
|
|
896
|
+
return result(undefined, false, true, true);
|
|
897
|
+
}
|
|
898
|
+
const winner = readRow();
|
|
899
|
+
return result(winner?.credential);
|
|
900
|
+
}
|
|
901
|
+
options.onRefreshFailure?.(error);
|
|
902
|
+
const keep =
|
|
903
|
+
typeof options.keepCredentialOnRefreshFailure === "function"
|
|
904
|
+
? options.keepCredentialOnRefreshFailure(error)
|
|
905
|
+
: options.keepCredentialOnRefreshFailure === true;
|
|
906
|
+
if (keep) return result(current);
|
|
907
|
+
throw error;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (leaseLost) return result(readRow()?.credential);
|
|
911
|
+
const merged = options.mergeRefreshedCredential
|
|
912
|
+
? options.mergeRefreshedCredential(current, refreshed)
|
|
913
|
+
: ({
|
|
914
|
+
...current,
|
|
915
|
+
...refreshed,
|
|
916
|
+
accountId: refreshed.accountId ?? current.accountId,
|
|
917
|
+
email: refreshed.email ?? current.email,
|
|
918
|
+
projectId: refreshed.projectId ?? current.projectId,
|
|
919
|
+
tierId: refreshed.tierId ?? current.tierId,
|
|
920
|
+
enterpriseUrl: refreshed.enterpriseUrl ?? current.enterpriseUrl,
|
|
921
|
+
} as T);
|
|
922
|
+
const updated = this.#store.tryUpdateAuthCredentialIfMatches(row.id, serialized.data, merged, {
|
|
923
|
+
owner,
|
|
924
|
+
nowMs: Date.now(),
|
|
925
|
+
});
|
|
926
|
+
if (!updated) return result(readRow()?.credential);
|
|
927
|
+
const rows = this.#store.listAuthCredentials(provider);
|
|
928
|
+
this.#setStoredCredentials(
|
|
929
|
+
provider,
|
|
930
|
+
rows.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
931
|
+
);
|
|
932
|
+
return result(merged, true);
|
|
933
|
+
} finally {
|
|
934
|
+
clearTimeout(timeout);
|
|
935
|
+
options.signal?.removeEventListener("abort", abortFromCaller);
|
|
936
|
+
stopRenewal = true;
|
|
937
|
+
if (renewalTimer) clearTimeout(renewalTimer);
|
|
938
|
+
wakeRenewal?.();
|
|
939
|
+
await renewal;
|
|
940
|
+
if (leasedCredentialId !== undefined) this.#store.releaseCredentialRefreshLease(leasedCredentialId, owner);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
673
944
|
async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
|
|
674
945
|
const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
|
|
675
946
|
this.#setStoredCredentials(
|
|
@@ -1135,32 +1406,10 @@ export class AuthStorage {
|
|
|
1135
1406
|
};
|
|
1136
1407
|
}
|
|
1137
1408
|
|
|
1138
|
-
#
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
|
|
1143
|
-
if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
|
|
1144
|
-
return (
|
|
1145
|
-
entry.credential.accountId === previous.accountId &&
|
|
1146
|
-
entry.credential.email === previous.email &&
|
|
1147
|
-
entry.credential.projectId === previous.projectId
|
|
1148
|
-
);
|
|
1149
|
-
});
|
|
1150
|
-
if (index === -1) return;
|
|
1151
|
-
const existing = entries[index]!.credential;
|
|
1152
|
-
if (existing.type !== "oauth") return;
|
|
1153
|
-
this.#replaceCredentialAt(provider, index, {
|
|
1154
|
-
type: "oauth",
|
|
1155
|
-
access: next.accessToken ?? existing.access,
|
|
1156
|
-
refresh: next.refreshToken ?? existing.refresh,
|
|
1157
|
-
expires: next.expiresAt ?? existing.expires,
|
|
1158
|
-
accountId: next.accountId,
|
|
1159
|
-
projectId: next.projectId,
|
|
1160
|
-
tierId: next.tierId ?? existing.tierId,
|
|
1161
|
-
email: next.email,
|
|
1162
|
-
enterpriseUrl: next.enterpriseUrl,
|
|
1163
|
-
});
|
|
1409
|
+
#findStoredOAuthCredential(provider: Provider, observed: OAuthCredential): StoredCredential | undefined {
|
|
1410
|
+
return this.#getStoredCredentials(provider).find(
|
|
1411
|
+
entry => entry.credential.type === "oauth" && authCredentialEquals(entry.credential, observed),
|
|
1412
|
+
);
|
|
1164
1413
|
}
|
|
1165
1414
|
|
|
1166
1415
|
async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
|
|
@@ -1183,14 +1432,30 @@ export class AuthStorage {
|
|
|
1183
1432
|
) {
|
|
1184
1433
|
const refreshableCredential = this.#buildRefreshableOauthCredential(request.credential);
|
|
1185
1434
|
if (refreshableCredential) {
|
|
1435
|
+
const stored = this.#findStoredOAuthCredential(request.provider, refreshableCredential);
|
|
1186
1436
|
try {
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1437
|
+
if (stored) {
|
|
1438
|
+
const outcome = await this.refreshStoredOAuthCredential(request.provider, {
|
|
1439
|
+
credentialId: stored.id,
|
|
1440
|
+
observedCredential: refreshableCredential,
|
|
1441
|
+
credentialFromRow: current => current,
|
|
1442
|
+
signal: timeoutSignal,
|
|
1443
|
+
refresh: (current, refreshSignal) =>
|
|
1444
|
+
this.#requestOAuthCredentialRefresh(request.provider, current, refreshSignal),
|
|
1445
|
+
keepCredentialOnRefreshFailure: true,
|
|
1446
|
+
onRefreshFailure: error =>
|
|
1447
|
+
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
|
|
1448
|
+
provider: request.provider,
|
|
1449
|
+
error: String(error),
|
|
1450
|
+
}),
|
|
1451
|
+
});
|
|
1452
|
+
if (outcome.credential) {
|
|
1453
|
+
params = {
|
|
1454
|
+
...params,
|
|
1455
|
+
credential: this.#mergeRefreshedUsageCredential(request.credential, outcome.credential),
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1194
1459
|
} catch (error) {
|
|
1195
1460
|
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
|
|
1196
1461
|
provider: request.provider,
|
|
@@ -1594,12 +1859,12 @@ export class AuthStorage {
|
|
|
1594
1859
|
providerKey: string;
|
|
1595
1860
|
provider: string;
|
|
1596
1861
|
order: number[];
|
|
1597
|
-
credentials: Array<{ credential: OAuthCredential; index: number }>;
|
|
1862
|
+
credentials: Array<{ credential: OAuthCredential; index: number; credentialId: number }>;
|
|
1598
1863
|
options?: AuthApiKeyOptions;
|
|
1599
1864
|
strategy: CredentialRankingStrategy;
|
|
1600
1865
|
}): Promise<
|
|
1601
1866
|
Array<{
|
|
1602
|
-
selection: { credential: OAuthCredential; index: number };
|
|
1867
|
+
selection: { credential: OAuthCredential; index: number; credentialId: number };
|
|
1603
1868
|
usage: UsageReport | null;
|
|
1604
1869
|
usageChecked: boolean;
|
|
1605
1870
|
}>
|
|
@@ -1607,7 +1872,7 @@ export class AuthStorage {
|
|
|
1607
1872
|
const nowMs = Date.now();
|
|
1608
1873
|
const { strategy } = args;
|
|
1609
1874
|
const ranked: Array<{
|
|
1610
|
-
selection: { credential: OAuthCredential; index: number };
|
|
1875
|
+
selection: { credential: OAuthCredential; index: number; credentialId: number };
|
|
1611
1876
|
usage: UsageReport | null;
|
|
1612
1877
|
usageChecked: boolean;
|
|
1613
1878
|
blocked: boolean;
|
|
@@ -1706,9 +1971,12 @@ export class AuthStorage {
|
|
|
1706
1971
|
sessionId?: string,
|
|
1707
1972
|
options?: AuthApiKeyOptions,
|
|
1708
1973
|
): Promise<string | undefined> {
|
|
1709
|
-
const credentials = this.#
|
|
1710
|
-
.map((
|
|
1711
|
-
.filter(
|
|
1974
|
+
const credentials = this.#getStoredCredentials(provider)
|
|
1975
|
+
.map((entry, index) => ({ credential: entry.credential, index, credentialId: entry.id }))
|
|
1976
|
+
.filter(
|
|
1977
|
+
(entry): entry is { credential: OAuthCredential; index: number; credentialId: number } =>
|
|
1978
|
+
entry.credential.type === "oauth",
|
|
1979
|
+
);
|
|
1712
1980
|
|
|
1713
1981
|
if (credentials.length === 0) return undefined;
|
|
1714
1982
|
|
|
@@ -1730,7 +1998,9 @@ export class AuthStorage {
|
|
|
1730
1998
|
? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
|
|
1731
1999
|
: order
|
|
1732
2000
|
.map(idx => credentials[idx])
|
|
1733
|
-
.filter((selection): selection is { credential: OAuthCredential; index: number } =>
|
|
2001
|
+
.filter((selection): selection is { credential: OAuthCredential; index: number; credentialId: number } =>
|
|
2002
|
+
Boolean(selection),
|
|
2003
|
+
)
|
|
1734
2004
|
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
|
1735
2005
|
|
|
1736
2006
|
if (sessionPreferredIndex !== undefined && !requiresProModel) {
|
|
@@ -1747,7 +2017,9 @@ export class AuthStorage {
|
|
|
1747
2017
|
await Promise.all(
|
|
1748
2018
|
candidates.map(async candidate => {
|
|
1749
2019
|
if (Date.now() < candidate.selection.credential.expires) return;
|
|
1750
|
-
const latestCredential = this.#
|
|
2020
|
+
const latestCredential = this.#getStoredCredentials(provider).find(
|
|
2021
|
+
entry => entry.id === candidate.selection.credentialId,
|
|
2022
|
+
)?.credential;
|
|
1751
2023
|
if (latestCredential?.type === "oauth" && Date.now() < latestCredential.expires) {
|
|
1752
2024
|
candidate.selection.credential = latestCredential;
|
|
1753
2025
|
return;
|
|
@@ -1756,6 +2028,7 @@ export class AuthStorage {
|
|
|
1756
2028
|
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
1757
2029
|
provider,
|
|
1758
2030
|
candidate.selection.credential,
|
|
2031
|
+
candidate.selection.credentialId,
|
|
1759
2032
|
);
|
|
1760
2033
|
candidate.selection.credential = {
|
|
1761
2034
|
...candidate.selection.credential,
|
|
@@ -1790,37 +2063,51 @@ export class AuthStorage {
|
|
|
1790
2063
|
return undefined;
|
|
1791
2064
|
}
|
|
1792
2065
|
|
|
1793
|
-
async #refreshOAuthCredential(
|
|
1794
|
-
|
|
2066
|
+
async #refreshOAuthCredential(
|
|
2067
|
+
provider: Provider,
|
|
2068
|
+
credential: OAuthCredential,
|
|
2069
|
+
credentialId: number,
|
|
2070
|
+
signal?: AbortSignal,
|
|
2071
|
+
): Promise<OAuthCredentials> {
|
|
2072
|
+
const outcome = await this.refreshStoredOAuthCredential(provider, {
|
|
2073
|
+
credentialId,
|
|
2074
|
+
observedCredential: credential,
|
|
2075
|
+
credentialFromRow: current => current,
|
|
2076
|
+
signal,
|
|
2077
|
+
refreshSkewMs:
|
|
2078
|
+
provider === "google-gemini-cli" || provider === "google-antigravity"
|
|
2079
|
+
? GOOGLE_OAUTH_REFRESH_SKEW_MS
|
|
2080
|
+
: DEFAULT_OAUTH_REFRESH_SKEW_MS,
|
|
2081
|
+
refresh: (current, refreshSignal) => this.#requestOAuthCredentialRefresh(provider, current, refreshSignal),
|
|
2082
|
+
isDefinitiveFailure: isDefinitiveOAuthFailure,
|
|
2083
|
+
disabledCause: error => `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2084
|
+
});
|
|
2085
|
+
if (outcome.credential) return outcome.credential;
|
|
2086
|
+
if (outcome.reauthenticationRequired) {
|
|
2087
|
+
throw new Error(`OAuth reauthentication required for provider: ${provider}`);
|
|
2088
|
+
}
|
|
2089
|
+
throw new Error(`OAuth credential no longer exists for provider: ${provider}`);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
async #requestOAuthCredentialRefresh(
|
|
2093
|
+
provider: Provider,
|
|
2094
|
+
credential: OAuthCredential,
|
|
2095
|
+
signal: AbortSignal,
|
|
2096
|
+
): Promise<OAuthCredentials> {
|
|
1795
2097
|
const customProvider = getOAuthProvider(provider);
|
|
1796
|
-
let refreshPromise: Promise<OAuthCredentials>;
|
|
1797
2098
|
if (customProvider) {
|
|
1798
2099
|
if (!customProvider.refreshToken) {
|
|
1799
2100
|
throw new Error(`OAuth provider "${provider}" does not support token refresh`);
|
|
1800
2101
|
}
|
|
1801
|
-
|
|
1802
|
-
} else {
|
|
1803
|
-
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
|
|
1804
|
-
}
|
|
1805
|
-
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
|
|
1806
|
-
let timeout: NodeJS.Timeout | undefined;
|
|
1807
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
1808
|
-
timeout = setTimeout(
|
|
1809
|
-
() => reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
|
|
1810
|
-
DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
|
|
1811
|
-
);
|
|
1812
|
-
});
|
|
1813
|
-
try {
|
|
1814
|
-
return await Promise.race([refreshPromise, timeoutPromise]);
|
|
1815
|
-
} finally {
|
|
1816
|
-
if (timeout) clearTimeout(timeout);
|
|
2102
|
+
return customProvider.refreshToken(credential, signal);
|
|
1817
2103
|
}
|
|
2104
|
+
return refreshOAuthToken(provider as OAuthProvider, credential, signal);
|
|
1818
2105
|
}
|
|
1819
2106
|
|
|
1820
2107
|
/** Attempts to use a single OAuth credential, checking usage and refreshing token. */
|
|
1821
2108
|
async #tryOAuthCredential(
|
|
1822
2109
|
provider: Provider,
|
|
1823
|
-
selection: { credential: OAuthCredential; index: number },
|
|
2110
|
+
selection: { credential: OAuthCredential; index: number; credentialId: number },
|
|
1824
2111
|
providerKey: string,
|
|
1825
2112
|
sessionId: string | undefined,
|
|
1826
2113
|
options: AuthApiKeyOptions | undefined,
|
|
@@ -1863,18 +2150,25 @@ export class AuthStorage {
|
|
|
1863
2150
|
|
|
1864
2151
|
try {
|
|
1865
2152
|
let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
|
|
2153
|
+
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
2154
|
+
provider,
|
|
2155
|
+
selection.credential,
|
|
2156
|
+
selection.credentialId,
|
|
2157
|
+
);
|
|
2158
|
+
if (Date.now() >= refreshedCredentials.expires) {
|
|
2159
|
+
throw new Error(`OAuth refresh coordination did not yield a usable credential for provider: ${provider}`);
|
|
2160
|
+
}
|
|
1866
2161
|
const customProvider = getOAuthProvider(provider);
|
|
1867
2162
|
if (customProvider) {
|
|
1868
|
-
const refreshedCredentials = await this.#refreshOAuthCredential(provider, selection.credential);
|
|
1869
2163
|
const apiKey = customProvider.getApiKey
|
|
1870
2164
|
? customProvider.getApiKey(refreshedCredentials)
|
|
1871
2165
|
: refreshedCredentials.access;
|
|
1872
2166
|
result = { newCredentials: refreshedCredentials, apiKey };
|
|
1873
2167
|
} else {
|
|
1874
2168
|
const oauthCreds: Record<string, OAuthCredentials> = {
|
|
1875
|
-
[provider]:
|
|
2169
|
+
[provider]: refreshedCredentials,
|
|
1876
2170
|
};
|
|
1877
|
-
result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
|
|
2171
|
+
result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds, { refreshExpired: false });
|
|
1878
2172
|
}
|
|
1879
2173
|
if (!result) return undefined;
|
|
1880
2174
|
const updated: OAuthCredential = {
|
|
@@ -1888,7 +2182,7 @@ export class AuthStorage {
|
|
|
1888
2182
|
tierId: result.newCredentials.tierId ?? selection.credential.tierId,
|
|
1889
2183
|
enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
|
|
1890
2184
|
};
|
|
1891
|
-
|
|
2185
|
+
selection.credential = updated;
|
|
1892
2186
|
if (checkUsage && !allowBlocked) {
|
|
1893
2187
|
const sameAccount = selection.credential.accountId === updated.accountId;
|
|
1894
2188
|
if (!usageChecked || !sameAccount) {
|
|
@@ -1913,9 +2207,7 @@ export class AuthStorage {
|
|
|
1913
2207
|
const errorMsg = String(error);
|
|
1914
2208
|
// Only remove credentials for definitive auth failures
|
|
1915
2209
|
// Keep credentials for transient errors (network, 5xx) and block temporarily
|
|
1916
|
-
const isDefinitiveFailure =
|
|
1917
|
-
/invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
|
|
1918
|
-
(/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
|
|
2210
|
+
const isDefinitiveFailure = isDefinitiveOAuthFailure(error);
|
|
1919
2211
|
|
|
1920
2212
|
logger.warn("OAuth token refresh failed", {
|
|
1921
2213
|
provider,
|
|
@@ -1924,9 +2216,7 @@ export class AuthStorage {
|
|
|
1924
2216
|
isDefinitiveFailure,
|
|
1925
2217
|
});
|
|
1926
2218
|
|
|
1927
|
-
if (isDefinitiveFailure) {
|
|
1928
|
-
// Permanently disable invalid credentials with an explicit cause for inspection/debugging
|
|
1929
|
-
this.#disableCredentialAt(provider, selection.index, `oauth refresh failed: ${errorMsg}`);
|
|
2219
|
+
if (isDefinitiveFailure || /reauthentication required/i.test(errorMsg)) {
|
|
1930
2220
|
if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
|
|
1931
2221
|
return this.getApiKey(provider, sessionId, options);
|
|
1932
2222
|
}
|
|
@@ -2035,7 +2325,7 @@ type SerializedCredentialRecord = {
|
|
|
2035
2325
|
identityKey: string | null;
|
|
2036
2326
|
};
|
|
2037
2327
|
|
|
2038
|
-
const AUTH_SCHEMA_VERSION =
|
|
2328
|
+
const AUTH_SCHEMA_VERSION = 5;
|
|
2039
2329
|
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
2040
2330
|
|
|
2041
2331
|
function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
|
|
@@ -2209,12 +2499,18 @@ export class AuthCredentialStore {
|
|
|
2209
2499
|
#listDisabledByProviderStmt: Statement;
|
|
2210
2500
|
#insertStmt: Statement;
|
|
2211
2501
|
#updateStmt: Statement;
|
|
2502
|
+
#updateIfMatchesWithLeaseStmt: Statement;
|
|
2212
2503
|
#deleteStmt: Statement;
|
|
2504
|
+
#deleteIfMatchesWithLeaseStmt: Statement;
|
|
2213
2505
|
#deleteByProviderStmt: Statement;
|
|
2214
2506
|
#hardDeleteStmt: Statement;
|
|
2215
2507
|
#getCacheStmt: Statement;
|
|
2216
2508
|
#upsertCacheStmt: Statement;
|
|
2217
2509
|
#deleteExpiredCacheStmt: Statement;
|
|
2510
|
+
#acquireCredentialRefreshLeaseStmt: Statement;
|
|
2511
|
+
#getCredentialRefreshLeaseStmt: Statement;
|
|
2512
|
+
#renewCredentialRefreshLeaseStmt: Statement;
|
|
2513
|
+
#releaseCredentialRefreshLeaseStmt: Statement;
|
|
2218
2514
|
#closed = false;
|
|
2219
2515
|
|
|
2220
2516
|
constructor(db: Database) {
|
|
@@ -2236,9 +2532,27 @@ export class AuthCredentialStore {
|
|
|
2236
2532
|
this.#updateStmt = this.#db.prepare(
|
|
2237
2533
|
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
2238
2534
|
);
|
|
2535
|
+
this.#updateIfMatchesWithLeaseStmt = this.#db.prepare(
|
|
2536
|
+
`UPDATE auth_credentials
|
|
2537
|
+
SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH}
|
|
2538
|
+
WHERE id = ? AND data = ? AND disabled_cause IS NULL
|
|
2539
|
+
AND EXISTS (
|
|
2540
|
+
SELECT 1 FROM auth_credential_refresh_leases
|
|
2541
|
+
WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
|
|
2542
|
+
)`,
|
|
2543
|
+
);
|
|
2239
2544
|
this.#deleteStmt = this.#db.prepare(
|
|
2240
2545
|
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
2241
2546
|
);
|
|
2547
|
+
this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare(
|
|
2548
|
+
`UPDATE auth_credentials
|
|
2549
|
+
SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH}
|
|
2550
|
+
WHERE id = ? AND data = ? AND disabled_cause IS NULL
|
|
2551
|
+
AND EXISTS (
|
|
2552
|
+
SELECT 1 FROM auth_credential_refresh_leases
|
|
2553
|
+
WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
|
|
2554
|
+
)`,
|
|
2555
|
+
);
|
|
2242
2556
|
this.#deleteByProviderStmt = this.#db.prepare(
|
|
2243
2557
|
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
2244
2558
|
);
|
|
@@ -2250,6 +2564,24 @@ export class AuthCredentialStore {
|
|
|
2250
2564
|
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
|
|
2251
2565
|
);
|
|
2252
2566
|
this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
|
|
2567
|
+
this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
2568
|
+
`INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at)
|
|
2569
|
+
VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH})
|
|
2570
|
+
ON CONFLICT(credential_id) DO UPDATE SET
|
|
2571
|
+
owner = excluded.owner,
|
|
2572
|
+
expires_at_ms = excluded.expires_at_ms,
|
|
2573
|
+
updated_at = excluded.updated_at
|
|
2574
|
+
WHERE auth_credential_refresh_leases.expires_at_ms <= ?`,
|
|
2575
|
+
);
|
|
2576
|
+
this.#getCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
2577
|
+
"SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?",
|
|
2578
|
+
);
|
|
2579
|
+
this.#renewCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
2580
|
+
`UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`,
|
|
2581
|
+
);
|
|
2582
|
+
this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
2583
|
+
"DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
|
|
2584
|
+
);
|
|
2253
2585
|
this.reactivateRegressionDisabledOpenAICodexCredentials();
|
|
2254
2586
|
}
|
|
2255
2587
|
|
|
@@ -2288,6 +2620,14 @@ export class AuthCredentialStore {
|
|
|
2288
2620
|
expires_at INTEGER NOT NULL
|
|
2289
2621
|
);
|
|
2290
2622
|
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
|
2623
|
+
CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases (
|
|
2624
|
+
credential_id INTEGER PRIMARY KEY,
|
|
2625
|
+
owner TEXT NOT NULL,
|
|
2626
|
+
expires_at_ms INTEGER NOT NULL,
|
|
2627
|
+
updated_at INTEGER NOT NULL
|
|
2628
|
+
);
|
|
2629
|
+
CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires
|
|
2630
|
+
ON auth_credential_refresh_leases(expires_at_ms);
|
|
2291
2631
|
`);
|
|
2292
2632
|
|
|
2293
2633
|
if (!this.#authCredentialsTableExists()) {
|
|
@@ -2377,6 +2717,7 @@ export class AuthCredentialStore {
|
|
|
2377
2717
|
if (fromVersion < 4) {
|
|
2378
2718
|
this.#migrateAuthSchemaV3ToV4();
|
|
2379
2719
|
}
|
|
2720
|
+
// Version 5 is additive; the refresh-lease table is created before migration.
|
|
2380
2721
|
}
|
|
2381
2722
|
|
|
2382
2723
|
#migrateAuthSchemaV0ToV1(): void {
|
|
@@ -2684,6 +3025,33 @@ export class AuthCredentialStore {
|
|
|
2684
3025
|
}
|
|
2685
3026
|
}
|
|
2686
3027
|
|
|
3028
|
+
tryUpdateAuthCredentialIfMatches(
|
|
3029
|
+
id: number,
|
|
3030
|
+
expectedData: string,
|
|
3031
|
+
credential: AuthCredential,
|
|
3032
|
+
lease: CredentialRefreshLeaseFence,
|
|
3033
|
+
): boolean {
|
|
3034
|
+
const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
|
|
3035
|
+
| { provider?: string }
|
|
3036
|
+
| undefined;
|
|
3037
|
+
const provider = providerRow?.provider ?? "";
|
|
3038
|
+
const serialized = serializeCredential(provider, credential);
|
|
3039
|
+
if (!serialized) return false;
|
|
3040
|
+
const outcome = this.#updateIfMatchesWithLeaseStmt.run(
|
|
3041
|
+
serialized.credentialType,
|
|
3042
|
+
serialized.data,
|
|
3043
|
+
serialized.identityKey,
|
|
3044
|
+
id,
|
|
3045
|
+
expectedData,
|
|
3046
|
+
id,
|
|
3047
|
+
lease.owner,
|
|
3048
|
+
lease.nowMs,
|
|
3049
|
+
) as { changes: number };
|
|
3050
|
+
if (outcome.changes === 0) return false;
|
|
3051
|
+
if (provider) this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
3052
|
+
return true;
|
|
3053
|
+
}
|
|
3054
|
+
|
|
2687
3055
|
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
2688
3056
|
try {
|
|
2689
3057
|
this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
|
|
@@ -2692,6 +3060,50 @@ export class AuthCredentialStore {
|
|
|
2692
3060
|
}
|
|
2693
3061
|
}
|
|
2694
3062
|
|
|
3063
|
+
tryDisableAuthCredentialIfMatches(
|
|
3064
|
+
id: number,
|
|
3065
|
+
expectedData: string,
|
|
3066
|
+
disabledCause: string,
|
|
3067
|
+
lease: CredentialRefreshLeaseFence,
|
|
3068
|
+
): boolean {
|
|
3069
|
+
const outcome = this.#deleteIfMatchesWithLeaseStmt.run(
|
|
3070
|
+
normalizeDisabledCause(disabledCause),
|
|
3071
|
+
id,
|
|
3072
|
+
expectedData,
|
|
3073
|
+
id,
|
|
3074
|
+
lease.owner,
|
|
3075
|
+
lease.nowMs,
|
|
3076
|
+
) as { changes: number };
|
|
3077
|
+
return outcome.changes === 1;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
3081
|
+
const outcome = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as {
|
|
3082
|
+
changes: number;
|
|
3083
|
+
};
|
|
3084
|
+
return outcome.changes === 1;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined {
|
|
3088
|
+
const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined;
|
|
3089
|
+
return typeof row?.expires_at_ms === "number" && row.expires_at_ms > Date.now() ? row.expires_at_ms : undefined;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
3093
|
+
const outcome = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as {
|
|
3094
|
+
changes: number;
|
|
3095
|
+
};
|
|
3096
|
+
return outcome.changes === 1;
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
releaseCredentialRefreshLease(credentialId: number, owner: string): void {
|
|
3100
|
+
try {
|
|
3101
|
+
this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner);
|
|
3102
|
+
} catch {
|
|
3103
|
+
// Expired leases remain recoverable if best-effort release fails.
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2695
3107
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
|
|
2696
3108
|
try {
|
|
2697
3109
|
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
|
|
@@ -373,6 +373,7 @@ export function shouldRefreshGeminiCliCredentials(
|
|
|
373
373
|
async function refreshGeminiCliCredentialsIfNeeded(
|
|
374
374
|
credentials: ParsedGeminiCliCredentials,
|
|
375
375
|
isAntigravity: boolean,
|
|
376
|
+
signal: AbortSignal,
|
|
376
377
|
): Promise<ParsedGeminiCliCredentials> {
|
|
377
378
|
if (!credentials.refreshToken || !shouldRefreshGeminiCliCredentials(credentials.expiresAt, isAntigravity)) {
|
|
378
379
|
return credentials;
|
|
@@ -380,8 +381,8 @@ async function refreshGeminiCliCredentialsIfNeeded(
|
|
|
380
381
|
|
|
381
382
|
try {
|
|
382
383
|
const refreshed = isAntigravity
|
|
383
|
-
? await refreshAntigravityToken(credentials.refreshToken, credentials.projectId)
|
|
384
|
-
: await refreshGoogleCloudToken(credentials.refreshToken, credentials.projectId);
|
|
384
|
+
? await refreshAntigravityToken(credentials.refreshToken, credentials.projectId, signal)
|
|
385
|
+
: await refreshGoogleCloudToken(credentials.refreshToken, credentials.projectId, signal);
|
|
385
386
|
return {
|
|
386
387
|
accessToken: refreshed.access,
|
|
387
388
|
projectId: credentials.projectId,
|
|
@@ -503,7 +504,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
503
504
|
|
|
504
505
|
const isAntigravity = model.provider === "google-antigravity";
|
|
505
506
|
const parsedCredentials = parseGeminiCliCredentials(apiKeyRaw);
|
|
506
|
-
const activeCredentials = await refreshGeminiCliCredentialsIfNeeded(
|
|
507
|
+
const activeCredentials = await refreshGeminiCliCredentialsIfNeeded(
|
|
508
|
+
parsedCredentials,
|
|
509
|
+
isAntigravity,
|
|
510
|
+
options?.signal ?? new AbortController().signal,
|
|
511
|
+
);
|
|
507
512
|
const { accessToken, projectId } = activeCredentials;
|
|
508
513
|
const vertexModelId = isAntigravity ? ANTIGRAVITY_VERTEX_MODELS[model.id] : undefined;
|
|
509
514
|
const serviceName = vertexModelId ? "Vertex AI" : "Cloud Code Assist";
|
package/src/usage/gemini.ts
CHANGED
|
@@ -108,7 +108,11 @@ async function resolveAccessToken(params: UsageFetchParams, ctx: UsageFetchConte
|
|
|
108
108
|
}
|
|
109
109
|
if (!credential.refreshToken || !credential.projectId) return credential.accessToken;
|
|
110
110
|
try {
|
|
111
|
-
const refreshed = await refreshGoogleCloudToken(
|
|
111
|
+
const refreshed = await refreshGoogleCloudToken(
|
|
112
|
+
credential.refreshToken,
|
|
113
|
+
credential.projectId,
|
|
114
|
+
params.signal ?? new AbortController().signal,
|
|
115
|
+
);
|
|
112
116
|
return refreshed.access;
|
|
113
117
|
} catch (error) {
|
|
114
118
|
ctx.logger?.warn("Gemini CLI token refresh failed", { error: String(error) });
|
|
@@ -108,7 +108,11 @@ async function resolveAccessToken(params: UsageFetchParams, ctx: UsageFetchConte
|
|
|
108
108
|
}
|
|
109
109
|
if (!credential.refreshToken || !credential.projectId) return undefined;
|
|
110
110
|
try {
|
|
111
|
-
const refreshed = await refreshAntigravityToken(
|
|
111
|
+
const refreshed = await refreshAntigravityToken(
|
|
112
|
+
credential.refreshToken,
|
|
113
|
+
credential.projectId,
|
|
114
|
+
params.signal ?? new AbortController().signal,
|
|
115
|
+
);
|
|
112
116
|
return refreshed.access;
|
|
113
117
|
} catch (error) {
|
|
114
118
|
ctx.logger?.warn("Antigravity usage token refresh failed", { error: String(error) });
|
package/src/usage/kimi.ts
CHANGED
|
@@ -234,7 +234,10 @@ export const kimiUsageProvider: UsageProvider = {
|
|
|
234
234
|
}
|
|
235
235
|
try {
|
|
236
236
|
ctx.logger?.debug("Kimi usage token expired, refreshing", { provider: params.provider });
|
|
237
|
-
const refreshed = await refreshKimiToken(
|
|
237
|
+
const refreshed = await refreshKimiToken(
|
|
238
|
+
credential.refreshToken,
|
|
239
|
+
params.signal ?? new AbortController().signal,
|
|
240
|
+
);
|
|
238
241
|
accessToken = refreshed.access;
|
|
239
242
|
} catch (error) {
|
|
240
243
|
ctx.logger?.warn("Kimi usage token refresh failed", { provider: params.provider, error: String(error) });
|
|
@@ -101,22 +101,61 @@ function tokenCredentials(payload: AnthropicTokenPayload, previousRefresh?: stri
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
function sanitizeOAuthErrorCode(value: unknown): string | undefined {
|
|
105
|
+
if (typeof value !== "string") return undefined;
|
|
106
|
+
const bounded = value.trim().slice(0, 64);
|
|
107
|
+
return /^[a-z][a-z0-9_.-]*$/i.test(bounded) ? bounded : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sanitizeOAuthErrorDescription(value: unknown): string | undefined {
|
|
111
|
+
if (typeof value !== "string") return undefined;
|
|
112
|
+
const bounded = value
|
|
113
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
114
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
115
|
+
.replace(/\b[A-Za-z0-9_-]{24,}\b/g, "[redacted]")
|
|
116
|
+
.trim()
|
|
117
|
+
.slice(0, 256);
|
|
118
|
+
return bounded || undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function formatAnthropicTokenError(response: Response): Promise<string> {
|
|
122
|
+
let payload: unknown;
|
|
123
|
+
try {
|
|
124
|
+
payload = await response.json();
|
|
125
|
+
} catch {
|
|
126
|
+
payload = undefined;
|
|
127
|
+
}
|
|
128
|
+
const record = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : undefined;
|
|
129
|
+
const code = sanitizeOAuthErrorCode((record as { error?: unknown } | undefined)?.error);
|
|
130
|
+
const description = sanitizeOAuthErrorDescription(
|
|
131
|
+
(record as { error_description?: unknown } | undefined)?.error_description,
|
|
132
|
+
);
|
|
133
|
+
const details = [code && `error=${code}`, description && `description=${description}`].filter(Boolean).join("; ");
|
|
134
|
+
return `Anthropic token exchange failed (HTTP ${response.status}${details ? `; ${details}` : ""})`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function requestToken(
|
|
138
|
+
body: Record<string, string>,
|
|
139
|
+
previousRefresh?: string,
|
|
140
|
+
signal?: AbortSignal,
|
|
141
|
+
): Promise<OAuthCredentials> {
|
|
105
142
|
let response: Response;
|
|
143
|
+
const timeoutSignal = AbortSignal.timeout(TOKEN_TIMEOUT_MS);
|
|
106
144
|
try {
|
|
107
145
|
response = await fetch(TOKEN_URL, {
|
|
108
146
|
method: "POST",
|
|
109
147
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
110
148
|
body: JSON.stringify(body),
|
|
111
|
-
signal: AbortSignal.
|
|
149
|
+
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
|
112
150
|
});
|
|
113
|
-
} catch
|
|
114
|
-
if (
|
|
151
|
+
} catch {
|
|
152
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Anthropic token exchange aborted");
|
|
153
|
+
if (timeoutSignal.aborted) {
|
|
115
154
|
throw new Error("Anthropic token exchange timed out after 30 seconds");
|
|
116
155
|
}
|
|
117
156
|
throw new Error("Anthropic token exchange failed");
|
|
118
157
|
}
|
|
119
|
-
if (!response.ok) throw new Error(
|
|
158
|
+
if (!response.ok) throw new Error(await formatAnthropicTokenError(response));
|
|
120
159
|
let payload: AnthropicTokenPayload;
|
|
121
160
|
try {
|
|
122
161
|
payload = (await response.json()) as AnthropicTokenPayload;
|
|
@@ -247,7 +286,7 @@ export async function loginAnthropic(
|
|
|
247
286
|
return new AnthropicOAuthFlow(ctrl, options).login();
|
|
248
287
|
}
|
|
249
288
|
|
|
250
|
-
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
289
|
+
export async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
|
|
251
290
|
return requestToken(
|
|
252
291
|
{
|
|
253
292
|
grant_type: "refresh_token",
|
|
@@ -255,5 +294,6 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
|
|
|
255
294
|
refresh_token: refreshToken,
|
|
256
295
|
},
|
|
257
296
|
refreshToken,
|
|
297
|
+
signal,
|
|
258
298
|
);
|
|
259
299
|
}
|
|
@@ -95,7 +95,7 @@ export async function loginCursor(
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials> {
|
|
98
|
+
export async function refreshCursorToken(apiKeyOrRefreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
|
|
99
99
|
const response = await fetch(CURSOR_REFRESH_URL, {
|
|
100
100
|
method: "POST",
|
|
101
101
|
headers: {
|
|
@@ -103,6 +103,7 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
|
|
|
103
103
|
"Content-Type": "application/json",
|
|
104
104
|
},
|
|
105
105
|
body: "{}",
|
|
106
|
+
signal,
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
if (!response.ok) {
|
|
@@ -242,7 +242,11 @@ const FAR_FUTURE_MS = Date.now() + 10 * 365.25 * 24 * 60 * 60 * 1000;
|
|
|
242
242
|
* Refresh GitHub Copilot token.
|
|
243
243
|
* With the opencode OAuth flow, the GitHub token is used directly — no JWT exchange needed.
|
|
244
244
|
*/
|
|
245
|
-
export function refreshGitHubCopilotToken(
|
|
245
|
+
export function refreshGitHubCopilotToken(
|
|
246
|
+
refreshToken: string,
|
|
247
|
+
enterpriseDomain: string | undefined,
|
|
248
|
+
_signal: AbortSignal,
|
|
249
|
+
): OAuthCredentials {
|
|
246
250
|
return {
|
|
247
251
|
refresh: refreshToken,
|
|
248
252
|
access: refreshToken,
|
|
@@ -96,7 +96,10 @@ export async function loginGitLabDuo(callbacks: OAuthLoginCallbacks): Promise<OA
|
|
|
96
96
|
return flow.login();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
export async function refreshGitLabDuoToken(
|
|
99
|
+
export async function refreshGitLabDuoToken(
|
|
100
|
+
credentials: OAuthCredentials,
|
|
101
|
+
signal: AbortSignal,
|
|
102
|
+
): Promise<OAuthCredentials> {
|
|
100
103
|
const response = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
|
|
101
104
|
method: "POST",
|
|
102
105
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -105,6 +108,7 @@ export async function refreshGitLabDuoToken(credentials: OAuthCredentials): Prom
|
|
|
105
108
|
grant_type: "refresh_token",
|
|
106
109
|
refresh_token: credentials.refresh,
|
|
107
110
|
}).toString(),
|
|
111
|
+
signal,
|
|
108
112
|
});
|
|
109
113
|
|
|
110
114
|
if (!response.ok) {
|
|
@@ -550,6 +550,7 @@ export async function loginVertexWithAntigravityOAuth(ctrl: OAuthController): Pr
|
|
|
550
550
|
|
|
551
551
|
export async function refreshVertexWithAntigravityOAuth(
|
|
552
552
|
refreshToken: string,
|
|
553
|
+
signal: AbortSignal,
|
|
553
554
|
fetchImpl: typeof fetch = fetch,
|
|
554
555
|
): Promise<OAuthCredentials> {
|
|
555
556
|
const { clientId, clientSecret } = resolveVertexOAuthClient();
|
|
@@ -562,6 +563,7 @@ export async function refreshVertexWithAntigravityOAuth(
|
|
|
562
563
|
refresh_token: refreshToken,
|
|
563
564
|
grant_type: "refresh_token",
|
|
564
565
|
}),
|
|
566
|
+
signal,
|
|
565
567
|
});
|
|
566
568
|
if (!response.ok) throw new Error(`Vertex token refresh failed: ${await response.text()}`);
|
|
567
569
|
const data = (await response.json()) as {
|
|
@@ -579,7 +581,11 @@ export async function refreshVertexWithAntigravityOAuth(
|
|
|
579
581
|
/**
|
|
580
582
|
* Refresh Antigravity token
|
|
581
583
|
*/
|
|
582
|
-
export async function refreshAntigravityToken(
|
|
584
|
+
export async function refreshAntigravityToken(
|
|
585
|
+
refreshToken: string,
|
|
586
|
+
projectId: string,
|
|
587
|
+
signal: AbortSignal,
|
|
588
|
+
): Promise<OAuthCredentials> {
|
|
583
589
|
const response = await fetch(TOKEN_URL, {
|
|
584
590
|
method: "POST",
|
|
585
591
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -589,6 +595,7 @@ export async function refreshAntigravityToken(refreshToken: string, projectId: s
|
|
|
589
595
|
refresh_token: refreshToken,
|
|
590
596
|
grant_type: "refresh_token",
|
|
591
597
|
}),
|
|
598
|
+
signal,
|
|
592
599
|
});
|
|
593
600
|
|
|
594
601
|
if (!response.ok) {
|
|
@@ -302,7 +302,11 @@ export async function loginGeminiCli(ctrl: OAuthController): Promise<OAuthCreden
|
|
|
302
302
|
/**
|
|
303
303
|
* Refresh Google Cloud Code Assist token
|
|
304
304
|
*/
|
|
305
|
-
export async function refreshGoogleCloudToken(
|
|
305
|
+
export async function refreshGoogleCloudToken(
|
|
306
|
+
refreshToken: string,
|
|
307
|
+
projectId: string,
|
|
308
|
+
signal: AbortSignal,
|
|
309
|
+
): Promise<OAuthCredentials> {
|
|
306
310
|
const response = await fetch(TOKEN_URL, {
|
|
307
311
|
method: "POST",
|
|
308
312
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -312,6 +316,7 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s
|
|
|
312
316
|
refresh_token: refreshToken,
|
|
313
317
|
grant_type: "refresh_token",
|
|
314
318
|
}),
|
|
319
|
+
signal,
|
|
315
320
|
});
|
|
316
321
|
|
|
317
322
|
if (!response.ok) {
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -375,6 +375,7 @@ export function unregisterOAuthProviders(sourceId: string): void {
|
|
|
375
375
|
export async function refreshOAuthToken(
|
|
376
376
|
provider: OAuthProvider,
|
|
377
377
|
credentials: OAuthCredentials,
|
|
378
|
+
signal: AbortSignal,
|
|
378
379
|
): Promise<OAuthCredentials> {
|
|
379
380
|
if (!credentials) {
|
|
380
381
|
throw new Error(`No OAuth credentials found for ${provider}`);
|
|
@@ -383,43 +384,43 @@ export async function refreshOAuthToken(
|
|
|
383
384
|
let newCredentials: OAuthCredentials;
|
|
384
385
|
switch (provider) {
|
|
385
386
|
case "anthropic":
|
|
386
|
-
newCredentials = await refreshAnthropicToken(credentials.refresh);
|
|
387
|
+
newCredentials = await refreshAnthropicToken(credentials.refresh, signal);
|
|
387
388
|
break;
|
|
388
389
|
case "github-copilot":
|
|
389
|
-
newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
|
|
390
|
+
newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl, signal);
|
|
390
391
|
break;
|
|
391
392
|
case "google-gemini-cli":
|
|
392
393
|
if (!credentials.projectId) {
|
|
393
394
|
throw new Error("Google Cloud credentials missing projectId");
|
|
394
395
|
}
|
|
395
|
-
newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId);
|
|
396
|
+
newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId, signal);
|
|
396
397
|
break;
|
|
397
398
|
case "google-vertex":
|
|
398
|
-
newCredentials = await refreshVertexWithAntigravityOAuth(credentials.refresh);
|
|
399
|
+
newCredentials = await refreshVertexWithAntigravityOAuth(credentials.refresh, signal);
|
|
399
400
|
break;
|
|
400
401
|
case "google-antigravity":
|
|
401
402
|
if (!credentials.projectId) {
|
|
402
403
|
throw new Error("Antigravity credentials missing projectId");
|
|
403
404
|
}
|
|
404
|
-
newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
|
|
405
|
+
newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId, signal);
|
|
405
406
|
break;
|
|
406
407
|
case "openai-codex":
|
|
407
|
-
newCredentials = await refreshOpenAICodexToken(credentials.refresh);
|
|
408
|
+
newCredentials = await refreshOpenAICodexToken(credentials.refresh, signal);
|
|
408
409
|
break;
|
|
409
410
|
case "openai":
|
|
410
411
|
newCredentials = credentials;
|
|
411
412
|
break;
|
|
412
413
|
case "kimi-code":
|
|
413
|
-
newCredentials = await refreshKimiToken(credentials.refresh);
|
|
414
|
+
newCredentials = await refreshKimiToken(credentials.refresh, signal);
|
|
414
415
|
break;
|
|
415
416
|
case "kilo":
|
|
416
417
|
newCredentials = credentials;
|
|
417
418
|
break;
|
|
418
419
|
case "gitlab-duo":
|
|
419
|
-
newCredentials = await refreshGitLabDuoToken(credentials);
|
|
420
|
+
newCredentials = await refreshGitLabDuoToken(credentials, signal);
|
|
420
421
|
break;
|
|
421
422
|
case "cursor":
|
|
422
|
-
newCredentials = await refreshCursorToken(credentials.refresh);
|
|
423
|
+
newCredentials = await refreshCursorToken(credentials.refresh, signal);
|
|
423
424
|
break;
|
|
424
425
|
case "perplexity":
|
|
425
426
|
case "huggingface":
|
|
@@ -474,12 +475,14 @@ function getPerplexityJwtExpiryMs(token: string): number | undefined {
|
|
|
474
475
|
*
|
|
475
476
|
* For providers that need credential metadata at request time, returns JSON-encoded credentials
|
|
476
477
|
* plus refresh/expiry metadata for proactive refresh support.
|
|
478
|
+
* @param options - Set `refreshExpired` false when a durable caller already coordinated refresh.
|
|
477
479
|
* @returns API key string, or null if no credentials
|
|
478
480
|
* @throws Error if refresh fails
|
|
479
481
|
*/
|
|
480
482
|
export async function getOAuthApiKey(
|
|
481
483
|
provider: OAuthProvider,
|
|
482
484
|
credentials: Record<string, OAuthCredentials>,
|
|
485
|
+
options: { refreshExpired?: boolean; signal?: AbortSignal } = {},
|
|
483
486
|
): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> {
|
|
484
487
|
let creds = credentials[provider];
|
|
485
488
|
if (!creds) {
|
|
@@ -496,9 +499,9 @@ export async function getOAuthApiKey(
|
|
|
496
499
|
}
|
|
497
500
|
}
|
|
498
501
|
// Refresh if expired
|
|
499
|
-
if (Date.now() >= creds.expires) {
|
|
502
|
+
if (Date.now() >= creds.expires && options.refreshExpired !== false) {
|
|
500
503
|
try {
|
|
501
|
-
creds = await refreshOAuthToken(provider, creds);
|
|
504
|
+
creds = await refreshOAuthToken(provider, creds, options.signal ?? AbortSignal.timeout(10_000));
|
|
502
505
|
} catch (refreshError) {
|
|
503
506
|
if (provider === "perplexity") {
|
|
504
507
|
const jwtExpiry = getPerplexityJwtExpiryMs(creds.access);
|
package/src/utils/oauth/kimi.ts
CHANGED
|
@@ -226,7 +226,7 @@ export async function loginKimi(options: OAuthController): Promise<OAuthCredenti
|
|
|
226
226
|
/**
|
|
227
227
|
* Refresh Kimi OAuth token.
|
|
228
228
|
*/
|
|
229
|
-
export async function refreshKimiToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
229
|
+
export async function refreshKimiToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
|
|
230
230
|
const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
|
|
231
231
|
method: "POST",
|
|
232
232
|
headers: {
|
|
@@ -238,6 +238,7 @@ export async function refreshKimiToken(refreshToken: string): Promise<OAuthCrede
|
|
|
238
238
|
refresh_token: refreshToken,
|
|
239
239
|
client_id: CLIENT_ID,
|
|
240
240
|
}),
|
|
241
|
+
signal,
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
if (!response.ok) {
|
|
@@ -413,7 +413,7 @@ export async function loginOpenAICodexDevice(
|
|
|
413
413
|
throw new Error("Device authorization expired after 15 minutes");
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
416
|
+
export async function refreshOpenAICodexToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
|
|
417
417
|
const response = await fetch(TOKEN_URL, {
|
|
418
418
|
method: "POST",
|
|
419
419
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -422,7 +422,7 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
|
|
|
422
422
|
refresh_token: refreshToken,
|
|
423
423
|
client_id: CLIENT_ID,
|
|
424
424
|
}),
|
|
425
|
-
signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
|
|
425
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS)]),
|
|
426
426
|
});
|
|
427
427
|
|
|
428
428
|
if (!response.ok) {
|
package/src/utils/oauth/types.ts
CHANGED
|
@@ -114,6 +114,6 @@ export interface OAuthProviderInterface {
|
|
|
114
114
|
readonly name: string;
|
|
115
115
|
readonly sourceId?: string;
|
|
116
116
|
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
|
|
117
|
-
refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
|
|
117
|
+
refreshToken?(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>;
|
|
118
118
|
getApiKey?(credentials: OAuthCredentials): string;
|
|
119
119
|
}
|