@ex-machina/opencode-anthropic-auth 1.1.2 → 1.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/constants.d.ts +3 -0
- package/dist/constants.js +7 -0
- package/dist/index.js +2 -10
- package/dist/tests/auth.test.d.ts +1 -0
- package/dist/tests/auth.test.js +87 -0
- package/dist/tests/index.test.d.ts +1 -0
- package/dist/tests/index.test.js +481 -0
- package/dist/tests/pkce.test.d.ts +1 -0
- package/dist/tests/pkce.test.js +111 -0
- package/dist/tests/transform.test.d.ts +1 -0
- package/dist/tests/transform.test.js +621 -0
- package/dist/transform.d.ts +22 -0
- package/dist/transform.js +112 -1
- package/package.json +1 -1
package/dist/constants.d.ts
CHANGED
|
@@ -8,4 +8,7 @@ export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
|
8
8
|
export declare const OAUTH_SCOPES: string[];
|
|
9
9
|
export declare const TOOL_PREFIX = "mcp_";
|
|
10
10
|
export declare const REQUIRED_BETAS: string[];
|
|
11
|
+
export declare const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
|
|
12
|
+
export declare const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
11
13
|
export declare const USER_AGENT = "claude-cli/2.1.2 (external, cli)";
|
|
14
|
+
export declare const PRESERVED_TAIL_MARKERS: string[];
|
package/dist/constants.js
CHANGED
|
@@ -18,4 +18,11 @@ export const REQUIRED_BETAS = [
|
|
|
18
18
|
'oauth-2025-04-20',
|
|
19
19
|
'interleaved-thinking-2025-05-14',
|
|
20
20
|
];
|
|
21
|
+
export const OPENCODE_IDENTITY = 'You are OpenCode, the best coding agent on the planet.';
|
|
22
|
+
export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
21
23
|
export const USER_AGENT = 'claude-cli/2.1.2 (external, cli)';
|
|
24
|
+
export const PRESERVED_TAIL_MARKERS = [
|
|
25
|
+
'\nInstructions from: ',
|
|
26
|
+
'\nInstructions from command: ',
|
|
27
|
+
'# Code References',
|
|
28
|
+
];
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import { authorize, exchange } from './auth';
|
|
2
2
|
import { CLIENT_ID, TOKEN_URL } from './constants';
|
|
3
|
-
import { createStrippedStream, isInsecure, mergeHeaders,
|
|
3
|
+
import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from './transform';
|
|
4
4
|
export const AnthropicAuthPlugin = async ({ client }) => {
|
|
5
5
|
return {
|
|
6
|
-
'experimental.chat.system.transform': (input, output) => {
|
|
7
|
-
const prefix = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
8
|
-
if (input.model?.providerID === 'anthropic') {
|
|
9
|
-
output.system.unshift(prefix);
|
|
10
|
-
if (output.system[1])
|
|
11
|
-
output.system[1] = `${prefix}\n\n${output.system[1]}`;
|
|
12
|
-
}
|
|
13
|
-
},
|
|
14
6
|
auth: {
|
|
15
7
|
provider: 'anthropic',
|
|
16
8
|
async loader(getAuth, provider) {
|
|
@@ -111,7 +103,7 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
111
103
|
setOAuthHeaders(requestHeaders, auth.access);
|
|
112
104
|
let body = init?.body;
|
|
113
105
|
if (body && typeof body === 'string') {
|
|
114
|
-
body =
|
|
106
|
+
body = rewriteRequestBody(body);
|
|
115
107
|
}
|
|
116
108
|
const rewritten = rewriteUrl(input);
|
|
117
109
|
const response = await fetch(rewritten.input, {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
|
2
|
+
import { authorize, exchange } from '../auth';
|
|
3
|
+
import { CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES } from '../constants';
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
mock.restore();
|
|
6
|
+
});
|
|
7
|
+
describe('authorize', () => {
|
|
8
|
+
test('returns the hosted callback URL for max mode', async () => {
|
|
9
|
+
const result = await authorize('max');
|
|
10
|
+
expect(result.url).toBeString();
|
|
11
|
+
expect(result.redirectUri).toBe(CODE_CALLBACK_URL);
|
|
12
|
+
expect(result.verifier).toBeString();
|
|
13
|
+
const url = new URL(result.url);
|
|
14
|
+
expect(url.origin).toBe('https://claude.ai');
|
|
15
|
+
expect(url.pathname).toBe('/oauth/authorize');
|
|
16
|
+
expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
|
|
17
|
+
});
|
|
18
|
+
test('returns the hosted callback URL for console mode', async () => {
|
|
19
|
+
const result = await authorize('console');
|
|
20
|
+
const url = new URL(result.url);
|
|
21
|
+
expect(url.origin).toBe('https://platform.claude.com');
|
|
22
|
+
expect(url.pathname).toBe('/oauth/authorize');
|
|
23
|
+
expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
|
|
24
|
+
});
|
|
25
|
+
test('sets required OAuth query params', async () => {
|
|
26
|
+
const result = await authorize('max');
|
|
27
|
+
const url = new URL(result.url);
|
|
28
|
+
expect(url.searchParams.get('code')).toBe('true');
|
|
29
|
+
expect(url.searchParams.get('client_id')).toBe(CLIENT_ID);
|
|
30
|
+
expect(url.searchParams.get('response_type')).toBe('code');
|
|
31
|
+
expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
|
|
32
|
+
expect(url.searchParams.get('scope')).toBe(OAUTH_SCOPES.join(' '));
|
|
33
|
+
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
|
34
|
+
expect(url.searchParams.get('state')).toBe(result.state);
|
|
35
|
+
});
|
|
36
|
+
test('does not use localhost', async () => {
|
|
37
|
+
const result = await authorize('max');
|
|
38
|
+
expect(result.redirectUri).not.toContain('localhost');
|
|
39
|
+
expect(result.url).not.toContain('localhost');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('exchange', () => {
|
|
43
|
+
test('accepts code#state format', async () => {
|
|
44
|
+
let capturedBody;
|
|
45
|
+
spyOn(globalThis, 'fetch').mockImplementation(((_input, init) => {
|
|
46
|
+
capturedBody = init?.body;
|
|
47
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
48
|
+
refresh_token: 'r',
|
|
49
|
+
access_token: 'a',
|
|
50
|
+
expires_in: 3600,
|
|
51
|
+
}), { status: 200 }));
|
|
52
|
+
}));
|
|
53
|
+
const result = await exchange('mycode#mystate', 'myverifier', CODE_CALLBACK_URL, 'mystate');
|
|
54
|
+
expect(result.type).toBe('success');
|
|
55
|
+
const body = JSON.parse(capturedBody);
|
|
56
|
+
expect(body.code).toBe('mycode');
|
|
57
|
+
expect(body.state).toBe('mystate');
|
|
58
|
+
expect(body.redirect_uri).toBe(CODE_CALLBACK_URL);
|
|
59
|
+
});
|
|
60
|
+
test('accepts a full callback URL', async () => {
|
|
61
|
+
let capturedBody;
|
|
62
|
+
spyOn(globalThis, 'fetch').mockImplementation(((_input, init) => {
|
|
63
|
+
capturedBody = init?.body;
|
|
64
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
65
|
+
refresh_token: 'r',
|
|
66
|
+
access_token: 'a',
|
|
67
|
+
expires_in: 3600,
|
|
68
|
+
}), { status: 200 }));
|
|
69
|
+
}));
|
|
70
|
+
await exchange('https://platform.claude.com/oauth/code/callback?code=mycode&state=mystate', 'myverifier', CODE_CALLBACK_URL, 'mystate');
|
|
71
|
+
const body = JSON.parse(capturedBody);
|
|
72
|
+
expect(body.code).toBe('mycode');
|
|
73
|
+
expect(body.state).toBe('mystate');
|
|
74
|
+
});
|
|
75
|
+
test('returns failed on invalid callback input', async () => {
|
|
76
|
+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => Promise.resolve(new Response(null))));
|
|
77
|
+
const result = await exchange('not-a-callback', 'verifier', CODE_CALLBACK_URL);
|
|
78
|
+
expect(result.type).toBe('failed');
|
|
79
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
test('returns failed on state mismatch', async () => {
|
|
82
|
+
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => Promise.resolve(new Response(null))));
|
|
83
|
+
const result = await exchange('code#wrong', 'verifier', CODE_CALLBACK_URL, 'expected');
|
|
84
|
+
expect(result.type).toBe('failed');
|
|
85
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { AnthropicAuthPlugin } from '../index';
|
|
3
|
+
// Minimal mock of the OpenCode plugin client
|
|
4
|
+
function createMockClient() {
|
|
5
|
+
return {
|
|
6
|
+
auth: {
|
|
7
|
+
set: mock(() => Promise.resolve()),
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
async function getPlugin(client) {
|
|
12
|
+
return AnthropicAuthPlugin({
|
|
13
|
+
// @ts-expect-error: minimal mock for testing
|
|
14
|
+
client: client ?? createMockClient(),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
describe('AnthropicAuthPlugin', () => {
|
|
18
|
+
test('returns an object with auth properties', async () => {
|
|
19
|
+
const plugin = await getPlugin();
|
|
20
|
+
expect(plugin.auth).toBeDefined();
|
|
21
|
+
expect(plugin.auth.provider).toBe('anthropic');
|
|
22
|
+
expect(plugin.auth.loader).toBeFunction();
|
|
23
|
+
expect(plugin.auth.methods).toBeArray();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('auth.methods', () => {
|
|
27
|
+
test('has three auth methods', async () => {
|
|
28
|
+
const plugin = await getPlugin();
|
|
29
|
+
expect(plugin.auth.methods).toHaveLength(3);
|
|
30
|
+
});
|
|
31
|
+
test('first method is Claude Pro/Max OAuth with code flow', async () => {
|
|
32
|
+
const plugin = await getPlugin();
|
|
33
|
+
const method = plugin.auth.methods[0];
|
|
34
|
+
expect(method.label).toBe('Claude Pro/Max');
|
|
35
|
+
expect(method.type).toBe('oauth');
|
|
36
|
+
expect(method.authorize).toBeFunction();
|
|
37
|
+
});
|
|
38
|
+
test('second method is Create an API Key OAuth with code flow', async () => {
|
|
39
|
+
const plugin = await getPlugin();
|
|
40
|
+
const method = plugin.auth.methods[1];
|
|
41
|
+
expect(method.label).toBe('Create an API Key');
|
|
42
|
+
expect(method.type).toBe('oauth');
|
|
43
|
+
expect(method.authorize).toBeFunction();
|
|
44
|
+
});
|
|
45
|
+
test('third method is manual API key', async () => {
|
|
46
|
+
const plugin = await getPlugin();
|
|
47
|
+
const method = plugin.auth.methods[2];
|
|
48
|
+
expect(method.label).toBe('Manually enter API Key');
|
|
49
|
+
expect(method.type).toBe('api');
|
|
50
|
+
expect(method.provider).toBe('anthropic');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('auth.loader', () => {
|
|
54
|
+
const originalFetch = globalThis.fetch;
|
|
55
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
globalThis.fetch = originalFetch;
|
|
58
|
+
globalThis.setTimeout = originalSetTimeout;
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
globalThis.fetch = originalFetch;
|
|
62
|
+
globalThis.setTimeout = originalSetTimeout;
|
|
63
|
+
});
|
|
64
|
+
test('returns empty object for non-oauth auth', async () => {
|
|
65
|
+
const plugin = await getPlugin();
|
|
66
|
+
const result = await plugin.auth.loader(() => Promise.resolve({ type: 'api' }), { models: {} });
|
|
67
|
+
expect(result).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
test('zeros out model costs for oauth auth', async () => {
|
|
70
|
+
const plugin = await getPlugin();
|
|
71
|
+
const models = {
|
|
72
|
+
'claude-3': {
|
|
73
|
+
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
await plugin.auth.loader(() => Promise.resolve({
|
|
77
|
+
type: 'oauth',
|
|
78
|
+
access: 'token',
|
|
79
|
+
refresh: 'refresh',
|
|
80
|
+
expires: Date.now() + 100000,
|
|
81
|
+
}), { models });
|
|
82
|
+
expect(models['claude-3'].cost).toEqual({
|
|
83
|
+
input: 0,
|
|
84
|
+
output: 0,
|
|
85
|
+
cache: { read: 0, write: 0 },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
test('returns fetch wrapper for oauth auth', async () => {
|
|
89
|
+
const plugin = await getPlugin();
|
|
90
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
91
|
+
type: 'oauth',
|
|
92
|
+
access: 'token',
|
|
93
|
+
refresh: 'refresh',
|
|
94
|
+
expires: Date.now() + 100000,
|
|
95
|
+
}), { models: {} });
|
|
96
|
+
expect(result.apiKey).toBe('');
|
|
97
|
+
expect(result.fetch).toBeFunction();
|
|
98
|
+
});
|
|
99
|
+
test('fetch wrapper sets OAuth headers and prefixes tools', async () => {
|
|
100
|
+
let capturedHeaders;
|
|
101
|
+
let capturedBody;
|
|
102
|
+
globalThis.fetch = mock((input, init) => {
|
|
103
|
+
capturedHeaders = init?.headers;
|
|
104
|
+
capturedBody = init?.body;
|
|
105
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
106
|
+
});
|
|
107
|
+
const plugin = await getPlugin();
|
|
108
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
109
|
+
type: 'oauth',
|
|
110
|
+
access: 'my-access-token',
|
|
111
|
+
refresh: 'refresh',
|
|
112
|
+
expires: Date.now() + 100000,
|
|
113
|
+
}), { models: {} });
|
|
114
|
+
const body = JSON.stringify({
|
|
115
|
+
tools: [{ name: 'bash', type: 'function' }],
|
|
116
|
+
messages: [{ role: 'user', content: 'hello world test message' }],
|
|
117
|
+
system: 'You are a helpful assistant.',
|
|
118
|
+
});
|
|
119
|
+
await result.fetch('https://api.anthropic.com/v1/messages', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
body,
|
|
122
|
+
});
|
|
123
|
+
expect(capturedHeaders).toBeDefined();
|
|
124
|
+
expect(capturedHeaders.get('authorization')).toBe('Bearer my-access-token');
|
|
125
|
+
expect(capturedHeaders.get('x-api-key')).toBeNull();
|
|
126
|
+
expect(capturedHeaders.get('anthropic-beta')).toContain('oauth-2025-04-20');
|
|
127
|
+
const parsedBody = JSON.parse(capturedBody);
|
|
128
|
+
// Tool name should be prefixed
|
|
129
|
+
expect(parsedBody.tools[0].name).toBe('mcp_bash');
|
|
130
|
+
// System prompt should be rewritten with Claude Code identity
|
|
131
|
+
expect(parsedBody.system[0].text).toContain("You are Claude Code, Anthropic's official CLI for Claude.");
|
|
132
|
+
});
|
|
133
|
+
test('fetch wrapper refreshes expired token', async () => {
|
|
134
|
+
const fetchCalls = [];
|
|
135
|
+
globalThis.fetch = mock((input, init) => {
|
|
136
|
+
const url = typeof input === 'string'
|
|
137
|
+
? input
|
|
138
|
+
: input instanceof URL
|
|
139
|
+
? input.toString()
|
|
140
|
+
: input.url;
|
|
141
|
+
fetchCalls.push({ url, body: init?.body });
|
|
142
|
+
if (url.includes('/v1/oauth/token')) {
|
|
143
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
144
|
+
refresh_token: 'new-refresh',
|
|
145
|
+
access_token: 'new-access',
|
|
146
|
+
expires_in: 3600,
|
|
147
|
+
}), { status: 200 }));
|
|
148
|
+
}
|
|
149
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
150
|
+
});
|
|
151
|
+
const mockClient = createMockClient();
|
|
152
|
+
const plugin = await getPlugin(mockClient);
|
|
153
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
154
|
+
type: 'oauth',
|
|
155
|
+
access: 'expired-token',
|
|
156
|
+
refresh: 'old-refresh',
|
|
157
|
+
expires: Date.now() - 1000, // expired
|
|
158
|
+
}), { models: {} });
|
|
159
|
+
await result.fetch('https://api.anthropic.com/v1/messages', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
body: '{}',
|
|
162
|
+
});
|
|
163
|
+
// Should have called token endpoint first
|
|
164
|
+
const tokenCall = fetchCalls.find((c) => c.url.includes('/v1/oauth/token'));
|
|
165
|
+
expect(tokenCall).toBeDefined();
|
|
166
|
+
const tokenBody = JSON.parse(tokenCall.body);
|
|
167
|
+
expect(tokenBody.grant_type).toBe('refresh_token');
|
|
168
|
+
expect(tokenBody.refresh_token).toBe('old-refresh');
|
|
169
|
+
// Should have called client.auth.set with new tokens
|
|
170
|
+
expect(mockClient.auth.set).toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
test('fetch wrapper retries transient token refresh failures', async () => {
|
|
173
|
+
let tokenRefreshCalls = 0;
|
|
174
|
+
const setTimeoutMock = mock((handler) => {
|
|
175
|
+
handler();
|
|
176
|
+
return 0;
|
|
177
|
+
});
|
|
178
|
+
// @ts-expect-error — mock override for testing
|
|
179
|
+
globalThis.setTimeout = setTimeoutMock;
|
|
180
|
+
globalThis.fetch = mock((input) => {
|
|
181
|
+
const url = typeof input === 'string'
|
|
182
|
+
? input
|
|
183
|
+
: input instanceof URL
|
|
184
|
+
? input.toString()
|
|
185
|
+
: input.url;
|
|
186
|
+
if (url.includes('/v1/oauth/token')) {
|
|
187
|
+
tokenRefreshCalls += 1;
|
|
188
|
+
if (tokenRefreshCalls === 1) {
|
|
189
|
+
return Promise.resolve(new Response('Temporary failure', { status: 500 }));
|
|
190
|
+
}
|
|
191
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
192
|
+
refresh_token: 'new-refresh',
|
|
193
|
+
access_token: 'new-access',
|
|
194
|
+
expires_in: 3600,
|
|
195
|
+
}), { status: 200 }));
|
|
196
|
+
}
|
|
197
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
198
|
+
});
|
|
199
|
+
const mockClient = createMockClient();
|
|
200
|
+
const plugin = await getPlugin(mockClient);
|
|
201
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
202
|
+
type: 'oauth',
|
|
203
|
+
access: 'expired',
|
|
204
|
+
refresh: 'refresh',
|
|
205
|
+
expires: Date.now() - 1000,
|
|
206
|
+
}), { models: {} });
|
|
207
|
+
await result.fetch('https://api.anthropic.com/v1/messages', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
body: '{}',
|
|
210
|
+
});
|
|
211
|
+
expect(tokenRefreshCalls).toBe(2);
|
|
212
|
+
expect(setTimeoutMock).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 500);
|
|
214
|
+
expect(mockClient.auth.set).toHaveBeenCalledTimes(1);
|
|
215
|
+
});
|
|
216
|
+
test('fetch wrapper does not retry non-transient token refresh failures', async () => {
|
|
217
|
+
let tokenRefreshCalls = 0;
|
|
218
|
+
globalThis.fetch = mock((input) => {
|
|
219
|
+
const url = typeof input === 'string'
|
|
220
|
+
? input
|
|
221
|
+
: input instanceof URL
|
|
222
|
+
? input.toString()
|
|
223
|
+
: input.url;
|
|
224
|
+
if (url.includes('/v1/oauth/token')) {
|
|
225
|
+
tokenRefreshCalls += 1;
|
|
226
|
+
return Promise.resolve(new Response('Forbidden', { status: 403 }));
|
|
227
|
+
}
|
|
228
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
229
|
+
});
|
|
230
|
+
const plugin = await getPlugin();
|
|
231
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
232
|
+
type: 'oauth',
|
|
233
|
+
access: 'expired',
|
|
234
|
+
refresh: 'refresh',
|
|
235
|
+
expires: Date.now() - 1000,
|
|
236
|
+
}), { models: {} });
|
|
237
|
+
expect(result.fetch('https://api.anthropic.com/v1/messages', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
body: '{}',
|
|
240
|
+
})).rejects.toThrow('Token refresh failed: 403');
|
|
241
|
+
expect(tokenRefreshCalls).toBe(1);
|
|
242
|
+
});
|
|
243
|
+
test('fetch wrapper strips tool prefix from streaming response', async () => {
|
|
244
|
+
const encoder = new TextEncoder();
|
|
245
|
+
const responseStream = new ReadableStream({
|
|
246
|
+
start(controller) {
|
|
247
|
+
controller.enqueue(encoder.encode('data: {"content_block":{"type":"tool_use","name":"mcp_bash"}}\n\n'));
|
|
248
|
+
controller.close();
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
globalThis.fetch = mock(() => Promise.resolve(new Response(responseStream, { status: 200 })));
|
|
252
|
+
const plugin = await getPlugin();
|
|
253
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
254
|
+
type: 'oauth',
|
|
255
|
+
access: 'token',
|
|
256
|
+
refresh: 'refresh',
|
|
257
|
+
expires: Date.now() + 100000,
|
|
258
|
+
}), { models: {} });
|
|
259
|
+
const response = await result.fetch('https://api.anthropic.com/v1/messages', {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
body: '{}',
|
|
262
|
+
});
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
expect(text).toContain('"name": "bash"');
|
|
265
|
+
expect(text).not.toContain('mcp_bash');
|
|
266
|
+
});
|
|
267
|
+
test('concurrent expired token refresh should deduplicate to a single token request', async () => {
|
|
268
|
+
let tokenRefreshCount = 0;
|
|
269
|
+
// @ts-expect-error — mock override for testing
|
|
270
|
+
globalThis.setTimeout = mock((handler) => {
|
|
271
|
+
handler();
|
|
272
|
+
return 0;
|
|
273
|
+
});
|
|
274
|
+
globalThis.fetch = mock((input, init) => {
|
|
275
|
+
const url = typeof input === 'string'
|
|
276
|
+
? input
|
|
277
|
+
: input instanceof URL
|
|
278
|
+
? input.toString()
|
|
279
|
+
: input.url;
|
|
280
|
+
if (url.includes('/v1/oauth/token')) {
|
|
281
|
+
tokenRefreshCount++;
|
|
282
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
283
|
+
refresh_token: 'new-refresh',
|
|
284
|
+
access_token: 'new-access',
|
|
285
|
+
expires_in: 3600,
|
|
286
|
+
}), { status: 200 }));
|
|
287
|
+
}
|
|
288
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
289
|
+
});
|
|
290
|
+
const mockClient = createMockClient();
|
|
291
|
+
const plugin = await getPlugin(mockClient);
|
|
292
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
293
|
+
type: 'oauth',
|
|
294
|
+
access: 'expired-token',
|
|
295
|
+
refresh: 'old-refresh',
|
|
296
|
+
expires: Date.now() - 1000,
|
|
297
|
+
}), { models: {} });
|
|
298
|
+
// Fire 5 concurrent requests, all with expired token
|
|
299
|
+
await Promise.all([
|
|
300
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
body: '{}',
|
|
303
|
+
}),
|
|
304
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
body: '{}',
|
|
307
|
+
}),
|
|
308
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
body: '{}',
|
|
311
|
+
}),
|
|
312
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
body: '{}',
|
|
315
|
+
}),
|
|
316
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
body: '{}',
|
|
319
|
+
}),
|
|
320
|
+
]);
|
|
321
|
+
// With deduplication, only ONE refresh request should be made, not 5
|
|
322
|
+
expect(tokenRefreshCount).toBe(1);
|
|
323
|
+
});
|
|
324
|
+
test('concurrent refresh with token rotation should not cause cascading failures', async () => {
|
|
325
|
+
const usedRefreshTokens = new Set();
|
|
326
|
+
// @ts-expect-error — mock override for testing
|
|
327
|
+
globalThis.setTimeout = mock((handler) => {
|
|
328
|
+
handler();
|
|
329
|
+
return 0;
|
|
330
|
+
});
|
|
331
|
+
globalThis.fetch = mock((input, init) => {
|
|
332
|
+
const url = typeof input === 'string'
|
|
333
|
+
? input
|
|
334
|
+
: input instanceof URL
|
|
335
|
+
? input.toString()
|
|
336
|
+
: input.url;
|
|
337
|
+
if (url.includes('/v1/oauth/token')) {
|
|
338
|
+
const body = JSON.parse(init?.body);
|
|
339
|
+
const refreshToken = body.refresh_token;
|
|
340
|
+
// Simulate refresh token rotation: first use succeeds, subsequent uses
|
|
341
|
+
// return 401 because the old token has been invalidated
|
|
342
|
+
if (usedRefreshTokens.has(refreshToken)) {
|
|
343
|
+
return Promise.resolve(new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
|
344
|
+
status: 401,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
usedRefreshTokens.add(refreshToken);
|
|
348
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
349
|
+
refresh_token: 'rotated-refresh',
|
|
350
|
+
access_token: 'new-access',
|
|
351
|
+
expires_in: 3600,
|
|
352
|
+
}), { status: 200 }));
|
|
353
|
+
}
|
|
354
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
355
|
+
});
|
|
356
|
+
const mockClient = createMockClient();
|
|
357
|
+
const plugin = await getPlugin(mockClient);
|
|
358
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
359
|
+
type: 'oauth',
|
|
360
|
+
access: 'expired-token',
|
|
361
|
+
refresh: 'old-refresh',
|
|
362
|
+
expires: Date.now() - 1000,
|
|
363
|
+
}), { models: {} });
|
|
364
|
+
// Fire 5 concurrent requests — ALL should succeed because only one refresh
|
|
365
|
+
// fires and the rest reuse its result
|
|
366
|
+
const outcomes = await Promise.all([
|
|
367
|
+
result
|
|
368
|
+
.fetch('https://api.anthropic.com/v1/messages', {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
body: '{}',
|
|
371
|
+
})
|
|
372
|
+
.then(() => 'ok', () => 'fail'),
|
|
373
|
+
result
|
|
374
|
+
.fetch('https://api.anthropic.com/v1/messages', {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
body: '{}',
|
|
377
|
+
})
|
|
378
|
+
.then(() => 'ok', () => 'fail'),
|
|
379
|
+
result
|
|
380
|
+
.fetch('https://api.anthropic.com/v1/messages', {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
body: '{}',
|
|
383
|
+
})
|
|
384
|
+
.then(() => 'ok', () => 'fail'),
|
|
385
|
+
result
|
|
386
|
+
.fetch('https://api.anthropic.com/v1/messages', {
|
|
387
|
+
method: 'POST',
|
|
388
|
+
body: '{}',
|
|
389
|
+
})
|
|
390
|
+
.then(() => 'ok', () => 'fail'),
|
|
391
|
+
result
|
|
392
|
+
.fetch('https://api.anthropic.com/v1/messages', {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
body: '{}',
|
|
395
|
+
})
|
|
396
|
+
.then(() => 'ok', () => 'fail'),
|
|
397
|
+
]);
|
|
398
|
+
// With deduplication, all callers share the single successful refresh.
|
|
399
|
+
// Without it, 4 out of 5 get 401 from the rotated-away token → cascading failures.
|
|
400
|
+
expect(outcomes).toEqual(['ok', 'ok', 'ok', 'ok', 'ok']);
|
|
401
|
+
});
|
|
402
|
+
test('concurrent refresh should persist tokens exactly once', async () => {
|
|
403
|
+
// @ts-expect-error — mock override for testing
|
|
404
|
+
globalThis.setTimeout = mock((handler) => {
|
|
405
|
+
handler();
|
|
406
|
+
return 0;
|
|
407
|
+
});
|
|
408
|
+
globalThis.fetch = mock((input) => {
|
|
409
|
+
const url = typeof input === 'string'
|
|
410
|
+
? input
|
|
411
|
+
: input instanceof URL
|
|
412
|
+
? input.toString()
|
|
413
|
+
: input.url;
|
|
414
|
+
if (url.includes('/v1/oauth/token')) {
|
|
415
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
416
|
+
refresh_token: 'new-refresh',
|
|
417
|
+
access_token: 'new-access',
|
|
418
|
+
expires_in: 3600,
|
|
419
|
+
}), { status: 200 }));
|
|
420
|
+
}
|
|
421
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
422
|
+
});
|
|
423
|
+
const mockClient = createMockClient();
|
|
424
|
+
const plugin = await getPlugin(mockClient);
|
|
425
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
426
|
+
type: 'oauth',
|
|
427
|
+
access: 'expired-token',
|
|
428
|
+
refresh: 'old-refresh',
|
|
429
|
+
expires: Date.now() - 1000,
|
|
430
|
+
}), { models: {} });
|
|
431
|
+
await Promise.all([
|
|
432
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
body: '{}',
|
|
435
|
+
}),
|
|
436
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
body: '{}',
|
|
439
|
+
}),
|
|
440
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
441
|
+
method: 'POST',
|
|
442
|
+
body: '{}',
|
|
443
|
+
}),
|
|
444
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
445
|
+
method: 'POST',
|
|
446
|
+
body: '{}',
|
|
447
|
+
}),
|
|
448
|
+
result.fetch('https://api.anthropic.com/v1/messages', {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
body: '{}',
|
|
451
|
+
}),
|
|
452
|
+
]);
|
|
453
|
+
// With deduplication, client.auth.set should be called exactly once.
|
|
454
|
+
// Without it, each concurrent refresh calls auth.set independently → 5 calls.
|
|
455
|
+
expect(mockClient.auth.set).toHaveBeenCalledTimes(1);
|
|
456
|
+
});
|
|
457
|
+
test('fetch wrapper adds beta=true to /v1/messages URL', async () => {
|
|
458
|
+
let capturedUrl;
|
|
459
|
+
globalThis.fetch = mock((input) => {
|
|
460
|
+
capturedUrl =
|
|
461
|
+
typeof input === 'string'
|
|
462
|
+
? input
|
|
463
|
+
: input instanceof URL
|
|
464
|
+
? input.toString()
|
|
465
|
+
: input.url;
|
|
466
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
467
|
+
});
|
|
468
|
+
const plugin = await getPlugin();
|
|
469
|
+
const result = await plugin.auth.loader(() => Promise.resolve({
|
|
470
|
+
type: 'oauth',
|
|
471
|
+
access: 'token',
|
|
472
|
+
refresh: 'refresh',
|
|
473
|
+
expires: Date.now() + 100000,
|
|
474
|
+
}), { models: {} });
|
|
475
|
+
await result.fetch('https://api.anthropic.com/v1/messages', {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
body: '{}',
|
|
478
|
+
});
|
|
479
|
+
expect(capturedUrl).toContain('beta=true');
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|