@ex-machina/opencode-anthropic-auth 1.2.0 → 1.3.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/README.md +38 -2
- package/dist/constants.d.ts +19 -1
- package/dist/constants.js +22 -4
- package/dist/index.js +0 -1
- package/dist/transform.d.ts +15 -7
- package/dist/transform.js +45 -31
- package/package.json +4 -1
- package/dist/tests/auth.test.d.ts +0 -1
- package/dist/tests/auth.test.js +0 -87
- package/dist/tests/index.test.d.ts +0 -1
- package/dist/tests/index.test.js +0 -481
- package/dist/tests/pkce.test.d.ts +0 -1
- package/dist/tests/pkce.test.js +0 -111
- package/dist/tests/transform.test.d.ts +0 -1
- package/dist/tests/transform.test.js +0 -621
package/dist/tests/index.test.js
DELETED
|
@@ -1,481 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/tests/pkce.test.js
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
-
import { generatePKCE } from '../pkce';
|
|
3
|
-
afterEach(() => {
|
|
4
|
-
mock.restore();
|
|
5
|
-
});
|
|
6
|
-
describe('generatePKCE', () => {
|
|
7
|
-
test('returns an object with verifier, challenge, and method', async () => {
|
|
8
|
-
const result = await generatePKCE();
|
|
9
|
-
expect(result).toHaveProperty('verifier');
|
|
10
|
-
expect(result).toHaveProperty('challenge');
|
|
11
|
-
expect(result).toHaveProperty('method');
|
|
12
|
-
});
|
|
13
|
-
test('method is S256', async () => {
|
|
14
|
-
const result = await generatePKCE();
|
|
15
|
-
expect(result.method).toBe('S256');
|
|
16
|
-
});
|
|
17
|
-
test('verifier is 86 characters (64 bytes base64url)', async () => {
|
|
18
|
-
const result = await generatePKCE();
|
|
19
|
-
expect(result.verifier.length).toBe(86);
|
|
20
|
-
});
|
|
21
|
-
test('challenge is 43 characters (SHA-256 base64url)', async () => {
|
|
22
|
-
const result = await generatePKCE();
|
|
23
|
-
expect(result.challenge.length).toBe(43);
|
|
24
|
-
});
|
|
25
|
-
test('verifier contains only base64url characters', async () => {
|
|
26
|
-
const result = await generatePKCE();
|
|
27
|
-
expect(result.verifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
28
|
-
});
|
|
29
|
-
test('challenge contains only base64url characters', async () => {
|
|
30
|
-
const result = await generatePKCE();
|
|
31
|
-
expect(result.challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
32
|
-
});
|
|
33
|
-
test('verifier has no base64 padding or unsafe chars', async () => {
|
|
34
|
-
const result = await generatePKCE();
|
|
35
|
-
expect(result.verifier).not.toContain('+');
|
|
36
|
-
expect(result.verifier).not.toContain('/');
|
|
37
|
-
expect(result.verifier).not.toContain('=');
|
|
38
|
-
});
|
|
39
|
-
test('challenge has no base64 padding or unsafe chars', async () => {
|
|
40
|
-
const result = await generatePKCE();
|
|
41
|
-
expect(result.challenge).not.toContain('+');
|
|
42
|
-
expect(result.challenge).not.toContain('/');
|
|
43
|
-
expect(result.challenge).not.toContain('=');
|
|
44
|
-
});
|
|
45
|
-
test('challenge is SHA-256 of verifier (independent verification)', async () => {
|
|
46
|
-
const result = await generatePKCE();
|
|
47
|
-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(result.verifier));
|
|
48
|
-
const hashBytes = new Uint8Array(digest);
|
|
49
|
-
let bin = '';
|
|
50
|
-
for (const byte of hashBytes)
|
|
51
|
-
bin += String.fromCharCode(byte);
|
|
52
|
-
const expected = btoa(bin)
|
|
53
|
-
.replace(/\+/g, '-')
|
|
54
|
-
.replace(/\//g, '_')
|
|
55
|
-
.replace(/=/g, '');
|
|
56
|
-
expect(result.challenge).toBe(expected);
|
|
57
|
-
});
|
|
58
|
-
test('consecutive calls produce different verifiers', async () => {
|
|
59
|
-
const a = await generatePKCE();
|
|
60
|
-
const b = await generatePKCE();
|
|
61
|
-
expect(a.verifier).not.toBe(b.verifier);
|
|
62
|
-
});
|
|
63
|
-
test('consecutive calls produce different challenges', async () => {
|
|
64
|
-
const a = await generatePKCE();
|
|
65
|
-
const b = await generatePKCE();
|
|
66
|
-
expect(a.challenge).not.toBe(b.challenge);
|
|
67
|
-
});
|
|
68
|
-
test('verifier is within RFC 7636 bounds (43-128 chars)', async () => {
|
|
69
|
-
const result = await generatePKCE();
|
|
70
|
-
expect(result.verifier.length).toBeGreaterThanOrEqual(43);
|
|
71
|
-
expect(result.verifier.length).toBeLessThanOrEqual(128);
|
|
72
|
-
});
|
|
73
|
-
test('deterministic output for known random bytes', async () => {
|
|
74
|
-
// Bytes 0-63 produce +, / and = in raw base64,
|
|
75
|
-
// pinning all three .replace() calls
|
|
76
|
-
const knownBytes = new Uint8Array(64);
|
|
77
|
-
for (let i = 0; i < 64; i++)
|
|
78
|
-
knownBytes[i] = i;
|
|
79
|
-
const original = crypto.getRandomValues;
|
|
80
|
-
crypto.getRandomValues = ((array) => {
|
|
81
|
-
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(knownBytes);
|
|
82
|
-
return array;
|
|
83
|
-
});
|
|
84
|
-
try {
|
|
85
|
-
const result = await generatePKCE();
|
|
86
|
-
// Expected verifier: base64url(bytes 0..63)
|
|
87
|
-
let bin = '';
|
|
88
|
-
for (let i = 0; i < 64; i++)
|
|
89
|
-
bin += String.fromCharCode(i);
|
|
90
|
-
const expectedVerifier = btoa(bin)
|
|
91
|
-
.replace(/\+/g, '-')
|
|
92
|
-
.replace(/\//g, '_')
|
|
93
|
-
.replace(/=/g, '');
|
|
94
|
-
expect(result.verifier).toBe(expectedVerifier);
|
|
95
|
-
// Expected challenge: base64url(SHA-256(verifier))
|
|
96
|
-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedVerifier));
|
|
97
|
-
const hashBytes = new Uint8Array(digest);
|
|
98
|
-
let hashBin = '';
|
|
99
|
-
for (const byte of hashBytes)
|
|
100
|
-
hashBin += String.fromCharCode(byte);
|
|
101
|
-
const expectedChallenge = btoa(hashBin)
|
|
102
|
-
.replace(/\+/g, '-')
|
|
103
|
-
.replace(/\//g, '_')
|
|
104
|
-
.replace(/=/g, '');
|
|
105
|
-
expect(result.challenge).toBe(expectedChallenge);
|
|
106
|
-
}
|
|
107
|
-
finally {
|
|
108
|
-
crypto.getRandomValues = original;
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|