@modelprofile.com/authswitch 5.2.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.
@@ -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
- /** The hash that identifies a saved login's refresh token, so a rejected grant is remembered without keeping it. */
164
- private grantKey(oauthArg: Record<string, unknown>): string { return credentialHash(credentialText(oauthArg.refreshToken) ?? ''); }
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 grant
230
- * the service already rejected is not sent again while the saved record still holds it.
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(oauth, `Login refresh: this saved login's access token has expired and cannot be renewed for this installation. ${view.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(oauth, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
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(oauth, `Login refresh: ${problem}`,
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`, @15410824).
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 access token it
123
- * carries. It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active
124
- * login's tokens to Claude Code. No inference or browser session is used.
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(token: string, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
143
- const lookup = () => this.lookupProfile(token, account, optionsArg.signal);
144
- return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(token)}`, PROFILE_REUSE_MS, lookup) : lookup();
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(token: string, account: Record<string, unknown>, signal?: AbortSignal): Promise<TClaudeProfile> {
166
+ private async lookupProfile(loginArg: IClaudeLogin, signal?: AbortSignal): Promise<TClaudeProfile> {
149
167
  let body: Record<string, unknown>;
150
- try { body = await this.get('profile', token, signal); }
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
- if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { kind: 'mismatch' };
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(oauth: Record<string, unknown>): Pick<IHarnessAccountStatus, 'summary'> {
169
- const plan = credentialText(oauth.subscriptionType);
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(oauth: Record<string, unknown>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
175
- return { facts: [], problems: [problem], ...this.storedSummary(oauth), ...(rateLimit ? { rateLimit } : {}) };
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(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
179
- const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(oauth) };
180
- if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes('user:profile')) {
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(oauth.accessToken);
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(token, account, optionsArg), this.get('usage', token, optionsArg.signal)]);
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 @12733057; see hints.md). */
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`, @12733057), used when a login does not name its own. */
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`, @15359182). */
9
- const REFRESH_MARGIN_MS = 300_000;
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
- * Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @15355055).
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() + REFRESH_MARGIN_MS >= expiresAt;
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`, and a rate limit is `ClaudeRateLimitError`.
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 scopes = oauthArg.scopes;
55
- if (!refreshToken || !Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string')) {
56
- throw new ClaudeRequestError('This saved login has no refresh token or scopes to refresh with. Log in again with Claude Code and save it.');
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
- const { status, body } = await claudeRequest(this.fetcher, {
59
- url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
60
- body: JSON.stringify({
61
- grant_type: 'refresh_token', refresh_token: refreshToken,
62
- client_id: credentialText(oauthArg.clientId) ?? CLAUDE_CODE_CLIENT_ID, scope: scopes.join(' '),
63
- }),
64
- timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
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) },
package/ts/claudehttp.ts CHANGED
@@ -13,6 +13,28 @@ export class ClaudeLoginRejectedError extends ClaudeRequestError {
13
13
  constructor() { super(CLAUDE_LOGIN_REJECTED); }
14
14
  }
15
15
 
16
+ /**
17
+ * Where Claude Code 2.1.273 sends an account the service put on hold (`T5e`, bundle offset @190070402). A hold is
18
+ * not a dead login: the service refuses the grant while it lasts and accepts it again once it is lifted.
19
+ */
20
+ export const CLAUDE_ACCOUNT_ON_HOLD_URL = 'https://claude.ai/restricted';
21
+
22
+ /** The service put this account on hold. The login itself is intact, so it is never remembered as refused. */
23
+ export class ClaudeAccountOnHoldError extends ClaudeRequestError {
24
+ constructor() { super(`This Claude account is on hold and cannot sign in. View details or appeal: ${CLAUDE_ACCOUNT_ON_HOLD_URL}`); }
25
+ }
26
+
27
+ /**
28
+ * The OAuth error code a response names, read as Claude Code 2.1.273 reads it (`xc`, @191165290): the `error` field
29
+ * as a string, or the `type` of the object some Anthropic endpoints send it as.
30
+ */
31
+ export const oauthErrorCode = (bodyArg: Record<string, unknown> | null): string | undefined => {
32
+ const error = bodyArg?.error;
33
+ if (typeof error === 'string') return error;
34
+ const type = typeof error === 'object' && error !== null ? (error as Record<string, unknown>).type : undefined;
35
+ return typeof type === 'string' ? type : undefined;
36
+ };
37
+
16
38
  /** The service refused a request for too many requests. That says nothing about the account's usage or login. */
17
39
  export class ClaudeRateLimitError extends ClaudeRequestError {
18
40
  constructor(subjectArg: string, public readonly retryAt: string | null) {