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/README.md +30 -3
- package/cli.mjs +77 -24
- package/index.mjs +297 -200
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/betas.mjs +131 -0
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +137 -33
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -90
- package/lib/passthrough-headers.mjs +152 -0
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
package/lib/oauth.mjs
CHANGED
|
@@ -1,33 +1,38 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
82
|
+
function revokeOnLogoutEnabled(override) {
|
|
66
83
|
if (typeof override === "boolean") return override;
|
|
67
84
|
try {
|
|
68
|
-
return loadConfig()?.oauth?.
|
|
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
|
-
*
|
|
77
|
-
*
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
*
|
|
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,
|
|
329
|
-
const tokenHeaders = buildTokenHeaders(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
497
|
+
code: authCode,
|
|
360
498
|
redirect_uri: OAUTH_REDIRECT_URI,
|
|
361
|
-
client_id:
|
|
499
|
+
client_id: OAUTH_CLIENT_ID,
|
|
362
500
|
code_verifier: verifier,
|
|
363
|
-
...(
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
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
|
-
* @
|
|
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:
|
|
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`
|
|
466
|
-
*
|
|
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
|
-
|
|
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:
|
|
485
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
+
}
|