@ex-machina/opencode-anthropic-auth 0.1.0 → 0.2.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/dist/auth.d.ts +8 -4
- package/dist/auth.js +177 -22
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +13 -0
- package/dist/index.js +70 -43
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
type AuthorizationResult = {
|
|
2
2
|
url: string;
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
redirectUri: string;
|
|
4
|
+
state: string;
|
|
5
|
+
callback: () => Promise<ExchangeResult>;
|
|
6
|
+
};
|
|
7
|
+
export declare function authorize(mode: 'max' | 'console'): Promise<AuthorizationResult>;
|
|
5
8
|
export type ExchangeResult = {
|
|
6
9
|
type: 'success';
|
|
7
10
|
refresh: string;
|
|
@@ -10,4 +13,5 @@ export type ExchangeResult = {
|
|
|
10
13
|
} | {
|
|
11
14
|
type: 'failed';
|
|
12
15
|
};
|
|
13
|
-
export declare function exchange(
|
|
16
|
+
export declare function exchange(input: string, verifier: string, redirectUri: string, expectedState?: string): Promise<ExchangeResult>;
|
|
17
|
+
export {};
|
package/dist/auth.js
CHANGED
|
@@ -1,34 +1,81 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
1
2
|
import { generatePKCE } from '@openauthjs/openauth/pkce';
|
|
2
|
-
import { CLIENT_ID } from './constants';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
3
|
+
import { AUTHORIZE_URLS, CLIENT_ID, OAUTH_SCOPES, TOKEN_URL } from './constants';
|
|
4
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
5
|
+
async function listen(server) {
|
|
6
|
+
await new Promise((resolve, reject) => {
|
|
7
|
+
server.once('error', reject);
|
|
8
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
async function close(server) {
|
|
12
|
+
if (!server.listening)
|
|
13
|
+
return;
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
server.close((error) => {
|
|
16
|
+
if (error) {
|
|
17
|
+
reject(error);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
resolve();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function generateState() {
|
|
25
|
+
return crypto.randomUUID().replace(/-/g, '');
|
|
26
|
+
}
|
|
27
|
+
function parseCallbackInput(input) {
|
|
28
|
+
const trimmed = input.trim();
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(trimmed);
|
|
31
|
+
const code = url.searchParams.get('code');
|
|
32
|
+
const state = url.searchParams.get('state');
|
|
33
|
+
if (code && state) {
|
|
34
|
+
return { code, state };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Fall through to legacy/manual formats.
|
|
39
|
+
}
|
|
40
|
+
const hashSplits = trimmed.split('#');
|
|
41
|
+
if (hashSplits.length === 2 && hashSplits[0] && hashSplits[1]) {
|
|
42
|
+
return { code: hashSplits[0], state: hashSplits[1] };
|
|
43
|
+
}
|
|
44
|
+
const params = new URLSearchParams(trimmed);
|
|
45
|
+
const code = params.get('code');
|
|
46
|
+
const state = params.get('state');
|
|
47
|
+
if (code && state) {
|
|
48
|
+
return { code, state };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function successPage() {
|
|
53
|
+
return `<!doctype html>
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="utf-8" />
|
|
57
|
+
<title>Authorization complete</title>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<h1>Authorization complete</h1>
|
|
61
|
+
<p>You can close this window and return to OpenCode.</p>
|
|
62
|
+
</body>
|
|
63
|
+
</html>`;
|
|
18
64
|
}
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const result = await fetch('https://console.anthropic.com/v1/oauth/token', {
|
|
65
|
+
async function exchangeCode(callback, verifier, redirectUri) {
|
|
66
|
+
const result = await fetch(TOKEN_URL, {
|
|
22
67
|
method: 'POST',
|
|
23
68
|
headers: {
|
|
24
69
|
'Content-Type': 'application/json',
|
|
70
|
+
Accept: 'application/json, text/plain, */*',
|
|
71
|
+
'User-Agent': 'axios/1.13.6',
|
|
25
72
|
},
|
|
26
73
|
body: JSON.stringify({
|
|
27
|
-
code:
|
|
28
|
-
state:
|
|
74
|
+
code: callback.code,
|
|
75
|
+
state: callback.state,
|
|
29
76
|
grant_type: 'authorization_code',
|
|
30
77
|
client_id: CLIENT_ID,
|
|
31
|
-
redirect_uri:
|
|
78
|
+
redirect_uri: redirectUri,
|
|
32
79
|
code_verifier: verifier,
|
|
33
80
|
}),
|
|
34
81
|
});
|
|
@@ -45,3 +92,111 @@ export async function exchange(code, verifier) {
|
|
|
45
92
|
expires: Date.now() + json.expires_in * 1000,
|
|
46
93
|
};
|
|
47
94
|
}
|
|
95
|
+
async function createCallbackServer(expectedState) {
|
|
96
|
+
let settled = false;
|
|
97
|
+
let cleanupTimer;
|
|
98
|
+
let resolveResult;
|
|
99
|
+
let rejectResult;
|
|
100
|
+
const server = createServer((req, res) => {
|
|
101
|
+
const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
102
|
+
if (requestUrl.pathname !== '/callback') {
|
|
103
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
104
|
+
res.end('Not found');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const code = requestUrl.searchParams.get('code');
|
|
108
|
+
const state = requestUrl.searchParams.get('state');
|
|
109
|
+
if (!code || !state) {
|
|
110
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
111
|
+
res.end('Missing code or state');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (state !== expectedState) {
|
|
115
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
116
|
+
res.end('Invalid state');
|
|
117
|
+
if (!settled) {
|
|
118
|
+
settled = true;
|
|
119
|
+
rejectResult?.(new Error('OAuth state mismatch'));
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
124
|
+
res.end(successPage());
|
|
125
|
+
if (!settled) {
|
|
126
|
+
settled = true;
|
|
127
|
+
resolveResult?.(requestUrl.toString());
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const callbackUrl = new Promise((resolve, reject) => {
|
|
131
|
+
resolveResult = resolve;
|
|
132
|
+
rejectResult = reject;
|
|
133
|
+
});
|
|
134
|
+
await listen(server);
|
|
135
|
+
cleanupTimer = setTimeout(() => {
|
|
136
|
+
if (settled)
|
|
137
|
+
return;
|
|
138
|
+
settled = true;
|
|
139
|
+
rejectResult?.(new Error('Timed out waiting for OAuth callback'));
|
|
140
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
141
|
+
const address = server.address();
|
|
142
|
+
if (!address || typeof address === 'string') {
|
|
143
|
+
clearTimeout(cleanupTimer);
|
|
144
|
+
await close(server);
|
|
145
|
+
throw new Error('Failed to allocate localhost redirect port');
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
redirectUri: `http://localhost:${address.port}/callback`,
|
|
149
|
+
waitForCallback: async () => {
|
|
150
|
+
try {
|
|
151
|
+
return await callbackUrl;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (cleanupTimer)
|
|
155
|
+
clearTimeout(cleanupTimer);
|
|
156
|
+
await close(server);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export async function authorize(mode) {
|
|
162
|
+
const pkce = await generatePKCE();
|
|
163
|
+
const state = generateState();
|
|
164
|
+
const callbackServer = await createCallbackServer(state);
|
|
165
|
+
const url = new URL(AUTHORIZE_URLS[mode], import.meta.url);
|
|
166
|
+
url.searchParams.set('code', 'true');
|
|
167
|
+
url.searchParams.set('client_id', CLIENT_ID);
|
|
168
|
+
url.searchParams.set('response_type', 'code');
|
|
169
|
+
url.searchParams.set('redirect_uri', callbackServer.redirectUri);
|
|
170
|
+
url.searchParams.set('scope', OAUTH_SCOPES.join(' '));
|
|
171
|
+
url.searchParams.set('code_challenge', pkce.challenge);
|
|
172
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
173
|
+
url.searchParams.set('state', state);
|
|
174
|
+
return {
|
|
175
|
+
url: url.toString(),
|
|
176
|
+
redirectUri: callbackServer.redirectUri,
|
|
177
|
+
state,
|
|
178
|
+
callback: async () => {
|
|
179
|
+
try {
|
|
180
|
+
const callbackUrl = await callbackServer.waitForCallback();
|
|
181
|
+
return await exchange(callbackUrl, pkce.verifier, callbackServer.redirectUri, state);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return { type: 'failed' };
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export async function exchange(input, verifier, redirectUri, expectedState) {
|
|
190
|
+
const callback = parseCallbackInput(input);
|
|
191
|
+
if (!callback) {
|
|
192
|
+
return {
|
|
193
|
+
type: 'failed',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (expectedState && callback.state !== expectedState) {
|
|
197
|
+
return {
|
|
198
|
+
type: 'failed',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return exchangeCode(callback, verifier, redirectUri);
|
|
202
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export declare const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
2
|
+
export declare const AUTHORIZE_URLS: {
|
|
3
|
+
readonly console: "https://platform.claude.com/oauth/authorize";
|
|
4
|
+
readonly max: "https://claude.ai/oauth/authorize";
|
|
5
|
+
};
|
|
6
|
+
export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
7
|
+
export declare const OAUTH_SCOPES: string[];
|
|
2
8
|
export declare const TOOL_PREFIX = "mcp_";
|
|
3
9
|
export declare const REQUIRED_BETAS: string[];
|
package/dist/constants.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
export const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
2
|
+
export const AUTHORIZE_URLS = {
|
|
3
|
+
console: 'https://platform.claude.com/oauth/authorize',
|
|
4
|
+
max: 'https://claude.ai/oauth/authorize',
|
|
5
|
+
};
|
|
6
|
+
export const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
7
|
+
export const OAUTH_SCOPES = [
|
|
8
|
+
'org:create_api_key',
|
|
9
|
+
'user:profile',
|
|
10
|
+
'user:inference',
|
|
11
|
+
'user:sessions:claude_code',
|
|
12
|
+
'user:mcp_servers',
|
|
13
|
+
'user:file_upload',
|
|
14
|
+
];
|
|
2
15
|
export const TOOL_PREFIX = 'mcp_';
|
|
3
16
|
export const REQUIRED_BETAS = [
|
|
4
17
|
'oauth-2025-04-20',
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { authorize
|
|
2
|
-
import { CLIENT_ID } from './constants';
|
|
1
|
+
import { authorize } from './auth';
|
|
2
|
+
import { CLIENT_ID, TOKEN_URL } from './constants';
|
|
3
3
|
import { createStrippedStream, mergeHeaders, prefixToolNames, rewriteUrl, setOAuthHeaders, } from './transform';
|
|
4
4
|
export const AnthropicAuthPlugin = async ({ client }) => {
|
|
5
5
|
return {
|
|
@@ -34,34 +34,64 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
34
34
|
if (auth.type !== 'oauth')
|
|
35
35
|
return fetch(input, init);
|
|
36
36
|
if (!auth.access || !auth.expires || auth.expires < Date.now()) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
const maxRetries = 2;
|
|
38
|
+
const baseDelayMs = 500;
|
|
39
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
|
+
try {
|
|
41
|
+
if (attempt > 0) {
|
|
42
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
44
|
+
}
|
|
45
|
+
const response = await fetch(TOKEN_URL, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
Accept: 'application/json, text/plain, */*',
|
|
50
|
+
'User-Agent': 'axios/1.13.6',
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
grant_type: 'refresh_token',
|
|
54
|
+
refresh_token: auth.refresh,
|
|
55
|
+
client_id: CLIENT_ID,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
60
|
+
await response.body?.cancel();
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
64
|
+
}
|
|
65
|
+
const json = (await response.json());
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
|
|
67
|
+
await client.auth.set({
|
|
68
|
+
path: {
|
|
69
|
+
id: 'anthropic',
|
|
70
|
+
},
|
|
71
|
+
body: {
|
|
72
|
+
type: 'oauth',
|
|
73
|
+
refresh: json.refresh_token,
|
|
74
|
+
access: json.access_token,
|
|
75
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
auth.access = json.access_token;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const isNetworkError = error instanceof Error &&
|
|
83
|
+
(error.message.includes('fetch failed') ||
|
|
84
|
+
('code' in error &&
|
|
85
|
+
(error.code === 'ECONNRESET' ||
|
|
86
|
+
error.code === 'ECONNREFUSED' ||
|
|
87
|
+
error.code === 'ETIMEDOUT' ||
|
|
88
|
+
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
89
|
+
if (attempt < maxRetries && isNetworkError) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
50
94
|
}
|
|
51
|
-
const json = (await response.json());
|
|
52
|
-
// biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
|
|
53
|
-
await client.auth.set({
|
|
54
|
-
path: {
|
|
55
|
-
id: 'anthropic',
|
|
56
|
-
},
|
|
57
|
-
body: {
|
|
58
|
-
type: 'oauth',
|
|
59
|
-
refresh: json.refresh_token,
|
|
60
|
-
access: json.access_token,
|
|
61
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
auth.access = json.access_token;
|
|
65
95
|
}
|
|
66
96
|
const requestHeaders = mergeHeaders(input, init);
|
|
67
97
|
// biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
|
|
@@ -87,15 +117,12 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
87
117
|
label: 'Claude Pro/Max',
|
|
88
118
|
type: 'oauth',
|
|
89
119
|
authorize: async () => {
|
|
90
|
-
const
|
|
120
|
+
const result = await authorize('max');
|
|
91
121
|
return {
|
|
92
|
-
url: url,
|
|
93
|
-
instructions: '
|
|
94
|
-
method: '
|
|
95
|
-
callback:
|
|
96
|
-
const credentials = await exchange(code, verifier);
|
|
97
|
-
return credentials;
|
|
98
|
-
},
|
|
122
|
+
url: result.url,
|
|
123
|
+
instructions: 'Complete authorization in the browser.',
|
|
124
|
+
method: 'auto',
|
|
125
|
+
callback: result.callback,
|
|
99
126
|
};
|
|
100
127
|
},
|
|
101
128
|
},
|
|
@@ -103,13 +130,13 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
103
130
|
label: 'Create an API Key',
|
|
104
131
|
type: 'oauth',
|
|
105
132
|
authorize: async () => {
|
|
106
|
-
const
|
|
133
|
+
const auth = await authorize('console');
|
|
107
134
|
return {
|
|
108
|
-
url: url,
|
|
109
|
-
instructions: '
|
|
110
|
-
method: '
|
|
111
|
-
callback: async (
|
|
112
|
-
const credentials = await
|
|
135
|
+
url: auth.url,
|
|
136
|
+
instructions: 'Complete authorization in the browser.',
|
|
137
|
+
method: 'auto',
|
|
138
|
+
callback: async () => {
|
|
139
|
+
const credentials = await auth.callback();
|
|
113
140
|
if (credentials.type === 'failed')
|
|
114
141
|
return credentials;
|
|
115
142
|
const result = await fetch(`https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, {
|