op-anthropic-auth-v2 0.1.1 → 0.1.3
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 +45 -13
- package/index.js +291 -95
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -1,34 +1,46 @@
|
|
|
1
1
|
# op-anthropic-auth-v2
|
|
2
2
|
|
|
3
|
+
[](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
|
|
|
20
|
-
-
|
|
30
|
+
- registers a `Claude Pro/Max` browser login using OAuth and PKCE
|
|
31
|
+
- lets OpenCode 2 store credentials and refresh expired tokens
|
|
32
|
+
- sets the `claude-cli` user-agent, matching your installed Claude Code version,
|
|
33
|
+
and the required `anthropic-beta` headers
|
|
21
34
|
- adds the `x-anthropic-billing-header` system block
|
|
22
35
|
- prepends the Claude Code identity to the system prompt and relocates remaining
|
|
23
36
|
system text into the first user message
|
|
24
37
|
- 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
38
|
|
|
28
39
|
## Install
|
|
29
40
|
|
|
30
41
|
```sh
|
|
31
42
|
opencode2 plugin add op-anthropic-auth-v2
|
|
43
|
+
opencode2 auth login anthropic --method oauth
|
|
32
44
|
```
|
|
33
45
|
|
|
34
46
|
or add it to `~/.config/opencode/opencode.jsonc`:
|
|
@@ -39,22 +51,42 @@ or add it to `~/.config/opencode/opencode.jsonc`:
|
|
|
39
51
|
}
|
|
40
52
|
```
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
The login command opens Anthropic's authorization page. Complete authorization, then
|
|
55
|
+
paste the returned code into OpenCode 2. Users with a plain API key are unaffected;
|
|
56
|
+
the plugin leaves non-OAuth connections untouched.
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
This adaptation only targets OpenCode 2. OpenCode 1 users should use the original
|
|
59
|
+
[op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) package.
|
|
48
60
|
|
|
49
61
|
## Compatibility
|
|
50
62
|
|
|
51
|
-
Built against `opencode2` `0.0.0-beta-
|
|
63
|
+
Built against `opencode2` `0.0.0-beta-18743`. The v2 plugin API is beta and may
|
|
52
64
|
change; pin accordingly.
|
|
53
65
|
|
|
66
|
+
The plugin reports a Claude Code version to meet Anthropic's model compatibility
|
|
67
|
+
check. It reads that version from your own install rather than pinning one, in
|
|
68
|
+
this order:
|
|
69
|
+
|
|
70
|
+
1. the `CLAUDE_CODE_VERSION` environment variable, if it looks like a version
|
|
71
|
+
2. a native install — it follows the `claude` symlink on your `PATH` and takes the
|
|
72
|
+
version from the target path
|
|
73
|
+
3. an npm install — it reads the version from `@anthropic-ai/claude-code`'s
|
|
74
|
+
`package.json`
|
|
75
|
+
4. `2.1.265`, if no install is found
|
|
76
|
+
|
|
77
|
+
The lookup is cached for 60 seconds, so a Claude Code update takes effect without
|
|
78
|
+
restarting OpenCode. Set `CLAUDE_CODE_VERSION` to report a specific version.
|
|
79
|
+
|
|
80
|
+
The runtime uses `@openauthjs/openauth` for PKCE generation. Development uses the
|
|
81
|
+
matching `@opencode-ai/plugin` beta types, TypeScript, and Node.js types to check the
|
|
82
|
+
published JavaScript against the OpenCode 2 plugin API.
|
|
83
|
+
|
|
54
84
|
## Development
|
|
55
85
|
|
|
56
86
|
```sh
|
|
87
|
+
npm install
|
|
57
88
|
npm test
|
|
89
|
+
npm run typecheck
|
|
58
90
|
npm run check
|
|
59
91
|
npm run pack:dry-run
|
|
60
92
|
```
|
package/index.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
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 {
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
6
13
|
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
14
|
+
import { basename, delimiter, dirname, join } from "node:path";
|
|
15
|
+
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
8
16
|
|
|
9
17
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
18
|
+
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
10
19
|
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
20
|
+
const CALLBACK_URL = "https://platform.claude.com/oauth/code/callback";
|
|
21
|
+
const AUTH_METHOD_ID = "oauth";
|
|
22
|
+
const AUTH_SCOPE =
|
|
23
|
+
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
11
24
|
const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"];
|
|
12
25
|
const TOOL_PREFIX = "mcp_";
|
|
13
26
|
const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
|
|
@@ -20,117 +33,282 @@ const PARAGRAPH_REMOVAL_ANCHORS = [
|
|
|
20
33
|
const TEXT_REPLACEMENTS = [
|
|
21
34
|
{ match: "if OpenCode honestly", replacement: "if the assistant honestly" },
|
|
22
35
|
];
|
|
23
|
-
|
|
36
|
+
// Used only when the local Claude Code install cannot be found.
|
|
37
|
+
const FALLBACK_CLAUDE_CODE_VERSION = "2.1.265";
|
|
24
38
|
const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
|
|
25
39
|
const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
|
26
40
|
const CCH_SALT = "59cf53e54c78";
|
|
27
41
|
const CCH_POSITIONS = [4, 7, 20];
|
|
28
|
-
const
|
|
42
|
+
const USER_AGENT_PREFIX = "claude-cli/";
|
|
29
43
|
const TOKEN_USER_AGENT = "axios/1.13.6";
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
|
|
34
|
-
: join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
44
|
+
const SEMVER = /^\d+\.\d+\.\d+/;
|
|
45
|
+
const VERSION_CACHE_MS = 60_000;
|
|
46
|
+
const NPM_PACKAGE_NAME = "@anthropic-ai/claude-code";
|
|
35
47
|
|
|
36
48
|
function isRecord(value) {
|
|
37
49
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
|
|
52
|
+
// ---- Claude Code version discovery ----
|
|
53
|
+
|
|
54
|
+
// Resolve the `claude` launcher through symlinks. The native installer points
|
|
55
|
+
// ~/.local/bin/claude at ~/.local/share/claude/versions/<version>, so the
|
|
56
|
+
// basename is the active version. Never pick the highest entry in versions/ —
|
|
57
|
+
// a failed download leaves a zero-byte file of a version that cannot run.
|
|
58
|
+
function resolveClaudeBinary() {
|
|
59
|
+
const candidates = [];
|
|
60
|
+
const searchPath = process.env.PATH;
|
|
61
|
+
if (searchPath) {
|
|
62
|
+
for (const dir of searchPath.split(delimiter)) {
|
|
63
|
+
if (dir) candidates.push(join(dir, "claude"));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
candidates.push(join(homedir(), ".local", "bin", "claude"));
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
if (existsSync(candidate)) return realpathSync(candidate);
|
|
70
|
+
} catch {
|
|
71
|
+
// Unreadable entry — keep looking.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// npm installs symlink `claude` to <root>/@anthropic-ai/claude-code/cli.js, so
|
|
78
|
+
// walk up from the resolved file to the package manifest.
|
|
79
|
+
function versionFromNpmPackage(binaryPath) {
|
|
80
|
+
let dir = dirname(binaryPath);
|
|
81
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
82
|
+
try {
|
|
83
|
+
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
84
|
+
if (isRecord(manifest) && manifest.name === NPM_PACKAGE_NAME) {
|
|
85
|
+
const version = manifest.version;
|
|
86
|
+
return typeof version === "string" && SEMVER.test(version) ? version : null;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// No manifest here — keep walking up.
|
|
90
|
+
}
|
|
91
|
+
const parent = dirname(dir);
|
|
92
|
+
if (parent === dir) break;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectClaudeCodeVersion() {
|
|
99
|
+
const override = process.env.CLAUDE_CODE_VERSION;
|
|
100
|
+
if (override && SEMVER.test(override)) return override;
|
|
101
|
+
const binary = resolveClaudeBinary();
|
|
102
|
+
if (!binary) return null;
|
|
103
|
+
const name = basename(binary);
|
|
104
|
+
if (SEMVER.test(name)) return name;
|
|
105
|
+
return versionFromNpmPackage(binary);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let versionCache = { value: "", checkedAt: 0 };
|
|
109
|
+
|
|
110
|
+
// Cached with a TTL rather than resolved once at import: Claude Code updates
|
|
111
|
+
// itself while the OpenCode server stays up for days, and the request hook runs
|
|
112
|
+
// on every call.
|
|
113
|
+
function claudeCodeVersion() {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (versionCache.value && now - versionCache.checkedAt < VERSION_CACHE_MS) {
|
|
116
|
+
return versionCache.value;
|
|
117
|
+
}
|
|
118
|
+
let detected = null;
|
|
119
|
+
try {
|
|
120
|
+
detected = detectClaudeCodeVersion();
|
|
121
|
+
} catch {
|
|
122
|
+
detected = null;
|
|
123
|
+
}
|
|
124
|
+
const value = detected ?? FALLBACK_CLAUDE_CODE_VERSION;
|
|
125
|
+
versionCache = { value, checkedAt: now };
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requestUserAgent() {
|
|
130
|
+
return `${USER_AGENT_PREFIX}${claudeCodeVersion()} (external, cli)`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Match the prefix, not the whole string. The version can change between a
|
|
134
|
+
// request and its response, and an exact match would skip the rewrite.
|
|
135
|
+
function isOwnUserAgent(value) {
|
|
136
|
+
return typeof value === "string" && value.startsWith(USER_AGENT_PREFIX);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isPluginOAuthCredential(value) {
|
|
41
140
|
return (
|
|
42
141
|
isRecord(value) &&
|
|
43
142
|
value.type === "oauth" &&
|
|
143
|
+
value.methodID === AUTH_METHOD_ID &&
|
|
44
144
|
typeof value.refresh === "string" &&
|
|
145
|
+
value.refresh.length > 0 &&
|
|
146
|
+
typeof value.access === "string" &&
|
|
147
|
+
value.access.length > 0 &&
|
|
45
148
|
typeof value.expires === "number"
|
|
46
149
|
);
|
|
47
150
|
}
|
|
48
151
|
|
|
49
|
-
|
|
152
|
+
function migrateLegacyCredential() {
|
|
153
|
+
const authFile =
|
|
154
|
+
process.env.XDG_DATA_HOME != null
|
|
155
|
+
? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
|
|
156
|
+
: join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
157
|
+
try {
|
|
158
|
+
const all = JSON.parse(readFileSync(authFile, "utf8"));
|
|
159
|
+
const credential = all?.anthropic;
|
|
160
|
+
if (
|
|
161
|
+
!isRecord(credential) ||
|
|
162
|
+
credential.type !== "oauth" ||
|
|
163
|
+
credential.methodID != null ||
|
|
164
|
+
typeof credential.refresh !== "string" ||
|
|
165
|
+
credential.refresh.length === 0 ||
|
|
166
|
+
!Number.isSafeInteger(credential.expires)
|
|
167
|
+
) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
50
170
|
|
|
51
|
-
|
|
171
|
+
const hasAccess = typeof credential.access === "string" && credential.access.length > 0;
|
|
172
|
+
all.anthropic = {
|
|
173
|
+
...credential,
|
|
174
|
+
methodID: AUTH_METHOD_ID,
|
|
175
|
+
access: hasAccess ? credential.access : "",
|
|
176
|
+
expires: hasAccess ? credential.expires : 0,
|
|
177
|
+
};
|
|
178
|
+
const temporary = `${authFile}.tmp-${process.pid}`;
|
|
179
|
+
writeFileSync(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
180
|
+
chmodSync(temporary, 0o600);
|
|
181
|
+
renameSync(temporary, authFile);
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseAuthorizationCode(input) {
|
|
189
|
+
const text = input.trim();
|
|
52
190
|
try {
|
|
53
|
-
|
|
191
|
+
const url = new URL(text);
|
|
192
|
+
const code = url.searchParams.get("code");
|
|
193
|
+
const state = url.searchParams.get("state");
|
|
194
|
+
if (code && state) return { code, state };
|
|
54
195
|
} catch {
|
|
55
|
-
|
|
196
|
+
// The callback may be a code-state pair instead of a URL.
|
|
56
197
|
}
|
|
198
|
+
|
|
199
|
+
const [code, state, ...rest] = text.split("#");
|
|
200
|
+
if (code && state && rest.length === 0) return { code, state };
|
|
201
|
+
|
|
202
|
+
const params = new URLSearchParams(text);
|
|
203
|
+
const queryCode = params.get("code");
|
|
204
|
+
const queryState = params.get("state");
|
|
205
|
+
if (queryCode && queryState) return { code: queryCode, state: queryState };
|
|
206
|
+
throw new Error("Authorization callback must include a code and state");
|
|
57
207
|
}
|
|
58
208
|
|
|
59
|
-
function
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
209
|
+
function makeAuthorizationUrl(challenge, state) {
|
|
210
|
+
const url = new URL(AUTHORIZE_URL);
|
|
211
|
+
url.searchParams.set("code", "true");
|
|
212
|
+
url.searchParams.set("client_id", CLIENT_ID);
|
|
213
|
+
url.searchParams.set("response_type", "code");
|
|
214
|
+
url.searchParams.set("redirect_uri", CALLBACK_URL);
|
|
215
|
+
url.searchParams.set("scope", AUTH_SCOPE);
|
|
216
|
+
url.searchParams.set("code_challenge", challenge);
|
|
217
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
218
|
+
url.searchParams.set("state", state);
|
|
219
|
+
return url.toString();
|
|
66
220
|
}
|
|
67
221
|
|
|
68
|
-
|
|
222
|
+
async function requestTokens(body, action, requireRefreshToken = true) {
|
|
223
|
+
const response = await fetch(TOKEN_URL, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: {
|
|
226
|
+
Accept: "application/json, text/plain, */*",
|
|
227
|
+
"Content-Type": "application/json",
|
|
228
|
+
"User-Agent": TOKEN_USER_AGENT,
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify(body),
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
const detail = await response.text().catch(() => "");
|
|
234
|
+
throw new Error(`${action} failed: ${response.status}${detail ? ` ${detail}` : ""}`);
|
|
235
|
+
}
|
|
69
236
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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;
|
|
237
|
+
const result = await response.json();
|
|
238
|
+
if (
|
|
239
|
+
!isRecord(result) ||
|
|
240
|
+
typeof result.access_token !== "string" ||
|
|
241
|
+
result.access_token.length === 0 ||
|
|
242
|
+
!Number.isSafeInteger(result.expires_in) ||
|
|
243
|
+
result.expires_in <= 0 ||
|
|
244
|
+
(requireRefreshToken &&
|
|
245
|
+
(typeof result.refresh_token !== "string" || result.refresh_token.length === 0)) ||
|
|
246
|
+
(!requireRefreshToken &&
|
|
247
|
+
result.refresh_token != null &&
|
|
248
|
+
(typeof result.refresh_token !== "string" || result.refresh_token.length === 0))
|
|
249
|
+
) {
|
|
250
|
+
throw new Error(`${action} failed: malformed token response`);
|
|
120
251
|
}
|
|
121
|
-
|
|
252
|
+
return result;
|
|
122
253
|
}
|
|
123
254
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
|
|
255
|
+
function toCredential(tokens, metadata, fallbackRefreshToken) {
|
|
256
|
+
return /** @type {import("@opencode-ai/plugin").Credential.OAuth} */ ({
|
|
257
|
+
type: /** @type {"oauth"} */ ("oauth"),
|
|
258
|
+
methodID: AUTH_METHOD_ID,
|
|
259
|
+
refresh: tokens.refresh_token ?? fallbackRefreshToken,
|
|
260
|
+
access: tokens.access_token,
|
|
261
|
+
expires: Date.now() + tokens.expires_in * 1000,
|
|
262
|
+
...(metadata == null ? {} : { metadata }),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function authorize() {
|
|
267
|
+
const pkce = await generatePKCE();
|
|
268
|
+
const state = randomBytes(16).toString("hex");
|
|
269
|
+
return {
|
|
270
|
+
url: makeAuthorizationUrl(pkce.challenge, state),
|
|
271
|
+
instructions: "Authorize Claude Pro/Max, then paste the returned code here.",
|
|
272
|
+
mode: /** @type {"code"} */ ("code"),
|
|
273
|
+
async callback(input) {
|
|
274
|
+
const parsed = parseAuthorizationCode(input);
|
|
275
|
+
if (parsed.state !== state) throw new Error("Authorization state does not match");
|
|
276
|
+
const tokens = await requestTokens(
|
|
277
|
+
{
|
|
278
|
+
code: parsed.code,
|
|
279
|
+
state: parsed.state,
|
|
280
|
+
grant_type: "authorization_code",
|
|
281
|
+
client_id: CLIENT_ID,
|
|
282
|
+
redirect_uri: CALLBACK_URL,
|
|
283
|
+
code_verifier: pkce.verifier,
|
|
284
|
+
},
|
|
285
|
+
"Token exchange",
|
|
286
|
+
);
|
|
287
|
+
return toCredential(tokens);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function refreshCredential(
|
|
293
|
+
/** @type {import("@opencode-ai/plugin").Credential.OAuth} */ credential,
|
|
294
|
+
) {
|
|
295
|
+
const tokens = await requestTokens(
|
|
296
|
+
{
|
|
297
|
+
grant_type: "refresh_token",
|
|
298
|
+
refresh_token: credential.refresh,
|
|
299
|
+
client_id: CLIENT_ID,
|
|
300
|
+
},
|
|
301
|
+
"Token refresh",
|
|
302
|
+
false,
|
|
303
|
+
);
|
|
304
|
+
return toCredential(tokens, credential.metadata, credential.refresh);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function currentCredential(ctx) {
|
|
308
|
+
const connection = await ctx.integration.connection.active("anthropic");
|
|
309
|
+
if (!connection) return null;
|
|
310
|
+
const credential = await ctx.integration.connection.resolve(connection);
|
|
311
|
+
return isPluginOAuthCredential(credential) ? credential : null;
|
|
134
312
|
}
|
|
135
313
|
|
|
136
314
|
// ---- request rewriting ----
|
|
@@ -202,21 +380,22 @@ function computeCCH(messageText) {
|
|
|
202
380
|
return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
|
|
203
381
|
}
|
|
204
382
|
|
|
205
|
-
function computeVersionSuffix(messageText) {
|
|
383
|
+
function computeVersionSuffix(messageText, version) {
|
|
206
384
|
const chars = CCH_POSITIONS.map((index) => messageText[index] || "0").join("");
|
|
207
385
|
return createHash("sha256")
|
|
208
|
-
.update(`${CCH_SALT}${chars}${
|
|
386
|
+
.update(`${CCH_SALT}${chars}${version}`)
|
|
209
387
|
.digest("hex")
|
|
210
388
|
.slice(0, 3);
|
|
211
389
|
}
|
|
212
390
|
|
|
213
391
|
function buildBillingHeaderValue(messages) {
|
|
214
392
|
const text = extractFirstUserMessageText(messages);
|
|
215
|
-
const
|
|
393
|
+
const version = claudeCodeVersion();
|
|
394
|
+
const suffix = computeVersionSuffix(text, version);
|
|
216
395
|
const cch = computeCCH(text);
|
|
217
396
|
return (
|
|
218
397
|
`${BILLING_HEADER_PREFIX} ` +
|
|
219
|
-
`cc_version=${
|
|
398
|
+
`cc_version=${version}.${suffix}; ` +
|
|
220
399
|
`cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; ` +
|
|
221
400
|
`cch=${cch};`
|
|
222
401
|
);
|
|
@@ -386,9 +565,24 @@ function isAnthropicApi(url) {
|
|
|
386
565
|
|
|
387
566
|
// ---- plugin ----
|
|
388
567
|
|
|
389
|
-
|
|
568
|
+
/** @type {import("@opencode-ai/plugin").Plugin.Plugin} */
|
|
569
|
+
const plugin = {
|
|
390
570
|
id: "anthropic-oauth-v2",
|
|
391
571
|
async setup(ctx) {
|
|
572
|
+
migrateLegacyCredential();
|
|
573
|
+
await ctx.integration.transform((draft) => {
|
|
574
|
+
draft.method.update({
|
|
575
|
+
integrationID: "anthropic",
|
|
576
|
+
method: {
|
|
577
|
+
id: AUTH_METHOD_ID,
|
|
578
|
+
type: "oauth",
|
|
579
|
+
label: "Claude Pro/Max",
|
|
580
|
+
},
|
|
581
|
+
authorize,
|
|
582
|
+
refresh: refreshCredential,
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
392
586
|
await ctx.session.hook(
|
|
393
587
|
"http.request",
|
|
394
588
|
async (event) => {
|
|
@@ -400,13 +594,13 @@ export default {
|
|
|
400
594
|
}
|
|
401
595
|
if (!isAnthropicApi(url)) return;
|
|
402
596
|
|
|
403
|
-
const
|
|
404
|
-
if (!
|
|
597
|
+
const credential = await currentCredential(ctx);
|
|
598
|
+
if (!credential) return;
|
|
405
599
|
|
|
406
600
|
const headers = new Headers(event.request.headers);
|
|
407
|
-
headers.set("authorization", `Bearer ${access}`);
|
|
601
|
+
headers.set("authorization", `Bearer ${credential.access}`);
|
|
408
602
|
headers.set("anthropic-beta", mergeBetaHeaders(headers));
|
|
409
|
-
headers.set("user-agent",
|
|
603
|
+
headers.set("user-agent", requestUserAgent());
|
|
410
604
|
headers.delete("x-api-key");
|
|
411
605
|
|
|
412
606
|
if (url.pathname === "/v1/messages" && !url.searchParams.has("beta")) {
|
|
@@ -423,7 +617,6 @@ export default {
|
|
|
423
617
|
method: event.request.method,
|
|
424
618
|
headers,
|
|
425
619
|
body,
|
|
426
|
-
duplex: "half",
|
|
427
620
|
signal: event.request.signal,
|
|
428
621
|
});
|
|
429
622
|
},
|
|
@@ -432,7 +625,8 @@ export default {
|
|
|
432
625
|
|
|
433
626
|
await ctx.session.hook(
|
|
434
627
|
"http.response",
|
|
435
|
-
(event) => {
|
|
628
|
+
async (event) => {
|
|
629
|
+
if (!isOwnUserAgent(event.request.headers.get("user-agent"))) return;
|
|
436
630
|
// Rewrite unless the response URL is present and provably non-Anthropic.
|
|
437
631
|
try {
|
|
438
632
|
const url = new URL(event.response.url);
|
|
@@ -446,3 +640,5 @@ export default {
|
|
|
446
640
|
);
|
|
447
641
|
},
|
|
448
642
|
};
|
|
643
|
+
|
|
644
|
+
export default plugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "op-anthropic-auth-v2",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
-
"
|
|
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
|
}
|