opencode-claude-auth 2.1.3 → 2.1.5
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/README.md +7 -2
- package/dist/credentials.d.ts +35 -7
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +280 -54
- package/dist/credentials.js.map +1 -1
- package/dist/http.d.ts +3 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +74 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +184 -69
- package/dist/index.js.map +1 -1
- package/dist/keychain.d.ts +20 -1
- package/dist/keychain.d.ts.map +1 -1
- package/dist/keychain.js +66 -1
- package/dist/keychain.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -176,10 +176,15 @@ export ANTHROPIC_CLI_VERSION=2.2.0
|
|
|
176
176
|
- Sets required API headers (beta flags, billing, user-agent) with model-aware selection
|
|
177
177
|
- On macOS, enumerates all `Claude Code-credentials*` Keychain entries and labels them by subscription tier
|
|
178
178
|
- Provides an account switcher via `opencode auth login` when multiple accounts are found; persists selection to `~/.local/share/opencode/claude-account-source.txt`
|
|
179
|
-
- Syncs credentials to `auth.json` on startup and every 5 minutes as a fallback
|
|
179
|
+
- Syncs credentials to `auth.json` on startup and every 5 minutes as a fallback; that same tick proactively refreshes once the token is within an hour of expiry
|
|
180
180
|
- On Windows, writes to both `%USERPROFILE%\.local\share\opencode\auth.json` and `%LOCALAPPDATA%\opencode\auth.json`
|
|
181
|
+
- Re-reads the credential source on every cache miss, so an account rotated by something other than this plugin — the `claude` CLI in another terminal, a second OpenCode instance, or a switcher like [claude-swap](https://github.com/realiti4/claude-swap) — gets picked up mid-session without a restart. Bounded by the same 30s cache, so it adds at most about two source reads a minute under load. A stored token is adopted whenever it is usable, and when it isn't only if the one already held is also unusable — otherwise a failed write-back would resurrect the pre-refresh token it left behind
|
|
182
|
+
- Guards credential write-back with the access token the refresh started from, so a switch landing mid-refresh can't write one account's rotated tokens into another account's slot
|
|
181
183
|
- Retries API requests on 429 (rate limit) and 529 (overloaded) with exponential backoff, respecting `retry-after` headers
|
|
182
|
-
-
|
|
184
|
+
- On a 429 that outlives those backoff retries, re-reads the source once and retries only if the access token changed, so a rate limit another process has already resolved by switching accounts isn't surfaced. A changed token isn't proof of a switch — a routine refresh of the same account changes it too — so this costs at most one extra request
|
|
185
|
+
- On a 401, recovers in place rather than surfacing it: adopts an externally rotated token if the source now holds one, otherwise forces an OAuth refresh, then retries the request. Bounded at two attempts, so a rejected token costs at most three API calls. A 401 that survives recovery is returned unmodified, without SSE stream transformation, since it carries an error body rather than a stream
|
|
186
|
+
- Refreshes directly via `POST https://claude.ai/v1/oauth/token` using the runtime's own `fetch` (no LLM tokens consumed, no subprocess). Requests are triggered within 60 seconds of expiry on the API request path and within an hour on the background tick; concurrent refreshes of one account share a single request, since each rotation invalidates the previous refresh token
|
|
187
|
+
- Falls back to the `claude` CLI only within the 60-second window, the point at which Claude Code will actually rotate the token — running it earlier costs a real API request and returns the same token. New tokens are written back to Keychain (macOS) or credentials file (Linux/Windows) to keep stored credentials in sync with rotated refresh tokens
|
|
183
188
|
- If credentials aren't OAuth-based, the auth loader returns `{}` and falls through to API key auth
|
|
184
189
|
- If credentials are unavailable or unreadable, the plugin disables itself and OpenCode continues without Claude auth
|
|
185
190
|
|
package/dist/credentials.d.ts
CHANGED
|
@@ -4,20 +4,48 @@ export type { ClaudeCredentials } from "./keychain.ts";
|
|
|
4
4
|
export declare function initAccounts(accounts: ClaudeAccount[]): void;
|
|
5
5
|
export declare function setActiveAccountSource(source: string): void;
|
|
6
6
|
export declare function refreshAccountsList(): ClaudeAccount[];
|
|
7
|
+
export declare function getActiveAccount(): ClaudeAccount | null;
|
|
7
8
|
export declare function loadPersistedAccountSource(): string | null;
|
|
8
9
|
export declare function saveAccountSource(source: string): void;
|
|
9
10
|
export declare function syncAuthJson(creds: ClaudeCredentials): void;
|
|
10
11
|
export declare const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token";
|
|
11
12
|
export declare const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
12
13
|
export declare function parseOAuthResponse(raw: string, currentRefreshToken: string, now?: number): ClaudeCredentials | null;
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Exchanges a refresh token for fresh credentials using the runtime's own
|
|
16
|
+
* fetch.
|
|
17
|
+
*
|
|
18
|
+
* This previously ran the request inside a child process spawned as
|
|
19
|
+
* `process.execPath -e <script>`. That assumed process.execPath is a
|
|
20
|
+
* JavaScript runtime, which does not hold inside OpenCode: the plugin runs
|
|
21
|
+
* in a compiled single-file executable, so process.execPath is the OpenCode
|
|
22
|
+
* binary itself and `-e` is not a script to evaluate. Every refresh exited
|
|
23
|
+
* non-zero with empty stdout and silently fell through to the claude CLI.
|
|
24
|
+
* Node 18+ and Bun both expose a global fetch, so no subprocess is needed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function refreshViaOAuth(refreshToken: string, timeoutMs?: number): Promise<ClaudeCredentials | null>;
|
|
27
|
+
/**
|
|
28
|
+
* Refreshes the given (or active) account's credentials if they are within
|
|
29
|
+
* `thresholdMs` of expiry. Defaults to 60s, matching the reactive
|
|
30
|
+
* per-request refresh path. Callers that want a proactive refresh further
|
|
31
|
+
* ahead of expiry (e.g. a background timer) should pass a larger threshold —
|
|
32
|
+
* the account resolution (via getActiveAccount()) stays correct regardless
|
|
33
|
+
* of threshold, so this always operates on the currently active account
|
|
34
|
+
* unless one is explicitly passed in.
|
|
35
|
+
*/
|
|
36
|
+
export declare function refreshIfNeeded(account?: ClaudeAccount, thresholdMs?: number): Promise<ClaudeCredentials | null>;
|
|
15
37
|
export declare function getCredentialsForSync(): ClaudeCredentials | null;
|
|
16
38
|
/**
|
|
17
39
|
* Re-read only the active account's credentials from its source (single
|
|
18
|
-
* keychain service read or credentials file) and update them in place
|
|
19
|
-
*
|
|
20
|
-
*
|
|
40
|
+
* keychain service read or credentials file) and update them in place,
|
|
41
|
+
* so an externally refreshed token is picked up without a full
|
|
42
|
+
* multi-account keychain rescan.
|
|
43
|
+
*
|
|
44
|
+
* Currently has no call sites: the 401 path uses
|
|
45
|
+
* reloadCredentialsFromSource, which additionally validates the result
|
|
46
|
+
* and refreshes the cache. Wiring this up or deleting it is tracked as a
|
|
47
|
+
* follow-up; until then it must stay consistent with the read paths that
|
|
48
|
+
* are live, hence the configDir below.
|
|
21
49
|
*/
|
|
22
50
|
export declare function reloadActiveAccount(): void;
|
|
23
51
|
/**
|
|
@@ -27,7 +55,7 @@ export declare function reloadActiveAccount(): void;
|
|
|
27
55
|
* On success the account, its source, and the cache are all updated.
|
|
28
56
|
* The refresh function is injectable for tests.
|
|
29
57
|
*/
|
|
30
|
-
export declare function forceRefreshActiveAccount(refresh?: (refreshToken: string) => ClaudeCredentials | null): ClaudeCredentials | null
|
|
58
|
+
export declare function forceRefreshActiveAccount(refresh?: (refreshToken: string) => Promise<ClaudeCredentials | null>): Promise<ClaudeCredentials | null>;
|
|
31
59
|
/**
|
|
32
60
|
* Drop the active account's cached credentials so the next
|
|
33
61
|
* getCachedCredentials() call re-reads from the source, bypassing the
|
|
@@ -35,6 +63,6 @@ export declare function forceRefreshActiveAccount(refresh?: (refreshToken: strin
|
|
|
35
63
|
* valid locally.
|
|
36
64
|
*/
|
|
37
65
|
export declare function invalidateCredentialCache(): void;
|
|
38
|
-
export declare function getCachedCredentials(): ClaudeCredentials | null
|
|
66
|
+
export declare function getCachedCredentials(): Promise<ClaudeCredentials | null>;
|
|
39
67
|
export declare function reloadCredentialsFromSource(): ClaudeCredentials | null;
|
|
40
68
|
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAUA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAUA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAKtB,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAClD,YAAY,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAqBtD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,IAAI,CAE5D;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAQ3D;AAED,wBAAgB,mBAAmB,IAAI,aAAa,EAAE,CAUrD;AAED,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,IAAI,CAOvD;AAYD,wBAAgB,0BAA0B,IAAI,MAAM,GAAG,IAAI,CAU1D;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAStD;AA4CD,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAc3D;AAED,eAAO,MAAM,eAAe,qCAAqC,CAAA;AACjE,eAAO,MAAM,eAAe,yCAAyC,CAAA;AAErE,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,mBAAmB,EAAE,MAAM,EAC3B,GAAG,GAAE,MAAmB,GACvB,iBAAiB,GAAG,IAAI,CAoB1B;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,YAAY,EAAE,MAAM,EACpB,SAAS,SAAmB,GAC3B,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAmDnC;AA0CD;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,OAAO,CAAC,EAAE,aAAa,EACvB,WAAW,SAAS,GACnB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CA2EnC;AA6OD,wBAAgB,qBAAqB,IAAI,iBAAiB,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAY1C;AAED;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,GAAE,CACP,YAAY,EAAE,MAAM,KACjB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAmB,GACvD,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAyCnC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAMhD;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAgC9E;AAED,wBAAgB,2BAA2B,IAAI,iBAAiB,GAAG,IAAI,CAmDtE"}
|
package/dist/credentials.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { PRIMARY_SERVICE, readAllClaudeAccounts, refreshAccount, writeBackCredentials, } from "./keychain.js";
|
|
6
6
|
import { resetExcludedBetas } from "./betas.js";
|
|
7
|
+
import { fetchWithRetry } from "./http.js";
|
|
7
8
|
import { log } from "./logger.js";
|
|
8
9
|
const CREDENTIAL_CACHE_TTL_MS = 30_000;
|
|
10
|
+
// Only inside this window will the claude CLI actually rotate a token, so
|
|
11
|
+
// it is also the only window where spawning it is worth a real API request.
|
|
12
|
+
const CLI_FALLBACK_THRESHOLD_MS = 60_000;
|
|
9
13
|
const accountCacheMap = new Map();
|
|
14
|
+
const inFlightRefreshes = new Map();
|
|
15
|
+
// Accounts currently running on credentials borrowed from another account.
|
|
16
|
+
// Those tokens belong to the lender: they must never be used as this
|
|
17
|
+
// account's refresh source, and never written back to its store.
|
|
18
|
+
const borrowedCredentialAccounts = new WeakSet();
|
|
10
19
|
let activeAccountSource = null;
|
|
11
20
|
let allAccounts = [];
|
|
12
21
|
export function initAccounts(accounts) {
|
|
@@ -32,7 +41,7 @@ export function refreshAccountsList() {
|
|
|
32
41
|
allAccounts = fresh;
|
|
33
42
|
return allAccounts;
|
|
34
43
|
}
|
|
35
|
-
function getActiveAccount() {
|
|
44
|
+
export function getActiveAccount() {
|
|
36
45
|
if (allAccounts.length === 0)
|
|
37
46
|
return null;
|
|
38
47
|
if (activeAccountSource) {
|
|
@@ -143,36 +152,47 @@ export function parseOAuthResponse(raw, currentRefreshToken, now = Date.now()) {
|
|
|
143
152
|
expiresAt: Math.trunc(now + (data.expires_in ?? 36_000) * 1000),
|
|
144
153
|
};
|
|
145
154
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.catch(e => { process.stdout.write(JSON.stringify({ error: String(e) })); process.exit(1); });
|
|
155
|
+
const OAUTH_TIMEOUT_MS = 15_000;
|
|
156
|
+
/**
|
|
157
|
+
* Exchanges a refresh token for fresh credentials using the runtime's own
|
|
158
|
+
* fetch.
|
|
159
|
+
*
|
|
160
|
+
* This previously ran the request inside a child process spawned as
|
|
161
|
+
* `process.execPath -e <script>`. That assumed process.execPath is a
|
|
162
|
+
* JavaScript runtime, which does not hold inside OpenCode: the plugin runs
|
|
163
|
+
* in a compiled single-file executable, so process.execPath is the OpenCode
|
|
164
|
+
* binary itself and `-e` is not a script to evaluate. Every refresh exited
|
|
165
|
+
* non-zero with empty stdout and silently fell through to the claude CLI.
|
|
166
|
+
* Node 18+ and Bun both expose a global fetch, so no subprocess is needed.
|
|
167
|
+
*/
|
|
168
|
+
export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
|
|
169
|
+
const body = new URLSearchParams({
|
|
170
|
+
grant_type: "refresh_token",
|
|
171
|
+
client_id: OAUTH_CLIENT_ID,
|
|
172
|
+
refresh_token: refreshToken,
|
|
165
173
|
});
|
|
166
|
-
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
167
176
|
try {
|
|
168
177
|
log("refresh_started", { source: "oauth" });
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
// The token endpoint rate-limits valid refresh requests, and several
|
|
179
|
+
// OpenCode instances refreshing near expiry cluster their calls, so a
|
|
180
|
+
// 429 here is transient rather than terminal. The shared helper caps
|
|
181
|
+
// its own backoff, and the abort signal bounds the whole sequence.
|
|
182
|
+
const response = await fetchWithRetry(OAUTH_TOKEN_URL, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
185
|
+
body: body.toString(),
|
|
186
|
+
signal: controller.signal,
|
|
174
187
|
});
|
|
175
|
-
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
log("refresh_failed", {
|
|
190
|
+
source: "oauth",
|
|
191
|
+
error: `HTTP ${response.status}`,
|
|
192
|
+
});
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const creds = parseOAuthResponse(await response.text(), refreshToken);
|
|
176
196
|
if (!creds) {
|
|
177
197
|
log("refresh_failed", {
|
|
178
198
|
source: "oauth",
|
|
@@ -190,6 +210,9 @@ export function refreshViaOAuth(refreshToken) {
|
|
|
190
210
|
});
|
|
191
211
|
return null;
|
|
192
212
|
}
|
|
213
|
+
finally {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
}
|
|
193
216
|
}
|
|
194
217
|
function refreshViaCli(configDir, requireConfigDir = false) {
|
|
195
218
|
if (requireConfigDir && !configDir) {
|
|
@@ -229,36 +252,155 @@ function refreshViaCli(configDir, requireConfigDir = false) {
|
|
|
229
252
|
log("refresh_cli_exhausted", { source: "cli", configDir });
|
|
230
253
|
return false;
|
|
231
254
|
}
|
|
232
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Refreshes the given (or active) account's credentials if they are within
|
|
257
|
+
* `thresholdMs` of expiry. Defaults to 60s, matching the reactive
|
|
258
|
+
* per-request refresh path. Callers that want a proactive refresh further
|
|
259
|
+
* ahead of expiry (e.g. a background timer) should pass a larger threshold —
|
|
260
|
+
* the account resolution (via getActiveAccount()) stays correct regardless
|
|
261
|
+
* of threshold, so this always operates on the currently active account
|
|
262
|
+
* unless one is explicitly passed in.
|
|
263
|
+
*/
|
|
264
|
+
export async function refreshIfNeeded(account, thresholdMs = 60_000) {
|
|
233
265
|
const target = account ?? getActiveAccount();
|
|
234
266
|
if (!target)
|
|
235
267
|
return null;
|
|
236
|
-
// Pick up
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
268
|
+
// Pick up credentials replaced externally — cswap switching accounts, the
|
|
269
|
+
// claude CLI in another terminal, or a second OpenCode instance. This was
|
|
270
|
+
// once limited to file sources, on the false assumption that a keychain
|
|
271
|
+
// entry is only ever mutated by our own writeBackCredentials. Bounded by
|
|
272
|
+
// getCachedCredentials's 30s TTL, so it fires at most ~2x/min under load.
|
|
273
|
+
//
|
|
274
|
+
// A keychain read shells out to `security`, which throws when the keychain
|
|
275
|
+
// is locked, access is denied, or the call times out. Degrade to the
|
|
276
|
+
// in-memory credentials rather than take down the request path.
|
|
277
|
+
//
|
|
278
|
+
// Adopt a usable stored blob always; an unusable one only when what we
|
|
279
|
+
// already hold is unusable too. Do not simplify this to an unconditional
|
|
280
|
+
// adopt: performRefresh ignores writeBackCredentials's return value, and
|
|
281
|
+
// that write can fail while the read before it succeeded (malformed blob,
|
|
282
|
+
// or an ACL allowing read but not add-generic-password), leaving memory
|
|
283
|
+
// freshly refreshed and the store holding the orphaned pre-refresh blob.
|
|
284
|
+
// On the reactive path that blob has under 60s left — that window is the
|
|
285
|
+
// only reason we refreshed — so adopting it re-enters performRefresh with
|
|
286
|
+
// a refresh token our own refresh just rotated dead: OAuth fails and we
|
|
287
|
+
// fall through to two 60s claude spawns, on every cache miss, forever.
|
|
288
|
+
//
|
|
289
|
+
// Two accepted residuals. An external switch installing an already-expired
|
|
290
|
+
// token while ours is usable is ignored until ours expires; cswap freshens
|
|
291
|
+
// a target before activating it, so that is rare. And the proactive timer
|
|
292
|
+
// refreshes an hour ahead (index.ts), where a failed write-back orphans a
|
|
293
|
+
// blob that is still usable — so it IS adopted, costing wasted background
|
|
294
|
+
// refreshes rather than failed requests until it drops under 60s and the
|
|
295
|
+
// CLI fallback recovers. No guard here closes that one: the re-read cannot
|
|
296
|
+
// tell "stale because our write failed" from "changed because cswap
|
|
297
|
+
// switched", as both present as store-disagrees-with-memory-and-usable.
|
|
298
|
+
// Only the return value performRefresh discards carries the distinction.
|
|
299
|
+
try {
|
|
300
|
+
const stored = refreshAccount(target.source, target.configDir);
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
if (stored &&
|
|
303
|
+
(stored.expiresAt > now + 60_000 ||
|
|
304
|
+
target.credentials.expiresAt <= now + 60_000)) {
|
|
305
|
+
target.credentials = stored;
|
|
306
|
+
// Read from this account's own source, so what it returned is this
|
|
307
|
+
// account's own credentials — it is no longer running on a lender's.
|
|
308
|
+
borrowedCredentialAccounts.delete(target);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
log("source_reread_failed", {
|
|
313
|
+
source: target.source,
|
|
314
|
+
error: err instanceof Error ? err.message : String(err),
|
|
315
|
+
});
|
|
245
316
|
}
|
|
246
317
|
const creds = target.credentials;
|
|
247
|
-
if (creds.expiresAt > Date.now() +
|
|
318
|
+
if (creds.expiresAt > Date.now() + thresholdMs)
|
|
248
319
|
return creds;
|
|
320
|
+
// The proactive sync timer calls this directly while the request path
|
|
321
|
+
// arrives via getCachedCredentials(). A rotation invalidates the refresh
|
|
322
|
+
// token it was issued against, so two concurrent refreshes would leave
|
|
323
|
+
// one caller holding an already-dead token. Share one attempt instead.
|
|
324
|
+
const inFlight = inFlightRefreshes.get(target.source);
|
|
325
|
+
if (inFlight) {
|
|
326
|
+
log("refresh_joined", { source: target.source });
|
|
327
|
+
return inFlight;
|
|
328
|
+
}
|
|
329
|
+
const pending = performRefresh(target, creds);
|
|
330
|
+
inFlightRefreshes.set(target.source, pending);
|
|
331
|
+
try {
|
|
332
|
+
return await pending;
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
inFlightRefreshes.delete(target.source);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function performRefresh(target, creds) {
|
|
339
|
+
if (borrowedCredentialAccounts.has(target)) {
|
|
340
|
+
return refreshBorrowedAccount(target);
|
|
341
|
+
}
|
|
249
342
|
log("refresh_needed", {
|
|
250
343
|
source: target.source,
|
|
251
344
|
expiresAt: creds.expiresAt,
|
|
252
345
|
expiresIn: creds.expiresAt - Date.now(),
|
|
253
346
|
});
|
|
254
347
|
if (creds.refreshToken) {
|
|
255
|
-
const oauthCreds = refreshViaOAuth(creds.refreshToken);
|
|
348
|
+
const oauthCreds = await refreshViaOAuth(creds.refreshToken);
|
|
256
349
|
if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
|
|
257
350
|
target.credentials = oauthCreds;
|
|
258
|
-
writeBackCredentials(target.source, oauthCreds, target.configDir)
|
|
351
|
+
if (!writeBackCredentials(target.source, oauthCreds, target.configDir, creds.accessToken)) {
|
|
352
|
+
// Mirrors force_refresh_writeback_failed on the forced path. The
|
|
353
|
+
// session continues from memory either way, so this stays a log
|
|
354
|
+
// rather than a control-flow change: acting on the two causes
|
|
355
|
+
// (I/O failure vs. CAS mismatch) differs, and the proactive-path
|
|
356
|
+
// consequence — a still-usable orphaned blob being re-adopted by
|
|
357
|
+
// the validated re-read — is tracked as a follow-up.
|
|
358
|
+
log("refresh_writeback_failed", { source: target.source });
|
|
359
|
+
}
|
|
259
360
|
return oauthCreds;
|
|
260
361
|
}
|
|
261
362
|
}
|
|
363
|
+
// The claude CLI only rotates a token that is itself close to expiry, so
|
|
364
|
+
// running it while the current one is still usable spawns a real API
|
|
365
|
+
// request that hands back the same token. Callers using a proactive
|
|
366
|
+
// threshold (the sync timer passes an hour) would otherwise pay for that
|
|
367
|
+
// request on every tick. Keep the fallback scoped to the reactive window
|
|
368
|
+
// and let the caller try again later.
|
|
369
|
+
if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS) {
|
|
370
|
+
log("refresh_cli_skipped", {
|
|
371
|
+
source: target.source,
|
|
372
|
+
reason: "credentials still usable",
|
|
373
|
+
expiresIn: creds.expiresAt - Date.now(),
|
|
374
|
+
});
|
|
375
|
+
return creds;
|
|
376
|
+
}
|
|
377
|
+
// Every OpenCode instance refreshes independently, and a rotation
|
|
378
|
+
// invalidates the refresh token the others are holding. When ours is
|
|
379
|
+
// rejected, the instance that won may already have written usable
|
|
380
|
+
// credentials to the shared store during the OAuth round trip — far
|
|
381
|
+
// cheaper to re-read than to spawn the CLI.
|
|
382
|
+
//
|
|
383
|
+
// The file-source exclusion below is a leftover from when refreshIfNeeded
|
|
384
|
+
// re-read file sources only. That rationale is gone and the exclusion now
|
|
385
|
+
// has none: a sibling process can write a file source mid-round-trip
|
|
386
|
+
// exactly as it can a keychain entry. Left in place only to keep this
|
|
387
|
+
// change off the file path; removing it is tracked as a follow-up.
|
|
388
|
+
if (target.source !== "file") {
|
|
389
|
+
let stored = null;
|
|
390
|
+
try {
|
|
391
|
+
stored = refreshAccount(target.source, target.configDir);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
stored = null;
|
|
395
|
+
}
|
|
396
|
+
if (stored &&
|
|
397
|
+
stored.accessToken !== creds.accessToken &&
|
|
398
|
+
stored.expiresAt > Date.now() + 60_000) {
|
|
399
|
+
target.credentials = stored;
|
|
400
|
+
log("refresh_adopted_external", { source: target.source });
|
|
401
|
+
return stored;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
262
404
|
log("refresh_fallback_cli", { source: target.source });
|
|
263
405
|
const isSuffixedAccount = target.source !== PRIMARY_SERVICE &&
|
|
264
406
|
target.source.startsWith(PRIMARY_SERVICE + "-");
|
|
@@ -267,6 +409,7 @@ export function refreshIfNeeded(account) {
|
|
|
267
409
|
const fallback = tryFallbackAccount(target.source);
|
|
268
410
|
if (fallback) {
|
|
269
411
|
target.credentials = fallback;
|
|
412
|
+
borrowedCredentialAccounts.add(target);
|
|
270
413
|
return fallback;
|
|
271
414
|
}
|
|
272
415
|
log("refresh_exhausted", {
|
|
@@ -295,6 +438,62 @@ export function refreshIfNeeded(account) {
|
|
|
295
438
|
});
|
|
296
439
|
return null;
|
|
297
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* Refresh path for an account running on borrowed credentials. The tokens it
|
|
443
|
+
* currently holds belong to another account, so they cannot be exchanged at
|
|
444
|
+
* the OAuth endpoint on this account's behalf, and the result must never be
|
|
445
|
+
* written to this account's store. Re-read our own source first — the claude
|
|
446
|
+
* CLI or another process may have repaired it — and otherwise borrow again.
|
|
447
|
+
*/
|
|
448
|
+
async function refreshBorrowedAccount(target) {
|
|
449
|
+
log("refresh_borrowed", { source: target.source });
|
|
450
|
+
let own = null;
|
|
451
|
+
try {
|
|
452
|
+
own = refreshAccount(target.source, target.configDir);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
own = null;
|
|
456
|
+
}
|
|
457
|
+
if (own && own.expiresAt > Date.now() + 60_000) {
|
|
458
|
+
borrowedCredentialAccounts.delete(target);
|
|
459
|
+
target.credentials = own;
|
|
460
|
+
log("refresh_borrowed_recovered", { source: target.source, via: "source" });
|
|
461
|
+
return own;
|
|
462
|
+
}
|
|
463
|
+
// A refresh token outlives its access token by weeks, so this account's
|
|
464
|
+
// own stored token is likely still exchangeable even though the access
|
|
465
|
+
// token it came with has expired. This is the only token we may present
|
|
466
|
+
// on its behalf, and the only result we may write to its store.
|
|
467
|
+
if (own?.refreshToken) {
|
|
468
|
+
const oauthCreds = await refreshViaOAuth(own.refreshToken);
|
|
469
|
+
if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
|
|
470
|
+
borrowedCredentialAccounts.delete(target);
|
|
471
|
+
target.credentials = oauthCreds;
|
|
472
|
+
writeBackCredentials(target.source, oauthCreds, target.configDir, own.accessToken);
|
|
473
|
+
log("refresh_borrowed_recovered", { source: target.source, via: "oauth" });
|
|
474
|
+
return oauthCreds;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const again = tryFallbackAccount(target.source);
|
|
478
|
+
if (again) {
|
|
479
|
+
target.credentials = again;
|
|
480
|
+
return again;
|
|
481
|
+
}
|
|
482
|
+
// Recovery failed. The account must not be left holding the lender's
|
|
483
|
+
// tokens, or the next cycle would take the normal path and exchange them.
|
|
484
|
+
// Restore its own credentials if we managed to read them — expired, but
|
|
485
|
+
// ours to refresh — and otherwise keep the guard in place.
|
|
486
|
+
if (own) {
|
|
487
|
+
borrowedCredentialAccounts.delete(target);
|
|
488
|
+
target.credentials = own;
|
|
489
|
+
}
|
|
490
|
+
log("refresh_exhausted", {
|
|
491
|
+
source: target.source,
|
|
492
|
+
hadCredentials: !!own,
|
|
493
|
+
expiresAt: own?.expiresAt,
|
|
494
|
+
});
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
298
497
|
function tryFallbackAccount(excludeSource) {
|
|
299
498
|
const now = Date.now();
|
|
300
499
|
const candidates = allAccounts.filter((a) => a.source !== excludeSource);
|
|
@@ -344,16 +543,22 @@ export function getCredentialsForSync() {
|
|
|
344
543
|
}
|
|
345
544
|
/**
|
|
346
545
|
* Re-read only the active account's credentials from its source (single
|
|
347
|
-
* keychain service read or credentials file) and update them in place
|
|
348
|
-
*
|
|
349
|
-
*
|
|
546
|
+
* keychain service read or credentials file) and update them in place,
|
|
547
|
+
* so an externally refreshed token is picked up without a full
|
|
548
|
+
* multi-account keychain rescan.
|
|
549
|
+
*
|
|
550
|
+
* Currently has no call sites: the 401 path uses
|
|
551
|
+
* reloadCredentialsFromSource, which additionally validates the result
|
|
552
|
+
* and refreshes the cache. Wiring this up or deleting it is tracked as a
|
|
553
|
+
* follow-up; until then it must stay consistent with the read paths that
|
|
554
|
+
* are live, hence the configDir below.
|
|
350
555
|
*/
|
|
351
556
|
export function reloadActiveAccount() {
|
|
352
557
|
const account = getActiveAccount();
|
|
353
558
|
if (!account)
|
|
354
559
|
return;
|
|
355
560
|
try {
|
|
356
|
-
const fresh = refreshAccount(account.source);
|
|
561
|
+
const fresh = refreshAccount(account.source, account.configDir);
|
|
357
562
|
if (fresh)
|
|
358
563
|
account.credentials = fresh;
|
|
359
564
|
}
|
|
@@ -371,16 +576,28 @@ export function reloadActiveAccount() {
|
|
|
371
576
|
* On success the account, its source, and the cache are all updated.
|
|
372
577
|
* The refresh function is injectable for tests.
|
|
373
578
|
*/
|
|
374
|
-
export function forceRefreshActiveAccount(refresh = refreshViaOAuth) {
|
|
579
|
+
export async function forceRefreshActiveAccount(refresh = refreshViaOAuth) {
|
|
375
580
|
const account = getActiveAccount();
|
|
376
581
|
if (!account?.credentials.refreshToken)
|
|
377
582
|
return null;
|
|
378
|
-
|
|
583
|
+
// These tokens belong to another account: exchanging them here would
|
|
584
|
+
// rotate the lender's refresh token and persist the result to this
|
|
585
|
+
// account's store. Borrowed-account recovery belongs to refreshIfNeeded.
|
|
586
|
+
if (borrowedCredentialAccounts.has(account)) {
|
|
587
|
+
log("force_refresh_skipped_borrowed", { source: account.source });
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
const priorAccessToken = account.credentials.accessToken;
|
|
591
|
+
const oauthCreds = await refresh(account.credentials.refreshToken);
|
|
379
592
|
if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
|
|
380
593
|
account.credentials = oauthCreds;
|
|
381
|
-
if (!writeBackCredentials(account.source, oauthCreds)) {
|
|
382
|
-
// Session continues from memory/cache
|
|
383
|
-
//
|
|
594
|
+
if (!writeBackCredentials(account.source, oauthCreds, account.configDir, priorAccessToken)) {
|
|
595
|
+
// Session continues from memory/cache either way, but the two causes
|
|
596
|
+
// diverge on a later source re-read. An I/O failure leaves our own
|
|
597
|
+
// rejected token in the store, so the re-read resurrects it and
|
|
598
|
+
// triggers another refresh. A CAS mismatch means the store now holds
|
|
599
|
+
// another account's token, so the re-read adopts that instead and this
|
|
600
|
+
// account stops using the credentials it just refreshed.
|
|
384
601
|
log("force_refresh_writeback_failed", { source: account.source });
|
|
385
602
|
}
|
|
386
603
|
accountCacheMap.set(account.source, {
|
|
@@ -405,7 +622,7 @@ export function invalidateCredentialCache() {
|
|
|
405
622
|
log("cache_invalidated", { source: account.source });
|
|
406
623
|
}
|
|
407
624
|
}
|
|
408
|
-
export function getCachedCredentials() {
|
|
625
|
+
export async function getCachedCredentials() {
|
|
409
626
|
const account = getActiveAccount();
|
|
410
627
|
if (!account)
|
|
411
628
|
return null;
|
|
@@ -424,13 +641,13 @@ export function getCachedCredentials() {
|
|
|
424
641
|
source: account.source,
|
|
425
642
|
reason: cached ? "stale or expiring" : "empty",
|
|
426
643
|
});
|
|
427
|
-
const fresh = refreshIfNeeded(account);
|
|
644
|
+
const fresh = await refreshIfNeeded(account);
|
|
428
645
|
if (!fresh) {
|
|
429
646
|
log("credentials_unavailable", { source: account.source });
|
|
430
647
|
accountCacheMap.delete(account.source);
|
|
431
648
|
return null;
|
|
432
649
|
}
|
|
433
|
-
accountCacheMap.set(account.source, { creds: fresh, cachedAt: now });
|
|
650
|
+
accountCacheMap.set(account.source, { creds: fresh, cachedAt: Date.now() });
|
|
434
651
|
return fresh;
|
|
435
652
|
}
|
|
436
653
|
export function reloadCredentialsFromSource() {
|
|
@@ -439,7 +656,9 @@ export function reloadCredentialsFromSource() {
|
|
|
439
656
|
return null;
|
|
440
657
|
let reloaded;
|
|
441
658
|
try {
|
|
442
|
-
|
|
659
|
+
// Same configDir the write path resolves, so the compare-and-swap in
|
|
660
|
+
// writeBackCredentials compares against the file this read came from.
|
|
661
|
+
reloaded = refreshAccount(account.source, account.configDir);
|
|
443
662
|
}
|
|
444
663
|
catch {
|
|
445
664
|
accountCacheMap.delete(account.source);
|
|
@@ -467,6 +686,13 @@ export function reloadCredentialsFromSource() {
|
|
|
467
686
|
return null;
|
|
468
687
|
}
|
|
469
688
|
account.credentials = reloaded;
|
|
689
|
+
// Read from this account's own source, so what it returned is this
|
|
690
|
+
// account's own credentials — it is no longer running on a lender's.
|
|
691
|
+
// Same invariant as refreshIfNeeded's up-front re-read: leaving the flag
|
|
692
|
+
// set here makes forceRefreshActiveAccount decline to exchange a token
|
|
693
|
+
// that is legitimately this account's, which strands the 401 recovery
|
|
694
|
+
// loop's second attempt on a credential it could have refreshed.
|
|
695
|
+
borrowedCredentialAccounts.delete(account);
|
|
470
696
|
accountCacheMap.set(account.source, { creds: reloaded, cachedAt: now });
|
|
471
697
|
log("credentials_source_reload", {
|
|
472
698
|
source: account.source,
|