@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 CHANGED
@@ -1,7 +1,10 @@
1
- export declare function authorize(mode: 'max' | 'console'): Promise<{
1
+ type AuthorizationResult = {
2
2
  url: string;
3
- verifier: string;
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(code: string, verifier: string): Promise<ExchangeResult>;
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
- export async function authorize(mode) {
4
- const pkce = await generatePKCE();
5
- const url = new URL(`https://${mode === 'console' ? 'console.anthropic.com' : 'claude.ai'}/oauth/authorize`, import.meta.url);
6
- url.searchParams.set('code', 'true');
7
- url.searchParams.set('client_id', CLIENT_ID);
8
- url.searchParams.set('response_type', 'code');
9
- url.searchParams.set('redirect_uri', 'https://console.anthropic.com/oauth/code/callback');
10
- url.searchParams.set('scope', 'org:create_api_key user:profile user:inference');
11
- url.searchParams.set('code_challenge', pkce.challenge);
12
- url.searchParams.set('code_challenge_method', 'S256');
13
- url.searchParams.set('state', pkce.verifier);
14
- return {
15
- url: url.toString(),
16
- verifier: pkce.verifier,
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
- export async function exchange(code, verifier) {
20
- const splits = code.split('#');
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: splits[0],
28
- state: splits[1],
74
+ code: callback.code,
75
+ state: callback.state,
29
76
  grant_type: 'authorization_code',
30
77
  client_id: CLIENT_ID,
31
- redirect_uri: 'https://console.anthropic.com/oauth/code/callback',
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
+ }
@@ -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, exchange } from './auth';
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 response = await fetch('https://console.anthropic.com/v1/oauth/token', {
38
- method: 'POST',
39
- headers: {
40
- 'Content-Type': 'application/json',
41
- },
42
- body: JSON.stringify({
43
- grant_type: 'refresh_token',
44
- refresh_token: auth.refresh,
45
- client_id: CLIENT_ID,
46
- }),
47
- });
48
- if (!response.ok) {
49
- throw new Error(`Token refresh failed: ${response.status}`);
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 { url, verifier } = await authorize('max');
120
+ const result = await authorize('max');
91
121
  return {
92
- url: url,
93
- instructions: 'Paste the authorization code here: ',
94
- method: 'code',
95
- callback: async (code) => {
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 { url, verifier } = await authorize('console');
133
+ const auth = await authorize('console');
107
134
  return {
108
- url: url,
109
- instructions: 'Paste the authorization code here: ',
110
- method: 'code',
111
- callback: async (code) => {
112
- const credentials = await exchange(code, verifier);
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`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ex-machina/opencode-anthropic-auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [