@modelprofile.com/authswitch 5.1.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/classes.claudecodeharness.d.ts +13 -4
- package/dist_ts/classes.claudecodeharness.js +36 -11
- package/dist_ts/classes.claudestatus.d.ts +25 -5
- package/dist_ts/classes.claudestatus.js +25 -20
- package/dist_ts/classes.claudetokenrefresh.d.ts +38 -3
- package/dist_ts/classes.claudetokenrefresh.js +80 -21
- package/dist_ts/classes.opencodeharness.d.ts +14 -0
- package/dist_ts/classes.opencodeharness.js +29 -1
- package/dist_ts/classes.operations.d.ts +6 -14
- package/dist_ts/classes.operations.js +7 -17
- package/dist_ts/claudehttp.d.ts +14 -0
- package/dist_ts/claudehttp.js +21 -1
- package/dist_ts/mutation.d.ts +23 -0
- package/dist_ts/mutation.js +26 -0
- package/package.json +13 -2
- package/readme.md +91 -20
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/classes.claudecodeharness.ts +37 -12
- package/ts/classes.claudestatus.ts +39 -19
- package/ts/classes.claudetokenrefresh.ts +96 -20
- package/ts/classes.opencodeharness.ts +30 -0
- package/ts/classes.operations.ts +7 -22
- package/ts/claudehttp.ts +22 -0
- package/ts/mutation.ts +32 -0
package/readme.md
CHANGED
|
@@ -193,14 +193,36 @@ concerns the instances running on it. The command line, the dashboard and a host
|
|
|
193
193
|
such as AGL all read this one rule; `remove` and a save that keeps the login
|
|
194
194
|
active leave the loaded login in place.
|
|
195
195
|
|
|
196
|
+
That rule, `assertAuthSwitchMutation` and the `TAuthSwitchMutation` command are also
|
|
197
|
+
published on their own at `@modelprofile.com/authswitch/mutation`, which is the one
|
|
198
|
+
import of this package a browser can bundle:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { authSwitchMutationReplacesLogin, type TAuthSwitchMutation } from '@modelprofile.com/authswitch/mutation';
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
That module imports nothing at all -- no Node built-in, no package, no other module of
|
|
205
|
+
this one -- so a web client decides what a mutation means to the instances it shows
|
|
206
|
+
without pulling a credential store into the bundle. The package root stays Node-only:
|
|
207
|
+
it reaches the stash, and with it `node:fs`, `node:child_process` and `node:crypto`.
|
|
208
|
+
Both entry points export the same three names from the same source, so a Node caller
|
|
209
|
+
can keep importing them from the package root.
|
|
210
|
+
|
|
196
211
|
OpenCode credentials live in `${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json`.
|
|
197
212
|
Several providers can be active at once. Saving or switching `openai` preserves
|
|
198
213
|
every other provider, and `stash openai` without `--keep` clears only OpenAI.
|
|
199
214
|
When several logins are active, the guide asks which to save; scripts must name
|
|
200
215
|
the provider or account. In the TUI, select an active row before pressing `a` or `c`.
|
|
201
216
|
ChatGPT OAuth accounts display their email and use the same direct status APIs as
|
|
202
|
-
Codex.
|
|
203
|
-
|
|
217
|
+
Codex. An `anthropic` OAuth login holds the same Claude subscriber grant Claude Code
|
|
218
|
+
stores, so it reports Claude's own plan, organization and usage windows through the
|
|
219
|
+
account endpoints described under **Claude Code**; OpenCode keeps no scopes and no
|
|
220
|
+
account identity with that entry, so both are left to the service. Renewing that token
|
|
221
|
+
stays OpenCode's -- the renewal rotates the refresh token a running OpenCode holds in
|
|
222
|
+
memory, which authswitch cannot write into an instance it does not manage -- so an access
|
|
223
|
+
token inside Claude Code's five-minute margin is reported as expired instead, with the
|
|
224
|
+
two ways to renew it. API keys and opaque provider tokens have no reliable email, so their
|
|
225
|
+
labels contain the provider and a SHA-256 fingerprint. Their quota/billing information is
|
|
204
226
|
explicitly unavailable. `OPENCODE_AUTH_CONTENT` overrides the file and blocks file
|
|
205
227
|
management. Provider configuration and environment credentials remain managed by OpenCode.
|
|
206
228
|
|
|
@@ -239,19 +261,27 @@ authswitch does not infer them from account creation or token expiry dates. The
|
|
|
239
261
|
Claude Code adapter declares that, so `limits` explains it once for the provider
|
|
240
262
|
instead of per account.
|
|
241
263
|
|
|
242
|
-
Nothing but Claude Code refreshes a Claude login, so a saved one stops being readable
|
|
243
|
-
|
|
264
|
+
Nothing but Claude Code refreshes a Claude login, so a saved one stops being readable a
|
|
265
|
+
few hours after it was saved. Reading a **saved, inactive** account's status therefore
|
|
244
266
|
refreshes it first, exactly as Claude Code would: when its access token expires within
|
|
245
267
|
five minutes, authswitch exchanges the stored refresh token at Claude's token endpoint,
|
|
246
268
|
keeps every other field of the login, and writes the result only while the saved record
|
|
247
|
-
still holds the refresh token it sent. The
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
login
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
still holds the refresh token it sent. The request is the one Claude Code sends, down to
|
|
270
|
+
the scopes: a login of Claude Code's own OAuth client is renewed with Claude Code's
|
|
271
|
+
current scope set plus the project scopes the login carries -- not with the set the
|
|
272
|
+
login happens to be stored under, which can be an older or wider one the service no
|
|
273
|
+
longer accepts -- and a set the service refuses as `invalid_scope` is sent once more
|
|
274
|
+
with the stored scopes. A login that names its own OAuth client keeps its stored scopes,
|
|
275
|
+
because only that client's grant knows them. A login that is neither one of Claude
|
|
276
|
+
Code's subscriber logins nor stored with a plan is not sent at all. The refresh holds
|
|
277
|
+
the account store's lock, so a switch started at the same time waits for it and then
|
|
278
|
+
activates the refreshed login. The **active** login is never refreshed -- Claude Code
|
|
279
|
+
owns it, and two parties refreshing one login can invalidate the grant. A refusal is
|
|
280
|
+
reported, never worked around -- a rejected grant says so and says what fixes it
|
|
281
|
+
(below), and a service failure, an unreachable service or an unsupported response leave
|
|
282
|
+
the saved login untouched. Requests carry `User-Agent: authswitch/<version>`, and HTTP
|
|
283
|
+
429 is reported as rate limiting with the service's retry time -- never as an exhausted
|
|
284
|
+
or expired account.
|
|
255
285
|
|
|
256
286
|
Claude Code also rotates the refresh token of the login it **is** running on, so reading the
|
|
257
287
|
accounts (`list`, `limits`, `active`, `current`, `watch`, the dashboard) mirrors that login
|
|
@@ -264,14 +294,55 @@ saved implicitly; `stash` is what saves a login. An installation whose native lo
|
|
|
264
294
|
authswitch cannot read mirrors nothing, and a read waits at most a second for a lock a
|
|
265
295
|
switch or Claude Code holds before leaving the saved copy to the next read.
|
|
266
296
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
that
|
|
274
|
-
new
|
|
297
|
+
An account the service has put **on hold** is not a dead login: the hold refuses the grant
|
|
298
|
+
while it lasts and the same login works again once it is lifted, so such an account keeps
|
|
299
|
+
its stored plan, says that it is on hold and where to appeal, and is asked again on the
|
|
300
|
+
next read.
|
|
301
|
+
|
|
302
|
+
A grant the sign-in service refused is remembered in the saved record itself, as a hash
|
|
303
|
+
of the request that was refused -- the refresh token, the client and the scopes, never
|
|
304
|
+
the token itself -- so a new authswitch run, a restarted watch or a second dashboard
|
|
305
|
+
does not replay a dead login. Because the hash covers the request and not only the
|
|
306
|
+
token, a release that changes how authswitch asks gives every such login one honest
|
|
307
|
+
attempt under the new derivation, while a refusal of the request authswitch still sends
|
|
308
|
+
stays remembered. Saving that login again clears it, as does any refresh that replaces
|
|
309
|
+
the saved credential. Such a login reports `Login expired or was rejected. Log in again
|
|
310
|
+
with Claude Code and save it. Claude Code rotates a login's refresh token as it uses it,
|
|
311
|
+
and the service can refuse the older one a saved copy still holds once that login has
|
|
312
|
+
been replaced outside authswitch; authswitch claude stash --keep saves the new one.`
|
|
313
|
+
|
|
314
|
+
Each `n/a` in a Claude row has a reason in the footnote under the table, and each
|
|
315
|
+
reason names what to do:
|
|
316
|
+
|
|
317
|
+
- `Login refresh: Login expired or was rejected.` -- the sign-in service refused the
|
|
318
|
+
saved refresh token itself. Log in again in Claude Code for that account and run
|
|
319
|
+
`authswitch claude stash --keep`; nothing else fixes it.
|
|
320
|
+
- `Login refresh: This Claude account is on hold and cannot sign in.` -- the account,
|
|
321
|
+
not the login. Appeal at the address in the message; the saved login is kept and is
|
|
322
|
+
asked again on the next read.
|
|
323
|
+
- `Login refresh: This saved login has no refresh token, and no subscriber scope or
|
|
324
|
+
plan to refresh with.` -- the saved record is missing what a refresh is made of.
|
|
325
|
+
Save the login again.
|
|
326
|
+
- `Login refresh: this saved login's access token has expired and cannot be renewed
|
|
327
|
+
for this installation.` -- authswitch cannot read Claude Code's own files here (the
|
|
328
|
+
macOS Keychain, or an incomplete native login), so it cannot prove this login is not
|
|
329
|
+
the active one, and never refreshes a login that might be.
|
|
330
|
+
- `Login refresh: The Claude sign-in service returned HTTP ...`, `... could not be
|
|
331
|
+
reached.`, `... timed out.`, `... is rate limiting requests (HTTP 429)` -- the
|
|
332
|
+
service, not the login. The saved login is untouched; read again later.
|
|
333
|
+
- `Profile: ...` or `Usage: ...` -- the account endpoints, asked with a token the
|
|
334
|
+
refresh had just accepted. `Profile: Login expired or was rejected.` means the access
|
|
335
|
+
token itself was refused; the other wordings are the service, as above.
|
|
336
|
+
- `Claude returned a different account or organization` -- the answer was about another
|
|
337
|
+
account and was discarded whole, rather than shown under this account's name.
|
|
338
|
+
- `Claude profile response is unsupported` or `Claude usage response is unsupported` --
|
|
339
|
+
the service answered in a shape authswitch does not read. Nothing is guessed from a
|
|
340
|
+
response it does not understand.
|
|
341
|
+
|
|
342
|
+
An OpenCode `anthropic` row reads the same endpoints and so shows the same reasons,
|
|
343
|
+
plus two of its own: `This Anthropic login's access token has expired.`, which OpenCode
|
|
344
|
+
renews by running or by `opencode auth login`, and `This Anthropic login holds no usable
|
|
345
|
+
access token`, which needs a new `opencode auth login`.
|
|
275
346
|
|
|
276
347
|
OpenCode and Claude saved records are separate owner-only files under
|
|
277
348
|
`~/.authswitch/opencode` and `~/.authswitch/claude`. Emails are accepted as account
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* autocreated commitinfo by @push.rocks/commitinfo
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
|
-
name:
|
|
6
|
-
version:
|
|
7
|
-
description:
|
|
5
|
+
name: '@modelprofile.com/authswitch',
|
|
6
|
+
version: '6.0.0',
|
|
7
|
+
description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
|
|
8
8
|
}
|
|
@@ -2,9 +2,9 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
|
|
3
3
|
import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, type IStoredCredential } from './classes.credentialstore.js';
|
|
4
4
|
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
5
|
-
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
5
|
+
import { ClaudeAccountStatus, type IClaudeLogin } from './classes.claudestatus.js';
|
|
6
6
|
import { ClaudeCodeLocks } from './classes.claudecodelocks.js';
|
|
7
|
-
import { ClaudeTokenRefresh, type IClaudeTokens } from './classes.claudetokenrefresh.js';
|
|
7
|
+
import { claudeGrant, ClaudeTokenRefresh, grantScope, type IClaudeTokens } from './classes.claudetokenrefresh.js';
|
|
8
8
|
import { CLAUDE_LOGIN_REJECTED, ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
|
|
9
9
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
10
10
|
import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
@@ -160,8 +160,33 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
160
160
|
throw new Error('Claude Code switch failed while updating account metadata and was rolled back. The outgoing login remains saved; check authswitch claude doctor.');
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
-
/**
|
|
164
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The hash that identifies a refused sign-in, so it is remembered without keeping anything of the login.
|
|
165
|
+
*
|
|
166
|
+
* A refusal is about one request: the refresh token it carried, and the client and scopes authswitch derived for
|
|
167
|
+
* it. Binding all three means a login keeps its refusal for as long as authswitch would send the same request
|
|
168
|
+
* again, and that a release which changes the derivation -- adopting a scope set Claude Code changed, say -- gives
|
|
169
|
+
* every such login one honest attempt under the new one instead of repeating a verdict its request no longer earns.
|
|
170
|
+
*/
|
|
171
|
+
private grantKey(oauthArg: Record<string, unknown>): string {
|
|
172
|
+
const grant = claudeGrant(oauthArg);
|
|
173
|
+
return credentialHash(JSON.stringify([credentialText(oauthArg.refreshToken) ?? '', grant?.clientId ?? '', grant === null ? '' : grantScope(grant.scopes)]));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The login a status lookup asks with: its token, and what this store knows about it. */
|
|
177
|
+
private login(accountArg: IFileAccount): IClaudeLogin {
|
|
178
|
+
const oauth = credentialRecord(accountArg.credential.claudeAiOauth);
|
|
179
|
+
const account = credentialRecord(accountArg.credential.oauthAccount);
|
|
180
|
+
const accountUuid = credentialText(account.accountUuid);
|
|
181
|
+
const organizationUuid = credentialText(account.organizationUuid);
|
|
182
|
+
const plan = credentialText(oauth.subscriptionType);
|
|
183
|
+
return {
|
|
184
|
+
accessToken: credentialText(oauth.accessToken) ?? '',
|
|
185
|
+
scopes: Array.isArray(oauth.scopes) ? oauth.scopes.filter((scope): scope is string => typeof scope === 'string') : [],
|
|
186
|
+
...(accountUuid !== undefined && organizationUuid !== undefined ? { identity: { accountUuid, organizationUuid } } : {}),
|
|
187
|
+
...(plan === undefined ? {} : { plan }),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
165
190
|
|
|
166
191
|
/**
|
|
167
192
|
* The harness's accounts, with the saved copy of the active login brought up to date first.
|
|
@@ -226,28 +251,28 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
226
251
|
* is due is refreshed first; when that fails, the status says why and keeps the stored plan, and the saved login is
|
|
227
252
|
* left exactly as it was.
|
|
228
253
|
*
|
|
229
|
-
* A refresh is only ever sent for a login the native files prove is not the active one (`nativeView`), and a
|
|
230
|
-
* the service already
|
|
254
|
+
* A refresh is only ever sent for a login the native files prove is not the active one (`nativeView`), and a request
|
|
255
|
+
* the service already refused is not sent again while the saved record would produce the same one (`grantKey`).
|
|
231
256
|
*/
|
|
232
257
|
public async readAccountStatus(id: string, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
233
|
-
const read = (accountArg: IFileAccount) =>
|
|
234
|
-
this.status.read(credentialRecord(accountArg.credential.claudeAiOauth), credentialRecord(accountArg.credential.oauthAccount), optionsArg);
|
|
258
|
+
const read = (accountArg: IFileAccount) => this.status.read(this.login(accountArg), optionsArg);
|
|
235
259
|
const view = this.nativeView(id);
|
|
236
260
|
if (view.active) return read(view.active);
|
|
237
261
|
const saved = this.savedCredential(id);
|
|
262
|
+
const login = this.login(saved);
|
|
238
263
|
const oauth = credentialRecord(saved.credential.claudeAiOauth);
|
|
239
264
|
if (!this.tokens.due(oauth)) return read(saved);
|
|
240
265
|
if (view.unavailable !== null) {
|
|
241
|
-
return this.status.unavailable(
|
|
266
|
+
return this.status.unavailable(login, `Login refresh: this saved login's access token has expired and cannot be renewed for this installation. ${view.unavailable}`);
|
|
242
267
|
}
|
|
243
|
-
if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(
|
|
268
|
+
if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(login, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
|
|
244
269
|
let refreshed: IFileAccount;
|
|
245
270
|
try { refreshed = await this.refreshSaved(id, optionsArg.signal); }
|
|
246
271
|
catch (error) {
|
|
247
272
|
if (optionsArg.signal?.aborted) throw error;
|
|
248
273
|
const problem = error instanceof ClaudeLoginRejectedError ? SAVED_LOGIN_REJECTED
|
|
249
274
|
: error instanceof Error ? error.message : 'the saved login could not be refreshed.';
|
|
250
|
-
return this.status.unavailable(
|
|
275
|
+
return this.status.unavailable(login, `Login refresh: ${problem}`,
|
|
251
276
|
error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
|
|
252
277
|
}
|
|
253
278
|
return read(refreshed);
|
|
@@ -260,7 +285,7 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
260
285
|
* became active meanwhile belongs to Claude Code and is returned as it is, one in an installation that can no
|
|
261
286
|
* longer be read is returned unrefreshed, and one another refresh already renewed is not refreshed twice. The new
|
|
262
287
|
* tokens replace the saved ones only while the record still holds the refresh token that was sent, the
|
|
263
|
-
* compare-and-swap Claude Code 2.1.273 applies to its own store (`$fn`, @
|
|
288
|
+
* compare-and-swap Claude Code 2.1.273 applies to its own store (`$fn`, @191212749).
|
|
264
289
|
*/
|
|
265
290
|
private refreshSaved(id: string, signal?: AbortSignal): Promise<IFileAccount> {
|
|
266
291
|
return this.store.locked(async () => {
|
|
@@ -110,6 +110,21 @@ type TClaudeProfile =
|
|
|
110
110
|
| { kind: 'mismatch' }
|
|
111
111
|
| { kind: 'unavailable'; problem: string };
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* One Claude OAuth login as a status lookup needs it: the token to ask with, and what the store that keeps it knows
|
|
115
|
+
* about it. Claude Code's own store keeps the scopes and the account the login belongs to; another harness holding
|
|
116
|
+
* the same kind of grant (OpenCode's `anthropic` provider entry) keeps neither, and leaves both to the service.
|
|
117
|
+
*/
|
|
118
|
+
export interface IClaudeLogin {
|
|
119
|
+
accessToken: string;
|
|
120
|
+
/** The scopes the login is stored with. An empty list means the store keeps none, not that the login has none. */
|
|
121
|
+
scopes: readonly string[];
|
|
122
|
+
/** The account the login must belong to; an answer about another account is refused. Omitted where no store keeps it. */
|
|
123
|
+
identity?: { accountUuid: string; organizationUuid: string };
|
|
124
|
+
/** The plan the login was saved with, the only one known while the service cannot be asked. */
|
|
125
|
+
plan?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
/**
|
|
114
129
|
* How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
|
|
115
130
|
* organization, billing type) changes far more rarely, so it is read at most hourly there.
|
|
@@ -119,9 +134,12 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
|
|
119
134
|
const MISMATCH = 'Claude returned a different account or organization; no account data was displayed.';
|
|
120
135
|
|
|
121
136
|
/**
|
|
122
|
-
* Claude Code's read-only OAuth account endpoints: the profile and usage of one login, with the
|
|
123
|
-
*
|
|
124
|
-
*
|
|
137
|
+
* Claude Code's read-only OAuth account endpoints: the profile and usage of one Claude OAuth login, asked with the
|
|
138
|
+
* access token that login carries and with the headers Claude Code 2.1.273 sends (`_ke`, bundle offset @191153494,
|
|
139
|
+
* and the `anthropic-beta` value `Vu`, @188533107). Any harness holding such a grant is served, not only Claude Code's own store.
|
|
140
|
+
*
|
|
141
|
+
* It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active login's
|
|
142
|
+
* tokens to Claude Code. No inference or browser session is used.
|
|
125
143
|
*/
|
|
126
144
|
export class ClaudeAccountStatus {
|
|
127
145
|
constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
|
|
@@ -139,15 +157,15 @@ export class ClaudeAccountStatus {
|
|
|
139
157
|
}
|
|
140
158
|
|
|
141
159
|
/** The profile, from the session while it is younger than an hour; the session key is a hash of the token, never the token. */
|
|
142
|
-
private profile(
|
|
143
|
-
const lookup = () => this.lookupProfile(
|
|
144
|
-
return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(
|
|
160
|
+
private profile(loginArg: IClaudeLogin, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
|
|
161
|
+
const lookup = () => this.lookupProfile(loginArg, optionsArg.signal);
|
|
162
|
+
return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(loginArg.accessToken)}`, PROFILE_REUSE_MS, lookup) : lookup();
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
/** A rejected login and an unsupported or foreign profile are answers; a rate limit or a failed request is thrown. */
|
|
148
|
-
private async lookupProfile(
|
|
166
|
+
private async lookupProfile(loginArg: IClaudeLogin, signal?: AbortSignal): Promise<TClaudeProfile> {
|
|
149
167
|
let body: Record<string, unknown>;
|
|
150
|
-
try { body = await this.get('profile',
|
|
168
|
+
try { body = await this.get('profile', loginArg.accessToken, signal); }
|
|
151
169
|
catch (error) {
|
|
152
170
|
if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
|
|
153
171
|
throw error;
|
|
@@ -155,7 +173,9 @@ export class ClaudeAccountStatus {
|
|
|
155
173
|
try {
|
|
156
174
|
const user = credentialRecord(body.account);
|
|
157
175
|
const organization = credentialRecord(body.organization);
|
|
158
|
-
|
|
176
|
+
// A store that keeps no account identity has none to compare; the service's answer is about the token that was sent.
|
|
177
|
+
const identity = loginArg.identity;
|
|
178
|
+
if (identity !== undefined && (user.uuid !== identity.accountUuid || organization.uuid !== identity.organizationUuid)) return { kind: 'mismatch' };
|
|
159
179
|
const facts: IHarnessStatusFact[] = [];
|
|
160
180
|
for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
|
|
161
181
|
if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
|
|
@@ -165,25 +185,25 @@ export class ClaudeAccountStatus {
|
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
/** The plan the login was saved with, the only one known while the service cannot be asked. */
|
|
168
|
-
private storedSummary(
|
|
169
|
-
|
|
170
|
-
return plan ? { summary: { subscription: { plan, source: 'stored' } } } : {};
|
|
188
|
+
private storedSummary(loginArg: Pick<IClaudeLogin, 'plan'>): Pick<IHarnessAccountStatus, 'summary'> {
|
|
189
|
+
return loginArg.plan ? { summary: { subscription: { plan: loginArg.plan, source: 'stored' } } } : {};
|
|
171
190
|
}
|
|
172
191
|
|
|
173
192
|
/** A login that could not be made usable for a lookup: its stored plan and why, with the service's retry time for a rate limit. */
|
|
174
|
-
public unavailable(
|
|
175
|
-
return { facts: [], problems: [problem], ...this.storedSummary(
|
|
193
|
+
public unavailable(loginArg: Pick<IClaudeLogin, 'plan'>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
|
|
194
|
+
return { facts: [], problems: [problem], ...this.storedSummary(loginArg), ...(rateLimit ? { rateLimit } : {}) };
|
|
176
195
|
}
|
|
177
196
|
|
|
178
|
-
public async read(
|
|
179
|
-
const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(
|
|
180
|
-
|
|
197
|
+
public async read(loginArg: IClaudeLogin, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
198
|
+
const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(loginArg) };
|
|
199
|
+
// Only a store that keeps the login's scopes can tell beforehand that the service will not answer about the account.
|
|
200
|
+
if (loginArg.scopes.length && !loginArg.scopes.includes('user:profile')) {
|
|
181
201
|
result.facts.push({ section: 'Availability', label: 'Live account status', value: 'This login lacks the user:profile scope. Log in through Claude Code to obtain subscriber status.' });
|
|
182
202
|
return result;
|
|
183
203
|
}
|
|
184
|
-
const token = credentialText(
|
|
204
|
+
const token = credentialText(loginArg.accessToken);
|
|
185
205
|
if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
|
|
186
|
-
const [profile, usage] = await Promise.allSettled([this.profile(
|
|
206
|
+
const [profile, usage] = await Promise.allSettled([this.profile(loginArg, optionsArg), this.get('usage', token, optionsArg.signal)]);
|
|
187
207
|
const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
|
|
188
208
|
if (profile.status === 'fulfilled') {
|
|
189
209
|
const found = profile.value;
|
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import { credentialText } from './classes.credentialstore.js';
|
|
2
|
-
import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError } from './claudehttp.js';
|
|
2
|
+
import { claudeRequest, ClaudeAccountOnHoldError, ClaudeLoginRejectedError, ClaudeRequestError, oauthErrorCode } from './claudehttp.js';
|
|
3
3
|
|
|
4
|
-
/** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @
|
|
4
|
+
/** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @188533776). */
|
|
5
5
|
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
6
|
-
/** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @
|
|
6
|
+
/** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @188534240), used when a login does not name its own. */
|
|
7
7
|
export const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
8
|
-
/** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @
|
|
9
|
-
const
|
|
8
|
+
/** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @191161107). */
|
|
9
|
+
export const CLAUDE_REFRESH_MARGIN_MS = 300_000;
|
|
10
10
|
/**
|
|
11
11
|
* Claude Code 2.1.273 allows a refresh 30 seconds. A refresh is never cut shorter than that, nor cancelled once sent:
|
|
12
12
|
* the service may already have replaced the refresh token the request carries.
|
|
13
13
|
*/
|
|
14
14
|
const REFRESH_TIMEOUT_MS = 30_000;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The scopes Claude Code 2.1.273 asks for when it refreshes a login of its own OAuth client (`bFe`, @188533214,
|
|
18
|
+
* with the plugins scope its production build registers). It sends this set whatever the login was stored with,
|
|
19
|
+
* so a login saved under an older or wider set is renewed exactly as Claude Code renews it.
|
|
20
|
+
*/
|
|
21
|
+
export const CLAUDE_CODE_SCOPES: readonly string[] = [
|
|
22
|
+
'user:profile', 'user:inference', 'user:sessions:claude_code', 'user:mcp_servers', 'user:file_upload', 'user:plugins',
|
|
23
|
+
];
|
|
24
|
+
/** The only stored scopes Claude Code carries into that set (`Dyn`, @188533421). */
|
|
25
|
+
const CARRIED_SCOPES: readonly string[] = ['user:projects:read', 'user:projects:write'];
|
|
26
|
+
/** The scope that marks a login as one of Claude Code's own subscriber logins (`NS`/`YWt`, @188532967). */
|
|
27
|
+
const INFERENCE_SCOPE = 'user:inference';
|
|
28
|
+
|
|
16
29
|
/** The token fields a refresh replaces in a stored `claudeAiOauth`; every other field is kept. */
|
|
17
30
|
export interface IClaudeTokens {
|
|
18
31
|
accessToken: string;
|
|
@@ -23,11 +36,64 @@ export interface IClaudeTokens {
|
|
|
23
36
|
scopes?: string[];
|
|
24
37
|
}
|
|
25
38
|
|
|
39
|
+
/** How Claude Code 2.1.273 builds one refresh request for a stored login. */
|
|
40
|
+
export interface IClaudeGrant {
|
|
41
|
+
/** The login's own OAuth client, or Claude Code's public one. */
|
|
42
|
+
clientId: string;
|
|
43
|
+
/** The scopes the request asks for. */
|
|
44
|
+
scopes: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* The stored scopes the request falls back to once the service refuses the set above as `invalid_scope`
|
|
47
|
+
* (`hg`, @191228494); null where Claude Code sends no second request.
|
|
48
|
+
*/
|
|
49
|
+
fallbackScopes: readonly string[] | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The scopes a stored login carries, or none when it carries nothing a scope list can be read from. */
|
|
53
|
+
const storedScopes = (oauthArg: Record<string, unknown>): string[] =>
|
|
54
|
+
Array.isArray(oauthArg.scopes) && oauthArg.scopes.every(scope => typeof scope === 'string') ? [...oauthArg.scopes as string[]] : [];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How Claude Code 2.1.273 would renew this login, or null where it would not send a refresh at all: a login without
|
|
58
|
+
* a refresh token, and one that is neither a subscriber login (`user:inference`) nor stored with a plan, which
|
|
59
|
+
* Claude Code reports as `not_refreshable` (`hg`, @191228494).
|
|
60
|
+
*
|
|
61
|
+
* A login of Claude Code's own client is renewed with Claude Code's current scope set plus the project scopes it
|
|
62
|
+
* carries (`DQn`, @188533511) -- not with the set it was stored under. A login that names its own OAuth client is
|
|
63
|
+
* renewed with exactly the scopes it was stored with, because only that client's grant knows them.
|
|
64
|
+
*/
|
|
65
|
+
export const claudeGrant = (oauthArg: Record<string, unknown>): IClaudeGrant | null => {
|
|
66
|
+
if (!credentialText(oauthArg.refreshToken)) return null;
|
|
67
|
+
const stored = storedScopes(oauthArg);
|
|
68
|
+
const isSubscriberLogin = stored.includes(INFERENCE_SCOPE);
|
|
69
|
+
if (!isSubscriberLogin && !credentialText(oauthArg.subscriptionType)) return null;
|
|
70
|
+
const clientId = credentialText(oauthArg.clientId);
|
|
71
|
+
if (clientId !== undefined) return { clientId, scopes: stored, fallbackScopes: null };
|
|
72
|
+
return {
|
|
73
|
+
clientId: CLAUDE_CODE_CLIENT_ID,
|
|
74
|
+
scopes: [...new Set([...CLAUDE_CODE_SCOPES, ...stored.filter(scope => CARRIED_SCOPES.includes(scope))])],
|
|
75
|
+
fallbackScopes: isSubscriberLogin ? stored : null,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The `scope` value one request carries: Claude Code sends its own set for an empty scope list (`RQ`, @191156785). */
|
|
80
|
+
export const grantScope = (scopesArg: readonly string[]): string => (scopesArg.length ? scopesArg : CLAUDE_CODE_SCOPES).join(' ');
|
|
81
|
+
|
|
26
82
|
const seconds = (valueArg: unknown): number | undefined =>
|
|
27
83
|
typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
|
|
28
84
|
|
|
29
85
|
/**
|
|
30
|
-
*
|
|
86
|
+
* Whether the service refused this grant because the account is on hold (`Pfn`, @191165493, over `fgt`, @190071426):
|
|
87
|
+
* an `invalid_grant` or `access_denied` whose description is the service's `account_on_hold` marker. Claude Code keeps
|
|
88
|
+
* such a login and retries it later; only a refusal without that marker means the grant itself is dead.
|
|
89
|
+
*/
|
|
90
|
+
const accountOnHold = (statusArg: number, bodyArg: Record<string, unknown> | null): boolean =>
|
|
91
|
+
(statusArg === 400 || statusArg === 401 || statusArg === 403)
|
|
92
|
+
&& (oauthErrorCode(bodyArg) === 'invalid_grant' || oauthErrorCode(bodyArg) === 'access_denied')
|
|
93
|
+
&& bodyArg?.error_description === 'account_on_hold';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @191156785).
|
|
31
97
|
*
|
|
32
98
|
* It only talks to the token endpoint; which login is refreshed, and how the result is stored, belongs to the harness.
|
|
33
99
|
*/
|
|
@@ -41,29 +107,39 @@ export class ClaudeTokenRefresh {
|
|
|
41
107
|
public due(oauthArg: Record<string, unknown>): boolean {
|
|
42
108
|
const expiresAt = oauthArg.expiresAt;
|
|
43
109
|
return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
|
|
44
|
-
&& this.now() +
|
|
110
|
+
&& this.now() + CLAUDE_REFRESH_MARGIN_MS >= expiresAt;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One refresh request, as Claude Code sends it: a JSON body, the login's client, and the scopes of this attempt. */
|
|
114
|
+
private attempt(refreshTokenArg: string, grantArg: IClaudeGrant, scopesArg: readonly string[]) {
|
|
115
|
+
return claudeRequest(this.fetcher, {
|
|
116
|
+
url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
grant_type: 'refresh_token', refresh_token: refreshTokenArg, client_id: grantArg.clientId, scope: grantScope(scopesArg),
|
|
119
|
+
}),
|
|
120
|
+
timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
|
|
121
|
+
});
|
|
45
122
|
}
|
|
46
123
|
|
|
47
124
|
/**
|
|
48
125
|
* New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
|
|
49
126
|
* stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
|
|
50
|
-
* rejects is `ClaudeLoginRejectedError`,
|
|
127
|
+
* rejects is `ClaudeLoginRejectedError`, an account the service put on hold is `ClaudeAccountOnHoldError`, and a rate
|
|
128
|
+
* limit is `ClaudeRateLimitError`.
|
|
51
129
|
*/
|
|
52
130
|
public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
|
|
53
131
|
const refreshToken = credentialText(oauthArg.refreshToken);
|
|
54
|
-
const
|
|
55
|
-
if (!refreshToken || !
|
|
56
|
-
throw new ClaudeRequestError('This saved login has no refresh token or
|
|
132
|
+
const grant = claudeGrant(oauthArg);
|
|
133
|
+
if (!refreshToken || !grant) {
|
|
134
|
+
throw new ClaudeRequestError('This saved login has no refresh token, and no subscriber scope or plan to refresh with. Log in again with Claude Code and save it.');
|
|
57
135
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
66
|
-
if ((status === 400 || status === 401) && body?.error === 'invalid_grant') throw new ClaudeLoginRejectedError();
|
|
136
|
+
let { status, body } = await this.attempt(refreshToken, grant, grant.scopes);
|
|
137
|
+
// The service refusing Claude Code's current scope set is the one failure it sends a second request for.
|
|
138
|
+
if (status === 400 && oauthErrorCode(body) === 'invalid_scope' && grant.fallbackScopes !== null) {
|
|
139
|
+
({ status, body } = await this.attempt(refreshToken, grant, grant.fallbackScopes));
|
|
140
|
+
}
|
|
141
|
+
if (accountOnHold(status, body)) throw new ClaudeAccountOnHoldError();
|
|
142
|
+
if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
|
|
67
143
|
if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
|
|
68
144
|
const receivedAt = this.now();
|
|
69
145
|
const accessToken = credentialText(body?.access_token);
|
|
@@ -2,6 +2,8 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
|
|
3
3
|
import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
|
|
4
4
|
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
5
|
+
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
6
|
+
import { CLAUDE_REFRESH_MARGIN_MS } from './classes.claudetokenrefresh.js';
|
|
5
7
|
import { CodexAccountStatus } from './classes.codexstatus.js';
|
|
6
8
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
7
9
|
import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
@@ -15,6 +17,8 @@ export interface IOpenCodeHarnessOptions {
|
|
|
15
17
|
fetch?: typeof fetch;
|
|
16
18
|
/** Running-instance inspection, injectable for isolated installations and tests. */
|
|
17
19
|
processes?: IHarnessProcessControl;
|
|
20
|
+
/** The clock that decides whether a provider's stored access token is still usable; `Date.now` by default. */
|
|
21
|
+
now?: () => number;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
|
|
@@ -31,8 +35,10 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
31
35
|
private readonly file: string;
|
|
32
36
|
private readonly env: NodeJS.ProcessEnv;
|
|
33
37
|
private readonly status: CodexAccountStatus;
|
|
38
|
+
private readonly claudeStatus: ClaudeAccountStatus;
|
|
34
39
|
public readonly processes: IHarnessProcessControl;
|
|
35
40
|
private readonly fetcher?: typeof fetch;
|
|
41
|
+
private readonly now: () => number;
|
|
36
42
|
|
|
37
43
|
constructor(options: IOpenCodeHarnessOptions = {}) {
|
|
38
44
|
super();
|
|
@@ -40,7 +46,9 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
40
46
|
this.file = plugins.path.join(options.dataHome ?? (this.env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local', 'share')), 'opencode', 'auth.json');
|
|
41
47
|
this.store = new CredentialStore(this.id, options.stashRoot);
|
|
42
48
|
this.status = new CodexAccountStatus(options.fetch);
|
|
49
|
+
this.claudeStatus = new ClaudeAccountStatus(options.fetch, options.now);
|
|
43
50
|
this.fetcher = options.fetch;
|
|
51
|
+
this.now = options.now ?? Date.now;
|
|
44
52
|
this.processes = options.processes ?? new HarnessProcesses('opencode');
|
|
45
53
|
}
|
|
46
54
|
/** Every provider entry lives in one file, so its bytes are the whole stability proof. */
|
|
@@ -104,6 +112,27 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
104
112
|
writeSecretFileAtomically(this.file, raw);
|
|
105
113
|
if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
|
|
106
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The subscription and usage of an OpenCode `anthropic` OAuth login.
|
|
117
|
+
*
|
|
118
|
+
* That entry holds the same Claude subscriber grant Claude Code stores, so Claude's own account endpoints answer
|
|
119
|
+
* for it and the account reports real windows instead of no quota API at all. OpenCode keeps no scopes and no
|
|
120
|
+
* account identity with it, so the service decides both; the token is used exactly as stored. Renewing it is
|
|
121
|
+
* OpenCode's, because the renewal rotates the refresh token a running OpenCode holds in memory, and authswitch
|
|
122
|
+
* cannot write that rotation into an instance it does not manage.
|
|
123
|
+
*/
|
|
124
|
+
private anthropicStatus(credentialArg: Record<string, unknown>, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
|
|
125
|
+
const accessToken = credentialText(credentialArg.access);
|
|
126
|
+
const expires = credentialArg.expires;
|
|
127
|
+
if (accessToken === undefined || typeof expires !== 'number') {
|
|
128
|
+
return Promise.resolve(this.claudeStatus.unavailable({}, 'This Anthropic login holds no usable access token; log in again with opencode auth login.'));
|
|
129
|
+
}
|
|
130
|
+
if (this.now() + CLAUDE_REFRESH_MARGIN_MS >= expires) {
|
|
131
|
+
return Promise.resolve(this.claudeStatus.unavailable({}, 'This Anthropic login\'s access token has expired. OpenCode renews it while it runs; authswitch does not, because the renewal rotates the refresh token a running OpenCode keeps in memory. Start OpenCode, or log in again with opencode auth login.'));
|
|
132
|
+
}
|
|
133
|
+
return this.claudeStatus.read({ accessToken, scopes: [] }, optionsArg);
|
|
134
|
+
}
|
|
135
|
+
|
|
107
136
|
public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
|
|
108
137
|
const account = this.readCredential(id);
|
|
109
138
|
if (account.slotId === 'openai' && account.credential.type === 'oauth') {
|
|
@@ -111,6 +140,7 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
111
140
|
if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
|
|
112
141
|
return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
|
|
113
142
|
}
|
|
143
|
+
if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account.credential, optionsArg);
|
|
114
144
|
return { facts: [
|
|
115
145
|
{ section: 'Authentication', label: 'Provider', value: account.slotId },
|
|
116
146
|
{ section: 'Authentication', label: 'Login type', value: String(account.credential.type) },
|