op-anthropic-auth-v2 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +31 -13
  2. package/index.js +186 -89
  3. package/package.json +11 -2
package/README.md CHANGED
@@ -1,34 +1,45 @@
1
1
  # op-anthropic-auth-v2
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/op-anthropic-auth-v2?style=flat-square&logo=npm&logoColor=white&label=npm&color=teal)](https://www.npmjs.com/package/op-anthropic-auth-v2)
4
+
3
5
  OpenCode 2 plugin for Anthropic OAuth with Claude Pro/Max.
4
6
 
7
+ ## Origin and attribution
8
+
9
+ This package is adapted from
10
+ [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) version `0.1.4`,
11
+ created by [leohenon](https://github.com/leohenon) and released under the MIT
12
+ License. The original project's authentication approach, request shaping, and
13
+ plugin design form the basis of this package.
14
+
15
+ This adaptation ports that work to the OpenCode 2 plugin API. It is maintained as
16
+ a separate package and is not an official OpenCode 2 release from the original
17
+ author. The [license](./LICENSE) retains credit for the original work.
18
+
5
19
  > [!WARNING]
6
20
  > This is an unofficial compatibility plugin. Anthropic may restrict third-party use
7
21
  > of Claude subscription OAuth credentials, and using it could put your account at
8
22
  > risk. Review Anthropic's current terms and use it at your own discretion.
9
23
 
10
- Port of [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) (MIT, by
11
- leohenon) to the [OpenCode 2](https://opencode.ai/v2/docs) plugin API. The original
12
- plugin targets OpenCode 1 and cannot load in v2 — this package is the v2 equivalent.
13
-
14
24
  ## What it does
15
25
 
16
26
  OpenCode 2 beta sends Anthropic OAuth requests as-is, and Anthropic rejects them with
17
27
  an opaque `429` (`{"message":"Error"}`). This plugin rewrites every request to
18
28
  `api.anthropic.com` so it passes OAuth gating:
19
29
 
30
+ - registers a `Claude Pro/Max` browser login using OAuth and PKCE
31
+ - lets OpenCode 2 store credentials and refresh expired tokens
20
32
  - sets the `claude-cli` user-agent and required `anthropic-beta` headers
21
33
  - adds the `x-anthropic-billing-header` system block
22
34
  - prepends the Claude Code identity to the system prompt and relocates remaining
23
35
  system text into the first user message
24
36
  - prefixes tool names with `mcp_` and rewrites the response stream back
25
- - refreshes the OAuth token on expiry and persists it to the shared
26
- `~/.local/share/opencode/auth.json`
27
37
 
28
38
  ## Install
29
39
 
30
40
  ```sh
31
41
  opencode2 plugin add op-anthropic-auth-v2
42
+ opencode2 auth login anthropic --method oauth
32
43
  ```
33
44
 
34
45
  or add it to `~/.config/opencode/opencode.jsonc`:
@@ -39,22 +50,29 @@ or add it to `~/.config/opencode/opencode.jsonc`:
39
50
  }
40
51
  ```
41
52
 
42
- Requires existing Anthropic OAuth credentials (`opencode2 auth login`, or credentials
43
- already stored by OpenCode 1 in `~/.local/share/opencode/auth.json`). Users with a
44
- plain API key are unaffected — the plugin leaves non-OAuth setups untouched.
53
+ The login command opens Anthropic's authorization page. Complete authorization, then
54
+ paste the returned code into OpenCode 2. Users with a plain API key are unaffected;
55
+ the plugin leaves non-OAuth connections untouched.
45
56
 
46
- For OpenCode 1, keep using [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth).
47
- Both can coexist: v1 ignores this package's format, v2 ignores the v1 package.
57
+ This adaptation only targets OpenCode 2. OpenCode 1 users should use the original
58
+ [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) package.
48
59
 
49
60
  ## Compatibility
50
61
 
51
- Built against `opencode2` `0.0.0-beta-18269`. The v2 plugin API is beta and may
52
- change; pin accordingly.
62
+ Built against `opencode2` `0.0.0-beta-18743`. Version `0.1.2` reports Claude Code
63
+ `2.1.257` to meet Anthropic's model compatibility check. The v2 plugin API is beta
64
+ and may change; pin accordingly.
65
+
66
+ The runtime uses `@openauthjs/openauth` for PKCE generation. Development uses the
67
+ matching `@opencode-ai/plugin` beta types, TypeScript, and Node.js types to check the
68
+ published JavaScript against the OpenCode 2 plugin API.
53
69
 
54
70
  ## Development
55
71
 
56
72
  ```sh
73
+ npm install
57
74
  npm test
75
+ npm run typecheck
58
76
  npm run check
59
77
  npm run pack:dry-run
60
78
  ```
package/index.js CHANGED
@@ -1,13 +1,19 @@
1
1
  // Anthropic OAuth (Claude Pro/Max) support for OpenCode 2.
2
2
  // Port of op-anthropic-auth@0.1.4 to the v2 plugin API.
3
3
  // v1 loads op-anthropic-auth itself; this file targets opencode2 only.
4
- import { createHash } from "node:crypto";
5
- import { readFileSync, writeFileSync, renameSync, chmodSync } from "node:fs";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { chmodSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { generatePKCE } from "@openauthjs/openauth/pkce";
8
9
 
9
10
  const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
11
+ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
10
12
  const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
13
+ const CALLBACK_URL = "https://platform.claude.com/oauth/code/callback";
14
+ const AUTH_METHOD_ID = "oauth";
15
+ const AUTH_SCOPE =
16
+ "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
11
17
  const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"];
12
18
  const TOOL_PREFIX = "mcp_";
13
19
  const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
@@ -20,117 +26,191 @@ const PARAGRAPH_REMOVAL_ANCHORS = [
20
26
  const TEXT_REPLACEMENTS = [
21
27
  { match: "if OpenCode honestly", replacement: "if the assistant honestly" },
22
28
  ];
23
- const CLAUDE_CODE_VERSION = "2.1.87";
29
+ const CLAUDE_CODE_VERSION = "2.1.257";
24
30
  const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
25
31
  const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
26
32
  const CCH_SALT = "59cf53e54c78";
27
33
  const CCH_POSITIONS = [4, 7, 20];
28
- const REQUEST_USER_AGENT = "claude-cli/2.1.87 (external, cli)";
34
+ const REQUEST_USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`;
29
35
  const TOKEN_USER_AGENT = "axios/1.13.6";
30
36
 
31
- const AUTH_FILE =
32
- process.env.XDG_DATA_HOME != null
33
- ? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
34
- : join(homedir(), ".local", "share", "opencode", "auth.json");
35
-
36
37
  function isRecord(value) {
37
38
  return value != null && typeof value === "object" && !Array.isArray(value);
38
39
  }
39
40
 
40
- function isOAuthAuth(value) {
41
+ function isPluginOAuthCredential(value) {
41
42
  return (
42
43
  isRecord(value) &&
43
44
  value.type === "oauth" &&
45
+ value.methodID === AUTH_METHOD_ID &&
44
46
  typeof value.refresh === "string" &&
47
+ value.refresh.length > 0 &&
48
+ typeof value.access === "string" &&
49
+ value.access.length > 0 &&
45
50
  typeof value.expires === "number"
46
51
  );
47
52
  }
48
53
 
49
- // ---- auth store ----
54
+ function migrateLegacyCredential() {
55
+ const authFile =
56
+ process.env.XDG_DATA_HOME != null
57
+ ? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
58
+ : join(homedir(), ".local", "share", "opencode", "auth.json");
59
+ try {
60
+ const all = JSON.parse(readFileSync(authFile, "utf8"));
61
+ const credential = all?.anthropic;
62
+ if (
63
+ !isRecord(credential) ||
64
+ credential.type !== "oauth" ||
65
+ credential.methodID != null ||
66
+ typeof credential.refresh !== "string" ||
67
+ credential.refresh.length === 0 ||
68
+ !Number.isSafeInteger(credential.expires)
69
+ ) {
70
+ return false;
71
+ }
72
+
73
+ const hasAccess = typeof credential.access === "string" && credential.access.length > 0;
74
+ all.anthropic = {
75
+ ...credential,
76
+ methodID: AUTH_METHOD_ID,
77
+ access: hasAccess ? credential.access : "",
78
+ expires: hasAccess ? credential.expires : 0,
79
+ };
80
+ const temporary = `${authFile}.tmp-${process.pid}`;
81
+ writeFileSync(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
82
+ chmodSync(temporary, 0o600);
83
+ renameSync(temporary, authFile);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
50
89
 
51
- function readAuthFile() {
90
+ function parseAuthorizationCode(input) {
91
+ const text = input.trim();
52
92
  try {
53
- return JSON.parse(readFileSync(AUTH_FILE, "utf8"));
93
+ const url = new URL(text);
94
+ const code = url.searchParams.get("code");
95
+ const state = url.searchParams.get("state");
96
+ if (code && state) return { code, state };
54
97
  } catch {
55
- return null;
98
+ // The callback may be a code-state pair instead of a URL.
56
99
  }
100
+
101
+ const [code, state, ...rest] = text.split("#");
102
+ if (code && state && rest.length === 0) return { code, state };
103
+
104
+ const params = new URLSearchParams(text);
105
+ const queryCode = params.get("code");
106
+ const queryState = params.get("state");
107
+ if (queryCode && queryState) return { code: queryCode, state: queryState };
108
+ throw new Error("Authorization callback must include a code and state");
57
109
  }
58
110
 
59
- function writeAuthEntry(entry) {
60
- const all = readAuthFile() ?? {};
61
- all.anthropic = entry;
62
- const tmp = `${AUTH_FILE}.tmp-${process.pid}`;
63
- writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
64
- chmodSync(tmp, 0o600);
65
- renameSync(tmp, AUTH_FILE);
111
+ function makeAuthorizationUrl(challenge, state) {
112
+ const url = new URL(AUTHORIZE_URL);
113
+ url.searchParams.set("code", "true");
114
+ url.searchParams.set("client_id", CLIENT_ID);
115
+ url.searchParams.set("response_type", "code");
116
+ url.searchParams.set("redirect_uri", CALLBACK_URL);
117
+ url.searchParams.set("scope", AUTH_SCOPE);
118
+ url.searchParams.set("code_challenge", challenge);
119
+ url.searchParams.set("code_challenge_method", "S256");
120
+ url.searchParams.set("state", state);
121
+ return url.toString();
66
122
  }
67
123
 
68
- let refreshPromise = null;
124
+ async function requestTokens(body, action, requireRefreshToken = true) {
125
+ const response = await fetch(TOKEN_URL, {
126
+ method: "POST",
127
+ headers: {
128
+ Accept: "application/json, text/plain, */*",
129
+ "Content-Type": "application/json",
130
+ "User-Agent": TOKEN_USER_AGENT,
131
+ },
132
+ body: JSON.stringify(body),
133
+ });
134
+ if (!response.ok) {
135
+ const detail = await response.text().catch(() => "");
136
+ throw new Error(`${action} failed: ${response.status}${detail ? ` ${detail}` : ""}`);
137
+ }
69
138
 
70
- async function refreshAccessToken(auth) {
71
- let refreshToken = auth.refresh;
72
- const maxRetries = 2;
73
- const baseDelayMs = 500;
74
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
75
- if (attempt > 0) {
76
- await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));
77
- }
78
- const response = await fetch(TOKEN_URL, {
79
- method: "POST",
80
- headers: {
81
- Accept: "application/json, text/plain, */*",
82
- "Content-Type": "application/json",
83
- "User-Agent": TOKEN_USER_AGENT,
84
- },
85
- body: JSON.stringify({
86
- grant_type: "refresh_token",
87
- refresh_token: refreshToken,
88
- client_id: CLIENT_ID,
89
- }),
90
- });
91
- if (!response.ok) {
92
- if (response.status >= 500 && attempt < maxRetries) {
93
- await response.body?.cancel();
94
- continue;
95
- }
96
- // On 401, another process may have already rotated the refresh token.
97
- if (response.status === 401 && attempt < maxRetries) {
98
- const updated = readAuthFile()?.anthropic;
99
- if (isOAuthAuth(updated) && updated.refresh !== refreshToken) {
100
- await response.body?.cancel();
101
- if (updated.access && updated.expires > Date.now()) {
102
- return updated.access;
103
- }
104
- refreshToken = updated.refresh;
105
- continue;
106
- }
107
- }
108
- const body = await response.text().catch(() => "");
109
- throw new Error(`Token refresh failed: ${response.status} — ${body}`);
110
- }
111
- const json = await response.json();
112
- const entry = {
113
- type: "oauth",
114
- refresh: json.refresh_token,
115
- access: json.access_token,
116
- expires: Date.now() + json.expires_in * 1000,
117
- };
118
- writeAuthEntry(entry);
119
- return entry.access;
139
+ const result = await response.json();
140
+ if (
141
+ !isRecord(result) ||
142
+ typeof result.access_token !== "string" ||
143
+ result.access_token.length === 0 ||
144
+ !Number.isSafeInteger(result.expires_in) ||
145
+ result.expires_in <= 0 ||
146
+ (requireRefreshToken &&
147
+ (typeof result.refresh_token !== "string" || result.refresh_token.length === 0)) ||
148
+ (!requireRefreshToken &&
149
+ result.refresh_token != null &&
150
+ (typeof result.refresh_token !== "string" || result.refresh_token.length === 0))
151
+ ) {
152
+ throw new Error(`${action} failed: malformed token response`);
120
153
  }
121
- throw new Error("Token refresh failed: retries exhausted");
154
+ return result;
122
155
  }
123
156
 
124
- async function currentAccessToken() {
125
- const auth = readAuthFile()?.anthropic;
126
- if (!isOAuthAuth(auth)) return null;
127
- if (auth.access && auth.expires > Date.now() + 30_000) return auth.access;
128
- if (!refreshPromise) {
129
- refreshPromise = refreshAccessToken(auth).finally(() => {
130
- refreshPromise = null;
131
- });
132
- }
133
- return refreshPromise;
157
+ function toCredential(tokens, metadata, fallbackRefreshToken) {
158
+ return /** @type {import("@opencode-ai/plugin").Credential.OAuth} */ ({
159
+ type: /** @type {"oauth"} */ ("oauth"),
160
+ methodID: AUTH_METHOD_ID,
161
+ refresh: tokens.refresh_token ?? fallbackRefreshToken,
162
+ access: tokens.access_token,
163
+ expires: Date.now() + tokens.expires_in * 1000,
164
+ ...(metadata == null ? {} : { metadata }),
165
+ });
166
+ }
167
+
168
+ async function authorize() {
169
+ const pkce = await generatePKCE();
170
+ const state = randomBytes(16).toString("hex");
171
+ return {
172
+ url: makeAuthorizationUrl(pkce.challenge, state),
173
+ instructions: "Authorize Claude Pro/Max, then paste the returned code here.",
174
+ mode: /** @type {"code"} */ ("code"),
175
+ async callback(input) {
176
+ const parsed = parseAuthorizationCode(input);
177
+ if (parsed.state !== state) throw new Error("Authorization state does not match");
178
+ const tokens = await requestTokens(
179
+ {
180
+ code: parsed.code,
181
+ state: parsed.state,
182
+ grant_type: "authorization_code",
183
+ client_id: CLIENT_ID,
184
+ redirect_uri: CALLBACK_URL,
185
+ code_verifier: pkce.verifier,
186
+ },
187
+ "Token exchange",
188
+ );
189
+ return toCredential(tokens);
190
+ },
191
+ };
192
+ }
193
+
194
+ async function refreshCredential(
195
+ /** @type {import("@opencode-ai/plugin").Credential.OAuth} */ credential,
196
+ ) {
197
+ const tokens = await requestTokens(
198
+ {
199
+ grant_type: "refresh_token",
200
+ refresh_token: credential.refresh,
201
+ client_id: CLIENT_ID,
202
+ },
203
+ "Token refresh",
204
+ false,
205
+ );
206
+ return toCredential(tokens, credential.metadata, credential.refresh);
207
+ }
208
+
209
+ async function currentCredential(ctx) {
210
+ const connection = await ctx.integration.connection.active("anthropic");
211
+ if (!connection) return null;
212
+ const credential = await ctx.integration.connection.resolve(connection);
213
+ return isPluginOAuthCredential(credential) ? credential : null;
134
214
  }
135
215
 
136
216
  // ---- request rewriting ----
@@ -386,9 +466,24 @@ function isAnthropicApi(url) {
386
466
 
387
467
  // ---- plugin ----
388
468
 
389
- export default {
469
+ /** @type {import("@opencode-ai/plugin").Plugin.Plugin} */
470
+ const plugin = {
390
471
  id: "anthropic-oauth-v2",
391
472
  async setup(ctx) {
473
+ migrateLegacyCredential();
474
+ await ctx.integration.transform((draft) => {
475
+ draft.method.update({
476
+ integrationID: "anthropic",
477
+ method: {
478
+ id: AUTH_METHOD_ID,
479
+ type: "oauth",
480
+ label: "Claude Pro/Max",
481
+ },
482
+ authorize,
483
+ refresh: refreshCredential,
484
+ });
485
+ });
486
+
392
487
  await ctx.session.hook(
393
488
  "http.request",
394
489
  async (event) => {
@@ -400,11 +495,11 @@ export default {
400
495
  }
401
496
  if (!isAnthropicApi(url)) return;
402
497
 
403
- const access = await currentAccessToken();
404
- if (!access) return; // not OAuth (API key user) — leave untouched
498
+ const credential = await currentCredential(ctx);
499
+ if (!credential) return;
405
500
 
406
501
  const headers = new Headers(event.request.headers);
407
- headers.set("authorization", `Bearer ${access}`);
502
+ headers.set("authorization", `Bearer ${credential.access}`);
408
503
  headers.set("anthropic-beta", mergeBetaHeaders(headers));
409
504
  headers.set("user-agent", REQUEST_USER_AGENT);
410
505
  headers.delete("x-api-key");
@@ -423,7 +518,6 @@ export default {
423
518
  method: event.request.method,
424
519
  headers,
425
520
  body,
426
- duplex: "half",
427
521
  signal: event.request.signal,
428
522
  });
429
523
  },
@@ -432,7 +526,8 @@ export default {
432
526
 
433
527
  await ctx.session.hook(
434
528
  "http.response",
435
- (event) => {
529
+ async (event) => {
530
+ if (event.request.headers.get("user-agent") !== REQUEST_USER_AGENT) return;
436
531
  // Rewrite unless the response URL is present and provably non-Anthropic.
437
532
  try {
438
533
  const url = new URL(event.response.url);
@@ -446,3 +541,5 @@ export default {
446
541
  );
447
542
  },
448
543
  };
544
+
545
+ export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "op-anthropic-auth-v2",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode 2 plugin for Anthropic OAuth with Claude Pro/Max",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -36,7 +36,16 @@
36
36
  },
37
37
  "scripts": {
38
38
  "test": "node --test",
39
- "check": "node --check index.js && npm test",
39
+ "typecheck": "tsc",
40
+ "check": "node --check index.js && npm run typecheck && npm test",
40
41
  "pack:dry-run": "npm pack --dry-run"
42
+ },
43
+ "dependencies": {
44
+ "@openauthjs/openauth": "0.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@opencode-ai/plugin": "0.0.0-beta-18743",
48
+ "@types/node": "24.6.1",
49
+ "typescript": "5.9.3"
41
50
  }
42
51
  }