opencode-anthropic-fix 0.6.0 → 2.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/lib/oauth.mjs CHANGED
@@ -1,33 +1,38 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { CLIENT_ID, loadConfig } from "./config.mjs";
2
+ import { loadConfig } from "./config.mjs";
3
3
  import { parseRetryAfterHeader, parseRetryAfterMsHeader } from "./backoff.mjs";
4
+ import {
5
+ OAUTH_BETA,
6
+ OAUTH_CLAUDE_AI_AUTHORIZE_URL,
7
+ OAUTH_CLIENT_ID,
8
+ OAUTH_CONSOLE_AUTHORIZE_URL,
9
+ OAUTH_REDIRECT_URI,
10
+ OAUTH_SDK_USER_AGENT,
11
+ OAUTH_TOKEN_URL,
12
+ } from "./oauth-constants.mjs";
13
+ import { CLAUDE_AI_BASE_SCOPES, composeScopes, missingBaseScopes, serializeScopes } from "./oauth-scopes.mjs";
14
+ import { resolveCustomOAuthUrl } from "./oauth-env.mjs";
4
15
 
5
16
  // ---------------------------------------------------------------------------
6
17
  // OAuth helpers — shared between plugin (index.mjs) and CLI (cli.mjs)
18
+ //
19
+ // Every *genuine* endpoint, user-agent and beta name used here is imported from
20
+ // ./oauth-constants.mjs, which transcribes docs/oauth-2.1.280-contract.md.
21
+ // This module owns OAuth *behaviour*; it owns no genuine OAuth *values*. The one
22
+ // value it does own is OAUTH_REVOKE_URL below, which is a divergence (D1) and is
23
+ // deliberately kept out of the transcription module.
7
24
  // ---------------------------------------------------------------------------
8
25
 
9
- const OAUTH_CONSOLE_HOST = "platform.claude.com";
10
- const OAUTH_MAX_HOST = "claude.ai";
11
- const OAUTH_REDIRECT_URI = `https://${OAUTH_CONSOLE_HOST}/oauth/code/callback`;
12
- const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
13
- const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
14
- // Real Claude Code uses axios 1.13.6 for OAuth calls — axios sets User-Agent
15
- // automatically and adds Accept header. We must match this fingerprint exactly.
16
- // CLI version profile: 2.1.84 (for reference; not sent on OAuth calls)
17
- const OAUTH_AXIOS_VERSION = "1.13.6";
18
- const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
19
- const OAUTH_ACCEPT = "application/json, text/plain, */*";
20
- // Claude Code 2.1.195 migrated the OAuth token client from axios to the
21
- // Anthropic TS SDK's native-fetch `userOAuthProvider`. When the
22
- // `oauth.sdk_token_useragent` config flag is ON, the token POST switches to
23
- // this fingerprint: `User-Agent: anthropic-sdk-typescript/0.94.0
24
- // userOAuthProvider`, `anthropic-beta: oauth-2025-04-20`, JSON body, and NO
25
- // axios `Accept` header (native fetch sends no default Accept). The SDK version
26
- // mirrors request-headers.mjs `ANTHROPIC_SDK_VERSION` ("0.94.0"); hardcoded here
27
- // to avoid coupling the OAuth path to the messages-header module.
28
- const OAUTH_SDK_VERSION = "0.94.0";
29
- const OAUTH_SDK_USER_AGENT = `anthropic-sdk-typescript/${OAUTH_SDK_VERSION} userOAuthProvider`;
30
- const OAUTH_SDK_BETA = "oauth-2025-04-20";
26
+ /**
27
+ * The RFC 7009 revocation endpoint.
28
+ *
29
+ * NOT PART OF GENUINE CLIENT BEHAVIOUR. `/v1/oauth/revoke` appears nowhere in
30
+ * the Claude Code 2.1.280 bundle, so this URL is deliberately absent from
31
+ * ./oauth-constants.mjs — that module is the transcription of the genuine
32
+ * contract, and this is a divergence.
33
+ * @see docs/oauth-2.1.280-contract.md §6 item 1, §7 divergence D1
34
+ */
35
+ const OAUTH_REVOKE_URL = "https://platform.claude.com/v1/oauth/revoke";
31
36
  const OAUTH_MAX_RETRIES = 2;
32
37
  const OAUTH_MAX_RETRY_DELAY_MS = 30_000;
33
38
  const OAUTH_RATE_LIMIT_COOLDOWN_MS = 30_000;
@@ -35,14 +40,23 @@ const OAUTH_RETRY_AFTER_SOURCE_HEADER_MS = "retry-after-ms";
35
40
  const OAUTH_RETRY_AFTER_SOURCE_HEADER = "retry-after";
36
41
  const OAUTH_RETRY_AFTER_SOURCE_FALLBACK_429 = "fallback-429";
37
42
 
38
- export const CLAUDE_AI_SCOPES = [
39
- "user:profile",
40
- "user:inference",
41
- "user:sessions:claude_code",
42
- "user:mcp_servers",
43
- "user:file_upload",
44
- ];
45
- const CONSOLE_SCOPES = ["org:create_api_key", "user:profile"];
43
+ /**
44
+ * The claude.ai base scope set, in wire order.
45
+ *
46
+ * The array itself is now owned by `./oauth-scopes.mjs`, which composes the
47
+ * conditional extensions. This is an alias binding onto that same frozen array
48
+ * (not an `export … from` re-export), retained so existing importers of
49
+ * `CLAUDE_AI_SCOPES` keep resolving.
50
+ *
51
+ * PARITY IS CONDITIONAL. §13.8 records two conditional extensions to this set:
52
+ * `user:plugins` is appended when the `PLUGINS_SCOPE_REGISTERED` gate is on, and
53
+ * `user:projects:read` / `user:projects:write` when requested. §13.8 does not
54
+ * record the gate's shipped state. If it ships on, a genuine authorize URL
55
+ * carries six scopes and this five-scope URL is itself a distinguishing signal.
56
+ * That is an open, unverifiable item, not a solved one.
57
+ * @see docs/oauth-2.1.280-contract.md §4.1, §4.2, §7 divergence D9
58
+ */
59
+ export const CLAUDE_AI_SCOPES = CLAUDE_AI_BASE_SCOPES;
46
60
 
47
61
  function base64url(input) {
48
62
  return Buffer.from(input).toString("base64url");
@@ -56,40 +70,108 @@ function generatePKCE() {
56
70
  }
57
71
 
58
72
  /**
59
- * Resolve whether the SDK-native-fetch token fingerprint should be used.
60
- * An explicit boolean override (used by callers/tests) wins; otherwise the
61
- * live `oauth.sdk_token_useragent` config flag is read (default true).
73
+ * Resolve whether logout should attempt server-side token revocation.
74
+ *
75
+ * Defaults to `false`: revocation is not genuine-client behaviour, so it is an
76
+ * explicit opt-in rather than something a user has to discover and disable.
77
+ * An explicit boolean override (used by callers/tests) wins.
62
78
  * @param {boolean | undefined} override
63
79
  * @returns {boolean}
80
+ * @see docs/oauth-2.1.280-contract.md §7 divergence D1
64
81
  */
65
- function useSdkTokenFingerprint(override) {
82
+ function revokeOnLogoutEnabled(override) {
66
83
  if (typeof override === "boolean") return override;
67
84
  try {
68
- return loadConfig()?.oauth?.sdk_token_useragent === true;
85
+ return loadConfig()?.oauth?.revoke_on_logout === true;
69
86
  } catch {
70
87
  return false;
71
88
  }
72
89
  }
73
90
 
91
+ /**
92
+ * Resolve the scope gates for an authorize call.
93
+ *
94
+ * An explicit option always wins over config, so a caller can force either
95
+ * state in a test without touching the user's config file. A wrongly-typed
96
+ * explicit option throws rather than silently deferring to config: these are
97
+ * programmer inputs, and a silent fallback would make the forced state a lie.
98
+ *
99
+ * Config is read live (never cached in a closed-over constant) because
100
+ * `/anthropic set` mutates it at runtime, and it is read ONLY when at least one
101
+ * gate is still unresolved — `authorize()` used to be free of any filesystem
102
+ * dependency and should reacquire one no more often than it must.
103
+ *
104
+ * A config read that fails degrades to the documented defaults rather than
105
+ * failing the login, but it warns: silently downgrading a user who configured
106
+ * `plugins_scope: true` to a five-scope token with no diagnostic is exactly the
107
+ * kind of unexplained behaviour this project treats as a defect.
108
+ * @param {{pluginsRegistered?: boolean, projectScopes?: string[]} | null} [options]
109
+ * @returns {{pluginsRegistered: boolean, projectScopes: unknown}}
110
+ * @see docs/oauth-2.1.280-contract.md §4.2, §7 divergence D9
111
+ */
112
+ function resolveScopeOptions(options) {
113
+ const opts = options ?? {};
114
+ const pluginsExplicit = opts.pluginsRegistered !== undefined;
115
+ const projectExplicit = opts.projectScopes !== undefined;
116
+ if (pluginsExplicit && typeof opts.pluginsRegistered !== "boolean") {
117
+ throw new TypeError("authorize(): options.pluginsRegistered must be a boolean when supplied");
118
+ }
119
+ if (projectExplicit && !Array.isArray(opts.projectScopes)) {
120
+ throw new TypeError("authorize(): options.projectScopes must be an array when supplied");
121
+ }
122
+ if (pluginsExplicit && projectExplicit) {
123
+ return { pluginsRegistered: opts.pluginsRegistered === true, projectScopes: opts.projectScopes };
124
+ }
125
+
126
+ let oauth;
127
+ try {
128
+ oauth = loadConfig()?.oauth;
129
+ } catch (err) {
130
+ console.warn(
131
+ `[anthropic-auth] could not read config while composing OAuth scopes; requesting the default set: ${err?.message ?? err}`,
132
+ );
133
+ oauth = undefined;
134
+ }
135
+ return {
136
+ pluginsRegistered: pluginsExplicit ? opts.pluginsRegistered === true : oauth?.plugins_scope === true,
137
+ projectScopes: projectExplicit
138
+ ? opts.projectScopes
139
+ : Array.isArray(oauth?.project_scopes)
140
+ ? oauth.project_scopes
141
+ : [],
142
+ };
143
+ }
144
+
74
145
  /**
75
146
  * Build the headers for an OAuth token POST (exchange/refresh).
76
- * @param {boolean} useSdk - When true, emit the CC 2.1.195 SDK-native-fetch
77
- * fingerprint; when false, emit the legacy axios fingerprint (default).
147
+ *
148
+ * MEASURED FOR REFRESH, ASSUMED FOR EXCHANGE. §13.8 attests this triple only at
149
+ * the refresh call site (byte 4429828). It records no `authorization_code` grant
150
+ * at all, so reusing the triple on exchange is assumption D8, not a measurement.
151
+ *
152
+ * This module emits one triple rather than switching between two: no axios usage
153
+ * appears anywhere in §13.8, so a switch selecting an axios shape could not make
154
+ * a user more anonymous — it would make them the only client sending that shape.
155
+ *
156
+ * SCOPE OF THE CLAIM: these are the three headers this module controls. The host
157
+ * runtime adds its own (`host`, `connection`, `accept-encoding`, `content-length`
158
+ * and others) with its own casing and ordering. §13.8 names no transport and no
159
+ * platform, so the runtime-added set is unverifiable from the evidence and is
160
+ * listed as an open signal in the contract doc. §13.8 also records a second
161
+ * token-endpoint fingerprint (`oidcFederationProvider`); that surface is out of
162
+ * scope here (D12).
163
+ *
164
+ * Header **order** is emitted in the order §13.8 enumerates it for refresh; the
165
+ * source literal's own order is not attested. The literal is built in emission
166
+ * order and must never be sorted or spread through a helper that reorders it.
78
167
  * @returns {Record<string, string>}
168
+ * @see docs/oauth-2.1.280-contract.md §2.3, §2.5, §7 divergences D8 and D12
79
169
  */
80
- function buildTokenHeaders(useSdk) {
81
- if (useSdk) {
82
- // Native fetch sends no default Accept header — omit it deliberately.
83
- return {
84
- "Content-Type": "application/json",
85
- "anthropic-beta": OAUTH_SDK_BETA,
86
- "User-Agent": OAUTH_SDK_USER_AGENT,
87
- };
88
- }
170
+ function buildTokenHeaders() {
89
171
  return {
90
- Accept: OAUTH_ACCEPT,
91
172
  "Content-Type": "application/json",
92
- "User-Agent": OAUTH_USER_AGENT,
173
+ "anthropic-beta": OAUTH_BETA,
174
+ "User-Agent": OAUTH_SDK_USER_AGENT,
93
175
  };
94
176
  }
95
177
 
@@ -205,19 +287,69 @@ function parseOAuthErrorBody(rawText) {
205
287
 
206
288
  /**
207
289
  * Build an OAuth authorization URL with PKCE challenge.
290
+ *
291
+ * ASSUMPTION — THE QUERY STRING IS NOT ATTESTED. §13.8 records the two authorize
292
+ * *URLs* and nothing else about this request: no query parameter set, no
293
+ * parameter order, no PKCE material, and no redirect URI. Everything below the
294
+ * origin and path is therefore pre-existing plugin behaviour preserved verbatim,
295
+ * not a transcription of a measurement. Tests pin it so it cannot drift
296
+ * accidentally; they do not certify it as genuine.
297
+ *
298
+ * The scope list is composed at call time from a base set plus gated additions.
299
+ * Defaults reproduce the previous five-scope / two-scope strings byte for byte.
300
+ * The genuine client's gate state is unattested (§7 divergence D9).
208
301
  * @param {"max" | "console"} mode
302
+ * @param {{pluginsRegistered?: boolean, projectScopes?: string[]}} [options]
209
303
  * @returns {Promise<{url: string, verifier: string, state: string}>}
304
+ * @see docs/oauth-2.1.280-contract.md §2.1, §1.1 (OAUTH_REDIRECT_URI)
210
305
  */
211
- export async function authorize(mode) {
212
- const pkce = generatePKCE();
306
+ export async function authorize(mode, options = {}) {
307
+ if (mode !== "max" && mode !== "console") {
308
+ // Silently treating an unknown mode as consumer login would send a user to
309
+ // the wrong provider with the wrong scope set and no visible failure.
310
+ throw new Error(`Unknown OAuth mode "${mode}" (expected "max" or "console")`);
311
+ }
312
+
313
+ // ATTESTED: §13.8 (byte 4657393) records that CLAUDE_CODE_CUSTOM_OAUTH_URL is
314
+ // validated against a three-entry allowlist and throws otherwise. That throw is
315
+ // the attested behaviour and is enforced here: an unapproved value rejects with
316
+ // the byte-exact genuine message.
317
+ //
318
+ // NOT ATTESTED: what an APPROVED value is then used for. §13.8 records the
319
+ // validation, not the consumption, so redirecting the flow to it would be an
320
+ // invention. But silently ignoring an approved value is worse than either
321
+ // option: an operator who points this plugin at a FedRAMP endpoint would get no
322
+ // error and would still have their authorization code and refresh token sent to
323
+ // platform.claude.com. Validate-then-ignore is a misroute dressed as a feature.
324
+ // So an approved value is refused explicitly until the use site is attested.
325
+ // Divergence D14.
326
+ const customOAuthUrl = resolveCustomOAuthUrl(options?.env ?? process.env);
327
+ if (customOAuthUrl) {
328
+ throw new Error(
329
+ `CLAUDE_CODE_CUSTOM_OAUTH_URL is set to the approved endpoint ${customOAuthUrl}, but this plugin cannot honour it: the evidence records that the value is validated, not what it is used for, so redirecting the flow would be a guess. Unset the variable to authenticate against the default endpoints. See the OAuth wire contract doc, divergence D14.`,
330
+ );
331
+ }
213
332
 
214
- const url = new URL(`https://${mode === "console" ? OAUTH_CONSOLE_HOST : OAUTH_MAX_HOST}/oauth/authorize`);
333
+ const pkce = generatePKCE();
334
+ // Console mode ignores both gates (`composeScopes` drops them), so it must not
335
+ // pay for a config read: `authorize()` acquires a filesystem dependency only
336
+ // on the path that can actually use the answer.
337
+ const gates = mode === "console" ? { pluginsRegistered: false, projectScopes: [] } : resolveScopeOptions(options);
338
+
339
+ // The consumer authorize page carries a `/cai/` path segment, so this is a
340
+ // full URL and not a host interpolated into a shared template.
341
+ const url = new URL(mode === "console" ? OAUTH_CONSOLE_AUTHORIZE_URL : OAUTH_CLAUDE_AI_AUTHORIZE_URL);
342
+ // --- unattested region: parameter set, order, and values (see the docblock) ---
215
343
  url.searchParams.set("code", "true");
216
- url.searchParams.set("client_id", CLIENT_ID);
344
+ url.searchParams.set("client_id", OAUTH_CLIENT_ID);
217
345
  url.searchParams.set("response_type", "code");
218
346
  url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
219
- const scopes = mode === "console" ? CONSOLE_SCOPES : CLAUDE_AI_SCOPES;
220
- url.searchParams.set("scope", scopes.join(" "));
347
+ url.searchParams.set(
348
+ "scope",
349
+ serializeScopes(
350
+ composeScopes({ mode, pluginsRegistered: gates.pluginsRegistered, projectScopes: gates.projectScopes }),
351
+ ),
352
+ );
221
353
  url.searchParams.set("code_challenge", pkce.challenge);
222
354
  url.searchParams.set("code_challenge_method", "S256");
223
355
  url.searchParams.set("state", pkce.state);
@@ -310,8 +442,9 @@ export function parseOAuthCallback(input) {
310
442
  * Exchange an authorization code for tokens.
311
443
  * @param {string} code
312
444
  * @param {string} verifier
313
- * @param {{ sdkTokenUserAgent?: boolean }} [options] - `sdkTokenUserAgent`
314
- * overrides the `oauth.sdk_token_useragent` config flag (mainly for tests).
445
+ * @param {{ sdkTokenUserAgent?: boolean }} [options] - `sdkTokenUserAgent` is
446
+ * accepted and ignored. It is retained so existing call sites keep type-
447
+ * checking; there is only one token fingerprint. See §7 divergence D2.
315
448
  * @returns {Promise<
316
449
  * | { type: "success", refresh: string, access: string, expires: number, email?: string }
317
450
  * | {
@@ -325,8 +458,8 @@ export function parseOAuthCallback(input) {
325
458
  * }
326
459
  * >}
327
460
  */
328
- export async function exchange(code, verifier, options = {}) {
329
- const tokenHeaders = buildTokenHeaders(useSdkTokenFingerprint(options.sdkTokenUserAgent));
461
+ export async function exchange(code, verifier, _options = {}) {
462
+ const tokenHeaders = buildTokenHeaders();
330
463
  const fail = (status, rawText = "", cooldownHint = { retryAfterMs: null }) => {
331
464
  const { errorCode, reason } = parseOAuthErrorBody(rawText);
332
465
  const retryAfterMs =
@@ -350,22 +483,27 @@ export async function exchange(code, verifier, options = {}) {
350
483
  };
351
484
  };
352
485
 
353
- const { code: _authCode, state: _authState } = parseOAuthCallback(code);
486
+ const { code: authCode, state: authState } = parseOAuthCallback(code);
354
487
  let result;
355
488
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
356
489
  try {
357
- const _exchangeBody = {
490
+ // SHAPE IS INPUT-DEPENDENT (D8). `state` is present only when the pasted
491
+ // callback carried one, so this body is five keys for a bare code and six
492
+ // for a full callback URL. §13.8 records no authorization_code grant, so
493
+ // neither shape can be checked against the evidence; the conditional is
494
+ // preserved because the server has always accepted both from this client.
495
+ const exchangeBody = {
358
496
  grant_type: "authorization_code",
359
- code: _authCode,
497
+ code: authCode,
360
498
  redirect_uri: OAUTH_REDIRECT_URI,
361
- client_id: CLIENT_ID,
499
+ client_id: OAUTH_CLIENT_ID,
362
500
  code_verifier: verifier,
363
- ...(_authState != null ? { state: _authState } : {}),
501
+ ...(authState != null ? { state: authState } : {}),
364
502
  };
365
503
  result = await fetch(OAUTH_TOKEN_URL, {
366
504
  method: "POST",
367
505
  headers: tokenHeaders,
368
- body: JSON.stringify(_exchangeBody),
506
+ body: JSON.stringify(exchangeBody),
369
507
  signal: AbortSignal.timeout(15_000),
370
508
  });
371
509
  } catch (err) {
@@ -427,53 +565,86 @@ export async function exchange(code, verifier, options = {}) {
427
565
  }
428
566
 
429
567
  /**
430
- * Attempt to revoke a refresh token server-side (best-effort, RFC 7009).
568
+ * THIS REQUEST IS NOT PART OF GENUINE CLIENT BEHAVIOUR. `/v1/oauth/revoke`
569
+ * appears nowhere in the Claude Code 2.1.280 bundle, so every revocation this
570
+ * plugin performs is a request that identifies it. It is therefore gated behind
571
+ * `oauth.revoke_on_logout`, which defaults to `false`.
572
+ *
573
+ * With the gate off the function performs **no network call at all** and
574
+ * reports `{ attempted: false }`. With the gate on an RFC 7009-*style* request
575
+ * is sent — note that RFC 7009 §2.1 mandates form encoding and this body is
576
+ * JSON, matching the rest of this plugin's token layer rather than the RFC.
431
577
  *
432
- * Anthropic may or may not support this endpoint. The function returns
433
- * `true` on a 2xx response and `false` otherwise — callers should always
434
- * proceed with local cleanup regardless of the result.
578
+ * The request carries the same SDK fingerprint as the other token requests
579
+ * rather than the retired axios one. That is a judgement call, not a parity
580
+ * claim: §13.8 has no revoke endpoint, so no user-agent is "correct" here, and
581
+ * a `userOAuthProvider` agent on a path the SDK never calls is arguably its own
582
+ * tell. The alternative was keeping a second, equally unevidenced fingerprint
583
+ * alive purely for this one request, which is worse.
584
+ *
585
+ * THE RETURN VALUE DISTINGUISHES THREE OUTCOMES, because "we chose not to try"
586
+ * and "we tried and the server refused" are different facts and a caller must
587
+ * not report one as the other. Callers must proceed with local cleanup in all
588
+ * three cases.
435
589
  *
436
590
  * @param {string} refreshToken
437
- * @returns {Promise<boolean>}
591
+ * @param {{ revokeOnLogout?: boolean }} [options] - `revokeOnLogout` overrides
592
+ * the config flag (mainly for tests).
593
+ * @returns {Promise<{ attempted: boolean, ok: boolean, status?: number, error?: string }>}
594
+ * @see docs/oauth-2.1.280-contract.md §6 item 1, §7 divergence D1
438
595
  */
439
- export async function revoke(refreshToken) {
596
+ export async function revoke(refreshToken, options = {}) {
597
+ if (!revokeOnLogoutEnabled(options.revokeOnLogout)) return { attempted: false, ok: false };
440
598
  try {
441
599
  const resp = await fetch(OAUTH_REVOKE_URL, {
442
600
  method: "POST",
443
- headers: {
444
- Accept: OAUTH_ACCEPT,
445
- "Content-Type": "application/json",
446
- "User-Agent": OAUTH_USER_AGENT,
447
- },
601
+ headers: buildTokenHeaders(),
448
602
  body: JSON.stringify({
449
603
  token: refreshToken,
450
604
  token_type_hint: "refresh_token",
451
- client_id: CLIENT_ID,
605
+ client_id: OAUTH_CLIENT_ID,
452
606
  }),
453
607
  signal: AbortSignal.timeout(5000),
454
608
  });
455
- return resp.ok;
456
- } catch {
457
- return false;
609
+ return { attempted: true, ok: resp.ok === true, status: resp.status };
610
+ } catch (err) {
611
+ return { attempted: true, ok: false, error: err instanceof Error ? err.message : String(err) };
458
612
  }
459
613
  }
460
614
 
461
615
  /**
462
616
  * Refresh an OAuth access token.
617
+ * The request body is exactly `{ grant_type, refresh_token, client_id }`.
618
+ * Genuine Claude Code 2.1.280 sends **no `scope` key** on refresh; sending one
619
+ * is a positive fingerprint. `options.scopes` is therefore accepted and
620
+ * ignored — it is retained so existing call sites keep working. No call site in
621
+ * this repository passes it; the guard exists so a future one cannot silently
622
+ * reintroduce the key.
623
+ *
463
624
  * @param {string} refreshTokenValue - The refresh token to use
464
- * @param {{ signal?: AbortSignal, scopes?: string[], sdkTokenUserAgent?: boolean }} [options]
465
- * `sdkTokenUserAgent` overrides the `oauth.sdk_token_useragent` config flag
466
- * (mainly for tests).
625
+ * @param {{ signal?: AbortSignal, scopes?: string[], sdkTokenUserAgent?: boolean, clientId?: string }} [options]
626
+ * `scopes` and `sdkTokenUserAgent` are accepted and ignored. A caller-supplied
627
+ * `signal` bounds the whole call including retries; when omitted, each attempt
628
+ * gets its own fresh 30 s timeout.
629
+ * `clientId` overrides the default OAuth client id. It exists for
630
+ * CLAUDE_CODE_OAUTH_CLIENT_ID-seeded accounts (contract §5.2, divergence D10)
631
+ * and does not change the body's key set — it stays exactly
632
+ * { grant_type, refresh_token, client_id } per §2.3.
467
633
  * @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>}
468
634
  * @throws {Error} On HTTP errors or network failures
635
+ * @see docs/oauth-2.1.280-contract.md §2.3, §6 item 2
469
636
  */
470
637
  export async function refreshToken(refreshTokenValue, options = {}) {
471
- // QA fix M13: default 30s timeout to prevent hanging on unresponsive server
472
- const signal = options.signal ?? AbortSignal.timeout(30_000);
473
- const tokenHeaders = buildTokenHeaders(useSdkTokenFingerprint(options.sdkTokenUserAgent));
638
+ const tokenHeaders = buildTokenHeaders();
474
639
 
475
640
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
476
641
  let resp;
642
+ // A single AbortSignal.timeout() created outside the loop starts counting at
643
+ // creation, so a slow first attempt would leave the retries with no budget
644
+ // and turn a 3-attempt policy into a 1-attempt one. A caller that supplies
645
+ // its own signal is explicitly bounding the whole operation, retries
646
+ // included, so that one is shared on purpose.
647
+ const signal = options.signal ?? AbortSignal.timeout(30_000);
477
648
  try {
478
649
  resp = await fetch(OAUTH_TOKEN_URL, {
479
650
  method: "POST",
@@ -481,8 +652,8 @@ export async function refreshToken(refreshTokenValue, options = {}) {
481
652
  body: JSON.stringify({
482
653
  grant_type: "refresh_token",
483
654
  refresh_token: refreshTokenValue,
484
- client_id: CLIENT_ID,
485
- scope: options.scopes ? options.scopes.join(" ") : CLAUDE_AI_SCOPES.join(" "),
655
+ client_id:
656
+ typeof options.clientId === "string" && options.clientId.length > 0 ? options.clientId : OAUTH_CLIENT_ID,
486
657
  }),
487
658
  signal,
488
659
  });
@@ -526,11 +697,20 @@ export async function refreshToken(refreshTokenValue, options = {}) {
526
697
 
527
698
  /**
528
699
  * Check if an account has all required Claude.ai scopes.
700
+ *
701
+ * "Required" means the five-scope base set only. The conditional extensions
702
+ * (`user:plugins`, `user:projects:*`) are deliberately NOT required: they are
703
+ * opt-in, and demanding them would invalidate every already-stored token the
704
+ * moment a user enables a gate. The consequence is that enabling
705
+ * `oauth.plugins_scope` on an existing account does not by itself obtain the
706
+ * scope — the user must re-authorise for the wider grant to be issued. That
707
+ * gap is recorded in §7.3 of the contract doc.
529
708
  * @param {string[] | undefined} scopes
530
709
  * @returns {boolean}
531
710
  */
532
711
  export function hasRequiredScopes(scopes) {
533
712
  if (!Array.isArray(scopes) || scopes.length === 0) return false;
534
- const stored = new Set(scopes);
535
- return CLAUDE_AI_SCOPES.every((s) => stored.has(s));
713
+ // Routed through missingBaseScopes() so the refresh-time check and the
714
+ // headless boot-time check in ./oauth-env.mjs cannot drift apart.
715
+ return missingBaseScopes(scopes).length === 0;
536
716
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The emulation-OFF request envelope.
3
+ *
4
+ * WHY THIS MODULE IS NOT UNDER lib/mimicry/. That is the point of it. With
5
+ * `signature_emulation.enabled === false` the plugin makes NO attempt to look
6
+ * like Claude Code, so no mimicry function may compose the outgoing headers —
7
+ * if one did, "off" would keep meaning "less mimicry" instead of "none", which
8
+ * is exactly the half-state Phase 2.2 removed.
9
+ *
10
+ * WHAT SURVIVES, AND WHY IT IS NOT MIMICRY. The plugin is an OAuth transport:
11
+ * it owns the AUTH ENVELOPE and nothing else.
12
+ *
13
+ * - `authorization` — the selected account's bearer. Without it there is no
14
+ * request; account rotation is the plugin's entire reason to exist.
15
+ * - `anthropic-beta` — ADDITIVE, never substitutive. The host's value is
16
+ * preserved verbatim and `oauth-2025-04-20` is appended when missing. That
17
+ * beta is a CONTRACT of the OAuth token, not a fingerprint: the API rejects
18
+ * an OAuth bearer without it (docs/claude-code-reverse-engineering.md §14.2
19
+ * item 3 and §14.3, both listing it as MUST for OAuth). Forging the rest of
20
+ * the beta list, or replacing the host's, would be mimicry — and is gone.
21
+ * - `x-api-key`, `x-session-affinity` — REMOVED. The first is a competing
22
+ * credential that would travel next to our bearer; the second is an opencode
23
+ * SDK routing hint that leaks session identity upstream. Both are envelope
24
+ * hygiene, not disguise.
25
+ *
26
+ * Everything else the host sent — `user-agent` above all — goes out untouched.
27
+ */
28
+
29
+ import { stripStainlessHelperMarkers } from "./mimicry/headers.mjs";
30
+
31
+ /** The beta the Anthropic API requires alongside an OAuth bearer. */
32
+ export const OAUTH_BETA_FLAG = "oauth-2025-04-20";
33
+
34
+ /**
35
+ * Credentials and routing hints that must not travel with our bearer.
36
+ * @see the module comment
37
+ */
38
+ const STRIPPED_HOST_HEADERS = ["x-api-key", "x-session-affinity"];
39
+
40
+ /**
41
+ * Copy the host's headers, from either carrier, into one mutable set.
42
+ *
43
+ * @param {Request | URL | string | undefined} input
44
+ * @param {RequestInit} requestInit
45
+ * @returns {Headers}
46
+ */
47
+ function collectHostHeaders(input, requestInit) {
48
+ const headers = new Headers();
49
+
50
+ if (input instanceof Request) {
51
+ input.headers.forEach((value, name) => headers.set(name, value));
52
+ }
53
+
54
+ const initHeaders = requestInit?.headers;
55
+ if (initHeaders instanceof Headers) {
56
+ initHeaders.forEach((value, name) => headers.set(name, value));
57
+ } else if (Array.isArray(initHeaders)) {
58
+ for (const [name, value] of initHeaders) {
59
+ if (typeof value !== "undefined") headers.set(name, String(value));
60
+ }
61
+ } else if (initHeaders && typeof initHeaders === "object") {
62
+ for (const [name, value] of Object.entries(initHeaders)) {
63
+ if (typeof value !== "undefined") headers.set(name, String(value));
64
+ }
65
+ }
66
+
67
+ return headers;
68
+ }
69
+
70
+ /**
71
+ * Build the outgoing headers for a request that carries NO Claude Code
72
+ * emulation: the host's own headers plus the auth envelope.
73
+ *
74
+ * @param {Request | URL | string | undefined} input The host's fetch input.
75
+ * @param {RequestInit} requestInit The host's fetch init.
76
+ * @param {string} accessToken The selected account's access token.
77
+ * @returns {Headers}
78
+ */
79
+ export function buildPassthroughHeaders(input, requestInit, accessToken) {
80
+ const headers = collectHostHeaders(input, requestInit);
81
+
82
+ for (const name of STRIPPED_HOST_HEADERS) headers.delete(name);
83
+
84
+ // ANTHROPIC_AUTH_TOKEN is the documented manual-bearer escape hatch and
85
+ // belongs to the same envelope: it selects WHICH token authenticates, not how
86
+ // the request looks.
87
+ const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
88
+ headers.set("authorization", `Bearer ${authTokenOverride || accessToken}`);
89
+
90
+ const hostBetas = (headers.get("anthropic-beta") || "")
91
+ .split(",")
92
+ .map((beta) => beta.trim())
93
+ .filter(Boolean);
94
+ if (!hostBetas.includes(OAUTH_BETA_FLAG)) hostBetas.push(OAUTH_BETA_FLAG);
95
+ headers.set("anthropic-beta", hostBetas.join(","));
96
+
97
+ return headers;
98
+ }
99
+
100
+ /**
101
+ * Remove the body fields the Anthropic API does not accept, and nothing else.
102
+ *
103
+ * THE ONLY BODY EDITS LEFT WITH EMULATION OFF. Every structural normalization
104
+ * `transformRequestBody` performs (output cap, thinking, effort ->
105
+ * output_config, system sanitize/compact, metadata, cache breakpoints) is
106
+ * skipped on this path — the host's body goes out as the host wrote it. These
107
+ * two strips are the exception, on the same grounds as the auth envelope: they
108
+ * are what keeps the request VALID, not what makes it look like Claude Code.
109
+ * Both were already applied unconditionally with emulation off, so keeping them
110
+ * preserves a working request rather than adding policy.
111
+ *
112
+ * - `betas` — never a first-party field. It existed for Bedrock, which cannot
113
+ * forward custom HTTP headers; the first-party API answers "Extra inputs are
114
+ * not permitted".
115
+ * - the stainless-helper markers — a HOST-side signal (opencode tags a tool or
116
+ * message so the plugin can derive `x-stainless-helper`). The plugin never
117
+ * injects them and, with emulation off, never derives the header either; the
118
+ * API has never known the keys and rejects them inside a tool definition.
119
+ * The marker list lives in lib/mimicry/headers.mjs and is imported rather
120
+ * than copied: the boundary rule this module enforces is that no mimicry
121
+ * function COMPOSES a fingerprint here, and removing a field the API rejects
122
+ * is the exact inverse of that.
123
+ *
124
+ * When neither is present the ORIGINAL string is returned, byte for byte: a
125
+ * re-serialization would reorder nothing today but would make the passthrough
126
+ * claim untrue tomorrow.
127
+ *
128
+ * @param {unknown} body
129
+ * @returns {unknown} The body, with the non-API fields removed if present.
130
+ */
131
+ export function stripNonApiBodyFields(body) {
132
+ if (typeof body !== "string" || body.length === 0) return body;
133
+
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(body);
137
+ } catch {
138
+ // Not JSON — there is no field to strip, and rewriting is not our business.
139
+ return body;
140
+ }
141
+
142
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return body;
143
+
144
+ let changed = false;
145
+ if (Object.hasOwn(parsed, "betas")) {
146
+ delete parsed.betas;
147
+ changed = true;
148
+ }
149
+ if (stripStainlessHelperMarkers(parsed.tools, parsed.messages) > 0) changed = true;
150
+
151
+ return changed ? JSON.stringify(parsed) : body;
152
+ }