@plosson/agentio 0.7.1 → 0.7.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.
@@ -0,0 +1,466 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { createHash } from 'crypto';
3
+ import { mkdtemp, rm } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import type { Subprocess } from 'bun';
7
+
8
+ import { startServerSubprocess } from './test-helpers';
9
+
10
+ /**
11
+ * End-to-end test for the full MCP OAuth flow against a real `agentio
12
+ * server` subprocess. This is the closest we get to "what Claude Code
13
+ * actually does" without involving Claude Code itself:
14
+ *
15
+ * 1. Discover OAuth metadata (RFC 9728 + RFC 8414).
16
+ * 2. POST /register (Dynamic Client Registration).
17
+ * 3. POST /authorize with the API key from the server's stdout.
18
+ * 4. POST /token to exchange the auth code (with PKCE verifier).
19
+ * 5. Use the bearer on /mcp.
20
+ *
21
+ * Each test runs in an isolated `mkdtemp` HOME so it never touches the
22
+ * developer's real config.
23
+ */
24
+
25
+ interface RunningServer {
26
+ proc: Subprocess<'ignore', 'pipe', 'pipe'>;
27
+ apiKey: string;
28
+ port: number;
29
+ baseUrl: string;
30
+ }
31
+
32
+ let tempHome = '';
33
+ let active: RunningServer | null = null;
34
+
35
+ beforeEach(async () => {
36
+ tempHome = await mkdtemp(join(tmpdir(), 'agentio-server-e2e-'));
37
+ });
38
+
39
+ afterEach(async () => {
40
+ if (active) {
41
+ try {
42
+ active.proc.kill('SIGTERM');
43
+ await Promise.race([
44
+ active.proc.exited,
45
+ new Promise<number>((resolve) => setTimeout(() => resolve(-1), 5000)),
46
+ ]);
47
+ } catch {
48
+ try {
49
+ active.proc.kill('SIGKILL');
50
+ await active.proc.exited;
51
+ } catch {
52
+ /* ignore */
53
+ }
54
+ }
55
+ active = null;
56
+ }
57
+ if (tempHome) {
58
+ await rm(tempHome, { recursive: true, force: true }).catch(() => {});
59
+ tempHome = '';
60
+ }
61
+ });
62
+
63
+ async function startServer(): Promise<RunningServer> {
64
+ const started = await startServerSubprocess({ home: tempHome });
65
+ const running: RunningServer = {
66
+ proc: started.proc,
67
+ apiKey: started.apiKey,
68
+ port: started.port,
69
+ baseUrl: `http://127.0.0.1:${started.port}`,
70
+ };
71
+ active = running;
72
+ return running;
73
+ }
74
+
75
+ function makePkcePair(): { verifier: string; challenge: string } {
76
+ const verifier = 'verifier_' + 'a'.repeat(54); // 63 chars, all valid
77
+ const challenge = createHash('sha256')
78
+ .update(verifier, 'ascii')
79
+ .digest('base64url');
80
+ return { verifier, challenge };
81
+ }
82
+
83
+ interface DiscoveredEndpoints {
84
+ authorization_endpoint: string;
85
+ token_endpoint: string;
86
+ registration_endpoint: string;
87
+ issuer: string;
88
+ }
89
+
90
+ async function discoverMetadata(
91
+ baseUrl: string
92
+ ): Promise<DiscoveredEndpoints> {
93
+ const prRes = await fetch(
94
+ `${baseUrl}/.well-known/oauth-protected-resource`
95
+ );
96
+ expect(prRes.status).toBe(200);
97
+ const pr = (await prRes.json()) as Record<string, unknown>;
98
+ const authServers = pr.authorization_servers as string[];
99
+ expect(authServers).toHaveLength(1);
100
+
101
+ const asRes = await fetch(
102
+ `${authServers[0]}/.well-known/oauth-authorization-server`
103
+ );
104
+ expect(asRes.status).toBe(200);
105
+ return (await asRes.json()) as DiscoveredEndpoints;
106
+ }
107
+
108
+ async function dynamicallyRegister(
109
+ endpoint: string,
110
+ redirectUri: string
111
+ ): Promise<string> {
112
+ const res = await fetch(endpoint, {
113
+ method: 'POST',
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({
116
+ client_name: 'E2E Test Client',
117
+ redirect_uris: [redirectUri],
118
+ }),
119
+ });
120
+ expect(res.status).toBe(201);
121
+ const body = (await res.json()) as Record<string, unknown>;
122
+ return body.client_id as string;
123
+ }
124
+
125
+ /* ------------------------------------------------------------------ */
126
+ /* the actual end-to-end flow */
127
+ /* ------------------------------------------------------------------ */
128
+
129
+ describe('end-to-end OAuth flow', () => {
130
+ test('complete happy path: discover → register → authorize → token → /mcp', async () => {
131
+ const server = await startServer();
132
+ const redirectUri = 'http://localhost:53682/callback';
133
+ const { verifier, challenge } = makePkcePair();
134
+
135
+ // 1. Discover metadata.
136
+ const meta = await discoverMetadata(server.baseUrl);
137
+ expect(meta.issuer).toBe(server.baseUrl);
138
+ expect(meta.authorization_endpoint).toBe(`${server.baseUrl}/authorize`);
139
+ expect(meta.token_endpoint).toBe(`${server.baseUrl}/token`);
140
+ expect(meta.registration_endpoint).toBe(`${server.baseUrl}/register`);
141
+
142
+ // 2. Dynamic Client Registration.
143
+ const clientId = await dynamicallyRegister(
144
+ meta.registration_endpoint,
145
+ redirectUri
146
+ );
147
+ expect(clientId).toMatch(/^cli_/);
148
+
149
+ // 3. POST /authorize with the operator API key.
150
+ const authForm = new URLSearchParams({
151
+ client_id: clientId,
152
+ redirect_uri: redirectUri,
153
+ response_type: 'code',
154
+ code_challenge: challenge,
155
+ code_challenge_method: 'S256',
156
+ state: 'opaque-state-value',
157
+ scope: 'mcp',
158
+ api_key: server.apiKey,
159
+ });
160
+ const authRes = await fetch(meta.authorization_endpoint, {
161
+ method: 'POST',
162
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
163
+ body: authForm.toString(),
164
+ redirect: 'manual',
165
+ });
166
+ expect(authRes.status).toBe(302);
167
+ const location = authRes.headers.get('location');
168
+ expect(location).toBeDefined();
169
+ const locUrl = new URL(location!);
170
+ expect(`${locUrl.origin}${locUrl.pathname}`).toBe(redirectUri);
171
+ const code = locUrl.searchParams.get('code');
172
+ const state = locUrl.searchParams.get('state');
173
+ expect(code).toBeDefined();
174
+ expect(state).toBe('opaque-state-value');
175
+
176
+ // 4. POST /token to exchange the code.
177
+ const tokenRes = await fetch(meta.token_endpoint, {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
180
+ body: new URLSearchParams({
181
+ grant_type: 'authorization_code',
182
+ code: code!,
183
+ client_id: clientId,
184
+ redirect_uri: redirectUri,
185
+ code_verifier: verifier,
186
+ }).toString(),
187
+ });
188
+ expect(tokenRes.status).toBe(200);
189
+ const tokenBody = (await tokenRes.json()) as Record<string, unknown>;
190
+ const accessToken = tokenBody.access_token as string;
191
+ expect(accessToken).toMatch(/^[A-Za-z0-9_-]+$/);
192
+ expect(tokenBody.token_type).toBe('Bearer');
193
+ expect(tokenBody.expires_in).toBe(30 * 24 * 60 * 60);
194
+
195
+ // 5. Use the bearer on /mcp. In Phase 4 this hits the real MCP
196
+ // Streamable HTTP transport — a plain GET without the MCP Accept
197
+ // headers is rejected by the transport (406), but the important
198
+ // thing for THIS test is that the bearer check passed: we did NOT
199
+ // get a 401 with WWW-Authenticate. The full MCP protocol flow is
200
+ // exercised in mcp-e2e.test.ts.
201
+ const mcpRes = await fetch(`${server.baseUrl}/mcp`, {
202
+ headers: { authorization: `Bearer ${accessToken}` },
203
+ });
204
+ expect(mcpRes.status).not.toBe(401);
205
+ expect(mcpRes.headers.get('www-authenticate')).toBeNull();
206
+ });
207
+
208
+ test('/mcp without bearer returns 401 + WWW-Authenticate (the trigger)', async () => {
209
+ const server = await startServer();
210
+ const res = await fetch(`${server.baseUrl}/mcp`);
211
+ expect(res.status).toBe(401);
212
+ const wwwAuth = res.headers.get('www-authenticate');
213
+ expect(wwwAuth).toBeDefined();
214
+ expect(wwwAuth).toContain('Bearer');
215
+ expect(wwwAuth).toContain(
216
+ `resource_metadata="${server.baseUrl}/.well-known/oauth-protected-resource"`
217
+ );
218
+ });
219
+
220
+ test('wrong API key at /authorize re-renders form, no code issued', async () => {
221
+ const server = await startServer();
222
+ const redirectUri = 'http://localhost:53682/callback';
223
+ const { challenge } = makePkcePair();
224
+ const meta = await discoverMetadata(server.baseUrl);
225
+ const clientId = await dynamicallyRegister(
226
+ meta.registration_endpoint,
227
+ redirectUri
228
+ );
229
+
230
+ const res = await fetch(meta.authorization_endpoint, {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
233
+ body: new URLSearchParams({
234
+ client_id: clientId,
235
+ redirect_uri: redirectUri,
236
+ response_type: 'code',
237
+ code_challenge: challenge,
238
+ code_challenge_method: 'S256',
239
+ state: 's',
240
+ scope: '',
241
+ api_key: 'srv_definitely_wrong',
242
+ }).toString(),
243
+ redirect: 'manual',
244
+ });
245
+ expect(res.status).toBe(200);
246
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
247
+ const html = await res.text();
248
+ expect(html).toContain('Invalid API key');
249
+ });
250
+
251
+ test('reusing an auth code at /token a second time fails', async () => {
252
+ const server = await startServer();
253
+ const redirectUri = 'http://localhost:53682/callback';
254
+ const { verifier, challenge } = makePkcePair();
255
+ const meta = await discoverMetadata(server.baseUrl);
256
+ const clientId = await dynamicallyRegister(
257
+ meta.registration_endpoint,
258
+ redirectUri
259
+ );
260
+
261
+ const authRes = await fetch(meta.authorization_endpoint, {
262
+ method: 'POST',
263
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
264
+ body: new URLSearchParams({
265
+ client_id: clientId,
266
+ redirect_uri: redirectUri,
267
+ response_type: 'code',
268
+ code_challenge: challenge,
269
+ code_challenge_method: 'S256',
270
+ state: '',
271
+ scope: '',
272
+ api_key: server.apiKey,
273
+ }).toString(),
274
+ redirect: 'manual',
275
+ });
276
+ const code = new URL(authRes.headers.get('location')!).searchParams.get(
277
+ 'code'
278
+ )!;
279
+
280
+ // First exchange — succeeds.
281
+ const first = await fetch(meta.token_endpoint, {
282
+ method: 'POST',
283
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
284
+ body: new URLSearchParams({
285
+ grant_type: 'authorization_code',
286
+ code,
287
+ client_id: clientId,
288
+ redirect_uri: redirectUri,
289
+ code_verifier: verifier,
290
+ }).toString(),
291
+ });
292
+ expect(first.status).toBe(200);
293
+
294
+ // Second exchange — fails.
295
+ const second = await fetch(meta.token_endpoint, {
296
+ method: 'POST',
297
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
298
+ body: new URLSearchParams({
299
+ grant_type: 'authorization_code',
300
+ code,
301
+ client_id: clientId,
302
+ redirect_uri: redirectUri,
303
+ code_verifier: verifier,
304
+ }).toString(),
305
+ });
306
+ expect(second.status).toBe(400);
307
+ const body = (await second.json()) as Record<string, unknown>;
308
+ expect(body.error).toBe('invalid_grant');
309
+ });
310
+
311
+ test('wrong PKCE verifier at /token fails AND consumes the code', async () => {
312
+ const server = await startServer();
313
+ const redirectUri = 'http://localhost:53682/callback';
314
+ const { verifier, challenge } = makePkcePair();
315
+ const meta = await discoverMetadata(server.baseUrl);
316
+ const clientId = await dynamicallyRegister(
317
+ meta.registration_endpoint,
318
+ redirectUri
319
+ );
320
+
321
+ const authRes = await fetch(meta.authorization_endpoint, {
322
+ method: 'POST',
323
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
324
+ body: new URLSearchParams({
325
+ client_id: clientId,
326
+ redirect_uri: redirectUri,
327
+ response_type: 'code',
328
+ code_challenge: challenge,
329
+ code_challenge_method: 'S256',
330
+ state: '',
331
+ scope: '',
332
+ api_key: server.apiKey,
333
+ }).toString(),
334
+ redirect: 'manual',
335
+ });
336
+ const code = new URL(authRes.headers.get('location')!).searchParams.get(
337
+ 'code'
338
+ )!;
339
+
340
+ // Wrong verifier first.
341
+ const wrongVerifier = 'verifier_' + 'b'.repeat(54);
342
+ const wrong = await fetch(meta.token_endpoint, {
343
+ method: 'POST',
344
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
345
+ body: new URLSearchParams({
346
+ grant_type: 'authorization_code',
347
+ code,
348
+ client_id: clientId,
349
+ redirect_uri: redirectUri,
350
+ code_verifier: wrongVerifier,
351
+ }).toString(),
352
+ });
353
+ expect(wrong.status).toBe(400);
354
+
355
+ // The correct verifier should also fail now — code is consumed.
356
+ const right = await fetch(meta.token_endpoint, {
357
+ method: 'POST',
358
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
359
+ body: new URLSearchParams({
360
+ grant_type: 'authorization_code',
361
+ code,
362
+ client_id: clientId,
363
+ redirect_uri: redirectUri,
364
+ code_verifier: verifier,
365
+ }).toString(),
366
+ });
367
+ expect(right.status).toBe(400);
368
+ });
369
+
370
+ test('issued tokens persist across server restarts', async () => {
371
+ // First boot: do the OAuth dance, get a token.
372
+ const server1 = await startServer();
373
+ const redirectUri = 'http://localhost:53682/callback';
374
+ const { verifier, challenge } = makePkcePair();
375
+ const meta = await discoverMetadata(server1.baseUrl);
376
+ const clientId = await dynamicallyRegister(
377
+ meta.registration_endpoint,
378
+ redirectUri
379
+ );
380
+ const authRes = await fetch(meta.authorization_endpoint, {
381
+ method: 'POST',
382
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
383
+ body: new URLSearchParams({
384
+ client_id: clientId,
385
+ redirect_uri: redirectUri,
386
+ response_type: 'code',
387
+ code_challenge: challenge,
388
+ code_challenge_method: 'S256',
389
+ state: '',
390
+ scope: '',
391
+ api_key: server1.apiKey,
392
+ }).toString(),
393
+ redirect: 'manual',
394
+ });
395
+ const code = new URL(authRes.headers.get('location')!).searchParams.get(
396
+ 'code'
397
+ )!;
398
+ const tokenRes = await fetch(meta.token_endpoint, {
399
+ method: 'POST',
400
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
401
+ body: new URLSearchParams({
402
+ grant_type: 'authorization_code',
403
+ code,
404
+ client_id: clientId,
405
+ redirect_uri: redirectUri,
406
+ code_verifier: verifier,
407
+ }).toString(),
408
+ });
409
+ const accessToken = ((await tokenRes.json()) as Record<string, unknown>)
410
+ .access_token as string;
411
+
412
+ // Stop server1.
413
+ server1.proc.kill('SIGTERM');
414
+ await server1.proc.exited;
415
+ active = null;
416
+
417
+ // Boot a fresh server (same HOME, same config, same persisted state).
418
+ const server2 = await startServer();
419
+ expect(server2.apiKey).toBe(server1.apiKey); // also persisted
420
+
421
+ // The bearer issued under server1 should still work on server2 —
422
+ // i.e. the bearer check passes and we don't get a 401.
423
+ const mcpRes = await fetch(`${server2.baseUrl}/mcp`, {
424
+ headers: { authorization: `Bearer ${accessToken}` },
425
+ });
426
+ expect(mcpRes.status).not.toBe(401);
427
+ expect(mcpRes.headers.get('www-authenticate')).toBeNull();
428
+ });
429
+
430
+ test('XSS attempt in client_name does not execute when /authorize renders', async () => {
431
+ const server = await startServer();
432
+ const redirectUri = 'http://localhost:53682/callback';
433
+ const { challenge } = makePkcePair();
434
+ const meta = await discoverMetadata(server.baseUrl);
435
+
436
+ // Register with a hostile client_name.
437
+ const regRes = await fetch(meta.registration_endpoint, {
438
+ method: 'POST',
439
+ headers: { 'content-type': 'application/json' },
440
+ body: JSON.stringify({
441
+ client_name: '<script>alert(document.domain)</script>',
442
+ redirect_uris: [redirectUri],
443
+ }),
444
+ });
445
+ const clientId = ((await regRes.json()) as Record<string, unknown>)
446
+ .client_id as string;
447
+
448
+ // GET /authorize and verify the script is escaped.
449
+ const authForm = new URLSearchParams({
450
+ client_id: clientId,
451
+ redirect_uri: redirectUri,
452
+ response_type: 'code',
453
+ code_challenge: challenge,
454
+ code_challenge_method: 'S256',
455
+ state: '',
456
+ scope: '',
457
+ });
458
+ const formRes = await fetch(
459
+ `${meta.authorization_endpoint}?${authForm}`
460
+ );
461
+ expect(formRes.status).toBe(200);
462
+ const html = await formRes.text();
463
+ expect(html).not.toContain('<script>alert(document.domain)</script>');
464
+ expect(html).toContain('&lt;script&gt;');
465
+ });
466
+ });