@plosson/agentio 0.7.2 → 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,1502 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { createHash } from 'crypto';
4
+
5
+ import type { ServerContext } from './http';
6
+ import {
7
+ buildAuthorizationServerMetadata,
8
+ buildProtectedResourceMetadata,
9
+ computeS256Challenge,
10
+ constantTimeEquals,
11
+ getRequestOrigin,
12
+ handleAuthorizeGet,
13
+ handleAuthorizePost,
14
+ handleRegister,
15
+ handleToken,
16
+ requireBearer,
17
+ } from './oauth';
18
+ import { createOAuthStore } from './oauth-store';
19
+
20
+ const TEST_API_KEY = 'srv_test_key_for_unit_tests';
21
+
22
+ function makeCtx(): ServerContext {
23
+ return {
24
+ apiKey: TEST_API_KEY,
25
+ oauthStore: createOAuthStore({ save: async () => {} }),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Register a client and return its full client_id. Used as setup for
31
+ * /authorize and /token tests.
32
+ */
33
+ async function registerTestClient(
34
+ ctx: ServerContext,
35
+ redirectUri = 'http://localhost:53682/callback',
36
+ clientName = 'Claude Code'
37
+ ): Promise<string> {
38
+ const c = await ctx.oauthStore.registerClient({
39
+ clientName,
40
+ redirectUris: [redirectUri],
41
+ });
42
+ return c.clientId;
43
+ }
44
+
45
+ /** Build a GET /authorize request with the given params. */
46
+ function authorizeGet(
47
+ params: Record<string, string>,
48
+ origin = 'http://localhost:9999'
49
+ ): Request {
50
+ const search = new URLSearchParams(params);
51
+ return new Request(`${origin}/authorize?${search}`);
52
+ }
53
+
54
+ /** Build a POST /authorize form submission with the given params. */
55
+ function authorizePost(
56
+ params: Record<string, string>,
57
+ origin = 'http://localhost:9999'
58
+ ): Request {
59
+ const body = new URLSearchParams(params).toString();
60
+ return new Request(`${origin}/authorize`, {
61
+ method: 'POST',
62
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
63
+ body,
64
+ });
65
+ }
66
+
67
+ /** Build a POST /token form submission. */
68
+ function tokenPost(
69
+ params: Record<string, string>,
70
+ origin = 'http://localhost:9999'
71
+ ): Request {
72
+ return new Request(`${origin}/token`, {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
75
+ body: new URLSearchParams(params).toString(),
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Generate a valid PKCE pair: a 64-char unreserved verifier and its S256
81
+ * challenge. The verifier characters are drawn from the RFC 7636 set
82
+ * `[A-Za-z0-9-._~]`.
83
+ */
84
+ function makePkcePair(): { verifier: string; challenge: string } {
85
+ const verifier = 'a'.repeat(64); // 64 chars, all valid
86
+ const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url');
87
+ return { verifier, challenge };
88
+ }
89
+
90
+ /**
91
+ * End-to-end helper: register a client, walk POST /authorize, return the
92
+ * issued code + the PKCE verifier needed at /token.
93
+ */
94
+ async function getAuthCode(
95
+ ctx: ServerContext,
96
+ apiKey: string = TEST_API_KEY
97
+ ): Promise<{
98
+ clientId: string;
99
+ redirectUri: string;
100
+ code: string;
101
+ verifier: string;
102
+ }> {
103
+ const clientId = await registerTestClient(ctx);
104
+ const redirectUri = 'http://localhost:53682/callback';
105
+ const { verifier, challenge } = makePkcePair();
106
+ const res = await handleAuthorizePost(
107
+ authorizePost({
108
+ client_id: clientId,
109
+ redirect_uri: redirectUri,
110
+ response_type: 'code',
111
+ code_challenge: challenge,
112
+ code_challenge_method: 'S256',
113
+ state: '',
114
+ scope: 'mcp',
115
+ api_key: apiKey,
116
+ }),
117
+ ctx
118
+ );
119
+ if (res.status !== 302) {
120
+ throw new Error(`expected 302, got ${res.status}: ${await res.text()}`);
121
+ }
122
+ const code = new URL(res.headers.get('location')!).searchParams.get('code')!;
123
+ return { clientId, redirectUri, code, verifier };
124
+ }
125
+
126
+ function postJson(path: string, body: unknown): Request {
127
+ return new Request(`http://localhost:9999${path}`, {
128
+ method: 'POST',
129
+ headers: { 'content-type': 'application/json' },
130
+ body: JSON.stringify(body),
131
+ });
132
+ }
133
+
134
+ /* ------------------------------------------------------------------ */
135
+ /* getRequestOrigin */
136
+ /* ------------------------------------------------------------------ */
137
+
138
+ describe('getRequestOrigin', () => {
139
+ test('falls back to the Request URL when no proxy headers are set', () => {
140
+ const req = new Request('http://localhost:9999/whatever');
141
+ expect(getRequestOrigin(req)).toBe('http://localhost:9999');
142
+ });
143
+
144
+ test('strips path and query from the Request URL', () => {
145
+ const req = new Request('https://example.com:8443/foo/bar?baz=qux');
146
+ expect(getRequestOrigin(req)).toBe('https://example.com:8443');
147
+ });
148
+
149
+ test('honors X-Forwarded-Proto + X-Forwarded-Host', () => {
150
+ const req = new Request('http://10.0.0.5:9999/x', {
151
+ headers: {
152
+ 'x-forwarded-proto': 'https',
153
+ 'x-forwarded-host': 'agentio.example.com',
154
+ },
155
+ });
156
+ expect(getRequestOrigin(req)).toBe('https://agentio.example.com');
157
+ });
158
+
159
+ test('Forwarded: header (RFC 7239) overrides X-Forwarded-*', () => {
160
+ const req = new Request('http://10.0.0.5:9999/x', {
161
+ headers: {
162
+ forwarded: 'proto=https;host=agentio.example.com',
163
+ 'x-forwarded-proto': 'http',
164
+ 'x-forwarded-host': 'wrong.example.com',
165
+ },
166
+ });
167
+ expect(getRequestOrigin(req)).toBe('https://agentio.example.com');
168
+ });
169
+
170
+ test('Forwarded: header with quoted host value', () => {
171
+ const req = new Request('http://10.0.0.5:9999/x', {
172
+ headers: {
173
+ forwarded: 'proto=https;host="agentio.example.com:8443"',
174
+ },
175
+ });
176
+ expect(getRequestOrigin(req)).toBe('https://agentio.example.com:8443');
177
+ });
178
+
179
+ test('Forwarded: with only proto (no host) falls back to next strategy', () => {
180
+ const req = new Request('http://10.0.0.5:9999/x', {
181
+ headers: {
182
+ forwarded: 'proto=https',
183
+ 'x-forwarded-proto': 'https',
184
+ 'x-forwarded-host': 'fallback.example.com',
185
+ },
186
+ });
187
+ expect(getRequestOrigin(req)).toBe('https://fallback.example.com');
188
+ });
189
+
190
+ test('only one of X-Forwarded-Proto / X-Forwarded-Host falls back to URL', () => {
191
+ const req = new Request('http://10.0.0.5:9999/x', {
192
+ headers: { 'x-forwarded-proto': 'https' },
193
+ });
194
+ expect(getRequestOrigin(req)).toBe('http://10.0.0.5:9999');
195
+ });
196
+
197
+ test('preserves IPv6 host literals from the URL', () => {
198
+ const req = new Request('http://[::1]:9999/x');
199
+ expect(getRequestOrigin(req)).toBe('http://[::1]:9999');
200
+ });
201
+ });
202
+
203
+ /* ------------------------------------------------------------------ */
204
+ /* metadata documents */
205
+ /* ------------------------------------------------------------------ */
206
+
207
+ describe('buildProtectedResourceMetadata', () => {
208
+ test('contains required RFC 9728 fields', () => {
209
+ const req = new Request('http://localhost:9999/.well-known/oauth-protected-resource');
210
+ const m = buildProtectedResourceMetadata(req) as Record<string, unknown>;
211
+ expect(m.resource).toBe('http://localhost:9999/mcp');
212
+ expect(m.authorization_servers).toEqual(['http://localhost:9999']);
213
+ expect(m.bearer_methods_supported).toEqual(['header']);
214
+ expect(m.scopes_supported).toEqual(['mcp']);
215
+ });
216
+
217
+ test('uses the proxied origin when X-Forwarded-* is present', () => {
218
+ const req = new Request('http://10.0.0.5:9999/.well-known/oauth-protected-resource', {
219
+ headers: {
220
+ 'x-forwarded-proto': 'https',
221
+ 'x-forwarded-host': 'agentio.example.com',
222
+ },
223
+ });
224
+ const m = buildProtectedResourceMetadata(req) as Record<string, unknown>;
225
+ expect(m.resource).toBe('https://agentio.example.com/mcp');
226
+ expect(m.authorization_servers).toEqual(['https://agentio.example.com']);
227
+ });
228
+ });
229
+
230
+ describe('buildAuthorizationServerMetadata', () => {
231
+ test('contains the four RFC 8414 endpoint URLs', () => {
232
+ const req = new Request('http://localhost:9999/.well-known/oauth-authorization-server');
233
+ const m = buildAuthorizationServerMetadata(req) as Record<string, unknown>;
234
+ expect(m.issuer).toBe('http://localhost:9999');
235
+ expect(m.authorization_endpoint).toBe('http://localhost:9999/authorize');
236
+ expect(m.token_endpoint).toBe('http://localhost:9999/token');
237
+ expect(m.registration_endpoint).toBe('http://localhost:9999/register');
238
+ });
239
+
240
+ test('advertises authorization_code grant + S256 PKCE only', () => {
241
+ const req = new Request('http://localhost:9999/.well-known/oauth-authorization-server');
242
+ const m = buildAuthorizationServerMetadata(req) as Record<string, unknown>;
243
+ expect(m.response_types_supported).toEqual(['code']);
244
+ expect(m.grant_types_supported).toEqual(['authorization_code']);
245
+ expect(m.code_challenge_methods_supported).toEqual(['S256']);
246
+ });
247
+
248
+ test('advertises public client / no client_secret', () => {
249
+ const req = new Request('http://localhost:9999/.well-known/oauth-authorization-server');
250
+ const m = buildAuthorizationServerMetadata(req) as Record<string, unknown>;
251
+ expect(m.token_endpoint_auth_methods_supported).toEqual(['none']);
252
+ });
253
+
254
+ test('endpoint URLs follow the proxied origin', () => {
255
+ const req = new Request('http://10.0.0.5:9999/.well-known/oauth-authorization-server', {
256
+ headers: {
257
+ forwarded: 'proto=https;host=agentio.example.com',
258
+ },
259
+ });
260
+ const m = buildAuthorizationServerMetadata(req) as Record<string, unknown>;
261
+ expect(m.issuer).toBe('https://agentio.example.com');
262
+ expect(m.authorization_endpoint).toBe('https://agentio.example.com/authorize');
263
+ expect(m.token_endpoint).toBe('https://agentio.example.com/token');
264
+ expect(m.registration_endpoint).toBe('https://agentio.example.com/register');
265
+ });
266
+ });
267
+
268
+ /* ------------------------------------------------------------------ */
269
+ /* handleRegister (DCR) */
270
+ /* ------------------------------------------------------------------ */
271
+
272
+ describe('handleRegister — happy path', () => {
273
+ test('registers a client and returns 201 + RFC 7591 fields', async () => {
274
+ const ctx = makeCtx();
275
+ const res = await handleRegister(
276
+ postJson('/register', {
277
+ client_name: 'Claude Code',
278
+ redirect_uris: ['http://localhost:53682/callback'],
279
+ }),
280
+ ctx
281
+ );
282
+ expect(res.status).toBe(201);
283
+ const body = (await res.json()) as Record<string, unknown>;
284
+ expect(body.client_id).toMatch(/^cli_/);
285
+ expect(body.client_name).toBe('Claude Code');
286
+ expect(body.redirect_uris).toEqual(['http://localhost:53682/callback']);
287
+ expect(body.grant_types).toEqual(['authorization_code']);
288
+ expect(body.response_types).toEqual(['code']);
289
+ expect(body.token_endpoint_auth_method).toBe('none');
290
+ expect(typeof body.client_id_issued_at).toBe('number');
291
+ // The client should now be findable in the store.
292
+ expect(ctx.oauthStore.findClient(body.client_id as string)).toBeDefined();
293
+ });
294
+
295
+ test('registers without client_name (it is optional)', async () => {
296
+ const ctx = makeCtx();
297
+ const res = await handleRegister(
298
+ postJson('/register', {
299
+ redirect_uris: ['http://localhost/cb'],
300
+ }),
301
+ ctx
302
+ );
303
+ expect(res.status).toBe(201);
304
+ const body = (await res.json()) as Record<string, unknown>;
305
+ expect(body.client_id).toMatch(/^cli_/);
306
+ expect(body.client_name).toBeUndefined();
307
+ });
308
+
309
+ test('multiple redirect_uris are all stored', async () => {
310
+ const ctx = makeCtx();
311
+ const res = await handleRegister(
312
+ postJson('/register', {
313
+ redirect_uris: [
314
+ 'http://localhost:53682/cb1',
315
+ 'https://example.com/cb2',
316
+ ],
317
+ }),
318
+ ctx
319
+ );
320
+ expect(res.status).toBe(201);
321
+ const body = (await res.json()) as Record<string, unknown>;
322
+ expect(body.redirect_uris).toEqual([
323
+ 'http://localhost:53682/cb1',
324
+ 'https://example.com/cb2',
325
+ ]);
326
+ });
327
+
328
+ test('issued at is a unix epoch SECONDS value, not ms', async () => {
329
+ const before = Math.floor(Date.now() / 1000);
330
+ const ctx = makeCtx();
331
+ const res = await handleRegister(
332
+ postJson('/register', { redirect_uris: ['http://x/cb'] }),
333
+ ctx
334
+ );
335
+ const body = (await res.json()) as Record<string, unknown>;
336
+ const after = Math.floor(Date.now() / 1000);
337
+ const issued = body.client_id_issued_at as number;
338
+ expect(issued).toBeGreaterThanOrEqual(before);
339
+ expect(issued).toBeLessThanOrEqual(after);
340
+ });
341
+ });
342
+
343
+ describe('handleRegister — adversarial inputs', () => {
344
+ test('non-JSON body → 400 invalid_client_metadata', async () => {
345
+ const ctx = makeCtx();
346
+ const req = new Request('http://localhost:9999/register', {
347
+ method: 'POST',
348
+ headers: { 'content-type': 'application/json' },
349
+ body: 'not valid json {',
350
+ });
351
+ const res = await handleRegister(req, ctx);
352
+ expect(res.status).toBe(400);
353
+ const body = (await res.json()) as Record<string, unknown>;
354
+ expect(body.error).toBe('invalid_client_metadata');
355
+ });
356
+
357
+ test('empty body → 400', async () => {
358
+ const ctx = makeCtx();
359
+ const req = new Request('http://localhost:9999/register', {
360
+ method: 'POST',
361
+ headers: { 'content-type': 'application/json' },
362
+ body: '',
363
+ });
364
+ const res = await handleRegister(req, ctx);
365
+ expect(res.status).toBe(400);
366
+ });
367
+
368
+ test('JSON body that is an array → 400 (must be an object)', async () => {
369
+ const ctx = makeCtx();
370
+ const res = await handleRegister(postJson('/register', []), ctx);
371
+ expect(res.status).toBe(400);
372
+ const body = (await res.json()) as Record<string, unknown>;
373
+ expect(body.error).toBe('invalid_client_metadata');
374
+ });
375
+
376
+ test('JSON body that is null → 400', async () => {
377
+ const ctx = makeCtx();
378
+ const res = await handleRegister(postJson('/register', null), ctx);
379
+ expect(res.status).toBe(400);
380
+ });
381
+
382
+ test('missing redirect_uris → 400 invalid_redirect_uri', async () => {
383
+ const ctx = makeCtx();
384
+ const res = await handleRegister(
385
+ postJson('/register', { client_name: 'x' }),
386
+ ctx
387
+ );
388
+ expect(res.status).toBe(400);
389
+ const body = (await res.json()) as Record<string, unknown>;
390
+ expect(body.error).toBe('invalid_redirect_uri');
391
+ });
392
+
393
+ test('redirect_uris is empty array → 400', async () => {
394
+ const ctx = makeCtx();
395
+ const res = await handleRegister(
396
+ postJson('/register', { redirect_uris: [] }),
397
+ ctx
398
+ );
399
+ expect(res.status).toBe(400);
400
+ const body = (await res.json()) as Record<string, unknown>;
401
+ expect(body.error).toBe('invalid_redirect_uri');
402
+ });
403
+
404
+ test('redirect_uris is not an array → 400', async () => {
405
+ const ctx = makeCtx();
406
+ const res = await handleRegister(
407
+ postJson('/register', { redirect_uris: 'http://x/cb' }),
408
+ ctx
409
+ );
410
+ expect(res.status).toBe(400);
411
+ });
412
+
413
+ test('redirect_uri entry is not a string → 400', async () => {
414
+ const ctx = makeCtx();
415
+ const res = await handleRegister(
416
+ postJson('/register', { redirect_uris: [123, 'http://x/cb'] }),
417
+ ctx
418
+ );
419
+ expect(res.status).toBe(400);
420
+ });
421
+
422
+ test('redirect_uri entry is empty string → 400', async () => {
423
+ const ctx = makeCtx();
424
+ const res = await handleRegister(
425
+ postJson('/register', { redirect_uris: [''] }),
426
+ ctx
427
+ );
428
+ expect(res.status).toBe(400);
429
+ });
430
+
431
+ test('redirect_uri entry is malformed → 400', async () => {
432
+ const ctx = makeCtx();
433
+ const res = await handleRegister(
434
+ postJson('/register', { redirect_uris: ['not a url'] }),
435
+ ctx
436
+ );
437
+ expect(res.status).toBe(400);
438
+ });
439
+
440
+ test('redirect_uri scheme javascript: → 400', async () => {
441
+ const ctx = makeCtx();
442
+ const res = await handleRegister(
443
+ postJson('/register', {
444
+ redirect_uris: ['javascript:alert(1)'],
445
+ }),
446
+ ctx
447
+ );
448
+ expect(res.status).toBe(400);
449
+ const body = (await res.json()) as Record<string, unknown>;
450
+ expect(body.error).toBe('invalid_redirect_uri');
451
+ });
452
+
453
+ test('redirect_uri scheme data: → 400', async () => {
454
+ const ctx = makeCtx();
455
+ const res = await handleRegister(
456
+ postJson('/register', {
457
+ redirect_uris: ['data:text/html,<script>alert(1)</script>'],
458
+ }),
459
+ ctx
460
+ );
461
+ expect(res.status).toBe(400);
462
+ });
463
+
464
+ test('redirect_uri scheme file: → 400', async () => {
465
+ const ctx = makeCtx();
466
+ const res = await handleRegister(
467
+ postJson('/register', { redirect_uris: ['file:///etc/passwd'] }),
468
+ ctx
469
+ );
470
+ expect(res.status).toBe(400);
471
+ });
472
+
473
+ test('client_name is not a string → 400', async () => {
474
+ const ctx = makeCtx();
475
+ const res = await handleRegister(
476
+ postJson('/register', {
477
+ client_name: 12345,
478
+ redirect_uris: ['http://x/cb'],
479
+ }),
480
+ ctx
481
+ );
482
+ expect(res.status).toBe(400);
483
+ });
484
+
485
+ test('client_name >200 chars → 400', async () => {
486
+ const ctx = makeCtx();
487
+ const res = await handleRegister(
488
+ postJson('/register', {
489
+ client_name: 'x'.repeat(201),
490
+ redirect_uris: ['http://x/cb'],
491
+ }),
492
+ ctx
493
+ );
494
+ expect(res.status).toBe(400);
495
+ });
496
+
497
+ test('client_name exactly 200 chars → 201 (boundary)', async () => {
498
+ const ctx = makeCtx();
499
+ const res = await handleRegister(
500
+ postJson('/register', {
501
+ client_name: 'x'.repeat(200),
502
+ redirect_uris: ['http://x/cb'],
503
+ }),
504
+ ctx
505
+ );
506
+ expect(res.status).toBe(201);
507
+ });
508
+ });
509
+
510
+ describe('handleRegister — store integration', () => {
511
+ test('two registrations produce distinct client_ids', async () => {
512
+ const ctx = makeCtx();
513
+ const res1 = await handleRegister(
514
+ postJson('/register', { redirect_uris: ['http://a/cb'] }),
515
+ ctx
516
+ );
517
+ const res2 = await handleRegister(
518
+ postJson('/register', { redirect_uris: ['http://b/cb'] }),
519
+ ctx
520
+ );
521
+ const body1 = (await res1.json()) as Record<string, unknown>;
522
+ const body2 = (await res2.json()) as Record<string, unknown>;
523
+ expect(body1.client_id).not.toBe(body2.client_id);
524
+ expect(ctx.oauthStore.listClients()).toHaveLength(2);
525
+ });
526
+
527
+ test('failed validation does NOT add a client to the store', async () => {
528
+ const ctx = makeCtx();
529
+ await handleRegister(
530
+ postJson('/register', { redirect_uris: ['javascript:alert(1)'] }),
531
+ ctx
532
+ );
533
+ expect(ctx.oauthStore.listClients()).toHaveLength(0);
534
+ });
535
+ });
536
+
537
+ /* ------------------------------------------------------------------ */
538
+ /* constantTimeEquals */
539
+ /* ------------------------------------------------------------------ */
540
+
541
+ describe('constantTimeEquals', () => {
542
+ test('identical strings → true', () => {
543
+ expect(constantTimeEquals('abc', 'abc')).toBe(true);
544
+ });
545
+
546
+ test('different strings of equal length → false', () => {
547
+ expect(constantTimeEquals('abc', 'abd')).toBe(false);
548
+ });
549
+
550
+ test('different lengths → false (no crash)', () => {
551
+ expect(constantTimeEquals('abc', 'abcd')).toBe(false);
552
+ expect(constantTimeEquals('', 'a')).toBe(false);
553
+ });
554
+
555
+ test('empty strings → true', () => {
556
+ expect(constantTimeEquals('', '')).toBe(true);
557
+ });
558
+
559
+ test('unicode strings work', () => {
560
+ expect(constantTimeEquals('héllo', 'héllo')).toBe(true);
561
+ expect(constantTimeEquals('héllo', 'hello')).toBe(false);
562
+ });
563
+ });
564
+
565
+ /* ------------------------------------------------------------------ */
566
+ /* handleAuthorizeGet */
567
+ /* ------------------------------------------------------------------ */
568
+
569
+ describe('handleAuthorizeGet — happy path', () => {
570
+ test('renders an HTML form with the API key field', async () => {
571
+ const ctx = makeCtx();
572
+ const clientId = await registerTestClient(ctx);
573
+ const res = await handleAuthorizeGet(
574
+ authorizeGet({
575
+ client_id: clientId,
576
+ redirect_uri: 'http://localhost:53682/callback',
577
+ response_type: 'code',
578
+ code_challenge: 'CHALLENGE',
579
+ code_challenge_method: 'S256',
580
+ state: 'STATE_VALUE',
581
+ scope: 'gchat:default',
582
+ }),
583
+ ctx
584
+ );
585
+ expect(res.status).toBe(200);
586
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
587
+ const html = await res.text();
588
+ expect(html).toContain('<form method="post" action="/authorize">');
589
+ expect(html).toContain('name="api_key"');
590
+ expect(html).toContain('Claude Code');
591
+ expect(html).toContain('gchat:default');
592
+ });
593
+
594
+ test('preserves all OAuth params as hidden inputs', async () => {
595
+ const ctx = makeCtx();
596
+ const clientId = await registerTestClient(ctx);
597
+ const res = await handleAuthorizeGet(
598
+ authorizeGet({
599
+ client_id: clientId,
600
+ redirect_uri: 'http://localhost:53682/callback',
601
+ response_type: 'code',
602
+ code_challenge: 'CHALLENGE_PRESERVED',
603
+ code_challenge_method: 'S256',
604
+ state: 'STATE_PRESERVED',
605
+ scope: 'mcp',
606
+ }),
607
+ ctx
608
+ );
609
+ const html = await res.text();
610
+ expect(html).toContain(`value="${clientId}"`);
611
+ expect(html).toContain('value="CHALLENGE_PRESERVED"');
612
+ expect(html).toContain('value="STATE_PRESERVED"');
613
+ expect(html).toContain('value="S256"');
614
+ });
615
+
616
+ test('escapes hostile state values to prevent reflected XSS', async () => {
617
+ const ctx = makeCtx();
618
+ const clientId = await registerTestClient(ctx);
619
+ const res = await handleAuthorizeGet(
620
+ authorizeGet({
621
+ client_id: clientId,
622
+ redirect_uri: 'http://localhost:53682/callback',
623
+ response_type: 'code',
624
+ code_challenge: 'X',
625
+ code_challenge_method: 'S256',
626
+ state: '"><script>alert(1)</script>',
627
+ scope: '',
628
+ }),
629
+ ctx
630
+ );
631
+ const html = await res.text();
632
+ expect(html).not.toContain('<script>alert(1)</script>');
633
+ expect(html).toContain('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;');
634
+ });
635
+
636
+ test('escapes hostile scope values', async () => {
637
+ const ctx = makeCtx();
638
+ const clientId = await registerTestClient(ctx);
639
+ const res = await handleAuthorizeGet(
640
+ authorizeGet({
641
+ client_id: clientId,
642
+ redirect_uri: 'http://localhost:53682/callback',
643
+ response_type: 'code',
644
+ code_challenge: 'X',
645
+ code_challenge_method: 'S256',
646
+ state: '',
647
+ scope: '<img src=x onerror=alert(1)>',
648
+ }),
649
+ ctx
650
+ );
651
+ const html = await res.text();
652
+ expect(html).not.toContain('<img src=x onerror=alert(1)>');
653
+ expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
654
+ });
655
+
656
+ test('escapes hostile client_name (set at DCR time)', async () => {
657
+ const ctx = makeCtx();
658
+ const c = await ctx.oauthStore.registerClient({
659
+ clientName: '<script>alert("XSS")</script>',
660
+ redirectUris: ['http://localhost/cb'],
661
+ });
662
+ const res = await handleAuthorizeGet(
663
+ authorizeGet({
664
+ client_id: c.clientId,
665
+ redirect_uri: 'http://localhost/cb',
666
+ response_type: 'code',
667
+ code_challenge: 'X',
668
+ code_challenge_method: 'S256',
669
+ state: '',
670
+ scope: '',
671
+ }),
672
+ ctx
673
+ );
674
+ const html = await res.text();
675
+ expect(html).not.toContain('<script>alert("XSS")</script>');
676
+ expect(html).toContain('&lt;script&gt;');
677
+ });
678
+ });
679
+
680
+ describe('handleAuthorizeGet — validation errors', () => {
681
+ test('missing client_id → 400 invalid_request', async () => {
682
+ const ctx = makeCtx();
683
+ const res = await handleAuthorizeGet(
684
+ authorizeGet({
685
+ redirect_uri: 'http://localhost/cb',
686
+ response_type: 'code',
687
+ code_challenge: 'X',
688
+ code_challenge_method: 'S256',
689
+ }),
690
+ ctx
691
+ );
692
+ expect(res.status).toBe(400);
693
+ const body = (await res.json()) as Record<string, unknown>;
694
+ expect(body.error).toBe('invalid_request');
695
+ });
696
+
697
+ test('unknown client_id → 400 invalid_client', async () => {
698
+ const ctx = makeCtx();
699
+ const res = await handleAuthorizeGet(
700
+ authorizeGet({
701
+ client_id: 'cli_does_not_exist',
702
+ redirect_uri: 'http://localhost/cb',
703
+ response_type: 'code',
704
+ code_challenge: 'X',
705
+ code_challenge_method: 'S256',
706
+ }),
707
+ ctx
708
+ );
709
+ expect(res.status).toBe(400);
710
+ const body = (await res.json()) as Record<string, unknown>;
711
+ expect(body.error).toBe('invalid_client');
712
+ });
713
+
714
+ test('redirect_uri not in registered list → 400 invalid_request', async () => {
715
+ const ctx = makeCtx();
716
+ const clientId = await registerTestClient(ctx);
717
+ const res = await handleAuthorizeGet(
718
+ authorizeGet({
719
+ client_id: clientId,
720
+ redirect_uri: 'http://attacker.example.com/cb',
721
+ response_type: 'code',
722
+ code_challenge: 'X',
723
+ code_challenge_method: 'S256',
724
+ }),
725
+ ctx
726
+ );
727
+ expect(res.status).toBe(400);
728
+ const body = (await res.json()) as Record<string, unknown>;
729
+ expect(body.error).toBe('invalid_request');
730
+ });
731
+
732
+ test('response_type ≠ code → 400', async () => {
733
+ const ctx = makeCtx();
734
+ const clientId = await registerTestClient(ctx);
735
+ const res = await handleAuthorizeGet(
736
+ authorizeGet({
737
+ client_id: clientId,
738
+ redirect_uri: 'http://localhost:53682/callback',
739
+ response_type: 'token', // implicit flow not supported
740
+ code_challenge: 'X',
741
+ code_challenge_method: 'S256',
742
+ }),
743
+ ctx
744
+ );
745
+ expect(res.status).toBe(400);
746
+ });
747
+
748
+ test('missing code_challenge → 400 (PKCE required)', async () => {
749
+ const ctx = makeCtx();
750
+ const clientId = await registerTestClient(ctx);
751
+ const res = await handleAuthorizeGet(
752
+ authorizeGet({
753
+ client_id: clientId,
754
+ redirect_uri: 'http://localhost:53682/callback',
755
+ response_type: 'code',
756
+ code_challenge_method: 'S256',
757
+ }),
758
+ ctx
759
+ );
760
+ expect(res.status).toBe(400);
761
+ });
762
+
763
+ test('code_challenge_method ≠ S256 → 400', async () => {
764
+ const ctx = makeCtx();
765
+ const clientId = await registerTestClient(ctx);
766
+ const res = await handleAuthorizeGet(
767
+ authorizeGet({
768
+ client_id: clientId,
769
+ redirect_uri: 'http://localhost:53682/callback',
770
+ response_type: 'code',
771
+ code_challenge: 'X',
772
+ code_challenge_method: 'plain', // explicitly disallowed
773
+ }),
774
+ ctx
775
+ );
776
+ expect(res.status).toBe(400);
777
+ });
778
+ });
779
+
780
+ /* ------------------------------------------------------------------ */
781
+ /* handleAuthorizePost */
782
+ /* ------------------------------------------------------------------ */
783
+
784
+ describe('handleAuthorizePost — happy path', () => {
785
+ test('valid API key → 302 to redirect_uri with code + state', async () => {
786
+ const ctx = makeCtx();
787
+ const clientId = await registerTestClient(ctx);
788
+ const res = await handleAuthorizePost(
789
+ authorizePost({
790
+ client_id: clientId,
791
+ redirect_uri: 'http://localhost:53682/callback',
792
+ response_type: 'code',
793
+ code_challenge: 'CHAL',
794
+ code_challenge_method: 'S256',
795
+ state: 'STATE_XYZ',
796
+ scope: 'gchat:default',
797
+ api_key: TEST_API_KEY,
798
+ }),
799
+ ctx
800
+ );
801
+ expect(res.status).toBe(302);
802
+ const location = res.headers.get('location');
803
+ expect(location).toBeDefined();
804
+ const url = new URL(location!);
805
+ expect(url.origin + url.pathname).toBe(
806
+ 'http://localhost:53682/callback'
807
+ );
808
+ expect(url.searchParams.get('code')).toMatch(/^[A-Za-z0-9_-]+$/);
809
+ expect(url.searchParams.get('state')).toBe('STATE_XYZ');
810
+ });
811
+
812
+ test('issued code is consumable from the store', async () => {
813
+ const ctx = makeCtx();
814
+ const clientId = await registerTestClient(ctx);
815
+ const res = await handleAuthorizePost(
816
+ authorizePost({
817
+ client_id: clientId,
818
+ redirect_uri: 'http://localhost:53682/callback',
819
+ response_type: 'code',
820
+ code_challenge: 'CHAL',
821
+ code_challenge_method: 'S256',
822
+ state: '',
823
+ scope: 'gchat:default',
824
+ api_key: TEST_API_KEY,
825
+ }),
826
+ ctx
827
+ );
828
+ const code = new URL(res.headers.get('location')!).searchParams.get(
829
+ 'code'
830
+ )!;
831
+
832
+ const consumed = ctx.oauthStore.consumeCode(code);
833
+ expect(consumed).toBeDefined();
834
+ expect(consumed!.clientId).toBe(clientId);
835
+ expect(consumed!.codeChallenge).toBe('CHAL');
836
+ expect(consumed!.scope).toBe('gchat:default');
837
+ expect(consumed!.redirectUri).toBe('http://localhost:53682/callback');
838
+ });
839
+
840
+ test('missing state in form → no state in redirect URL', async () => {
841
+ const ctx = makeCtx();
842
+ const clientId = await registerTestClient(ctx);
843
+ const res = await handleAuthorizePost(
844
+ authorizePost({
845
+ client_id: clientId,
846
+ redirect_uri: 'http://localhost:53682/callback',
847
+ response_type: 'code',
848
+ code_challenge: 'CHAL',
849
+ code_challenge_method: 'S256',
850
+ state: '',
851
+ scope: 'mcp',
852
+ api_key: TEST_API_KEY,
853
+ }),
854
+ ctx
855
+ );
856
+ expect(res.status).toBe(302);
857
+ const url = new URL(res.headers.get('location')!);
858
+ expect(url.searchParams.has('state')).toBe(false);
859
+ });
860
+ });
861
+
862
+ describe('handleAuthorizePost — adversarial', () => {
863
+ test('wrong API key → re-renders form with error (NOT a redirect)', async () => {
864
+ const ctx = makeCtx();
865
+ const clientId = await registerTestClient(ctx);
866
+ const res = await handleAuthorizePost(
867
+ authorizePost({
868
+ client_id: clientId,
869
+ redirect_uri: 'http://localhost:53682/callback',
870
+ response_type: 'code',
871
+ code_challenge: 'CHAL',
872
+ code_challenge_method: 'S256',
873
+ state: '',
874
+ scope: '',
875
+ api_key: 'srv_wrong_key_value',
876
+ }),
877
+ ctx
878
+ );
879
+ expect(res.status).toBe(200);
880
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
881
+ const html = await res.text();
882
+ expect(html).toContain('Invalid API key');
883
+ // The form must be re-rendered with the original hidden inputs, so the
884
+ // user can re-submit with the right key.
885
+ expect(html).toContain('<form method="post" action="/authorize">');
886
+ expect(html).toContain('value="CHAL"');
887
+ });
888
+
889
+ test('empty API key → form re-rendered with error', async () => {
890
+ const ctx = makeCtx();
891
+ const clientId = await registerTestClient(ctx);
892
+ const res = await handleAuthorizePost(
893
+ authorizePost({
894
+ client_id: clientId,
895
+ redirect_uri: 'http://localhost:53682/callback',
896
+ response_type: 'code',
897
+ code_challenge: 'CHAL',
898
+ code_challenge_method: 'S256',
899
+ state: '',
900
+ scope: '',
901
+ api_key: '',
902
+ }),
903
+ ctx
904
+ );
905
+ expect(res.status).toBe(200);
906
+ expect(await res.text()).toContain('Invalid API key');
907
+ });
908
+
909
+ test('API key off by one character → form re-rendered with error', async () => {
910
+ const ctx = makeCtx();
911
+ const clientId = await registerTestClient(ctx);
912
+ const wrong = TEST_API_KEY.slice(0, -1) + 'X';
913
+ const res = await handleAuthorizePost(
914
+ authorizePost({
915
+ client_id: clientId,
916
+ redirect_uri: 'http://localhost:53682/callback',
917
+ response_type: 'code',
918
+ code_challenge: 'CHAL',
919
+ code_challenge_method: 'S256',
920
+ state: '',
921
+ scope: '',
922
+ api_key: wrong,
923
+ }),
924
+ ctx
925
+ );
926
+ expect(res.status).toBe(200);
927
+ expect(await res.text()).toContain('Invalid API key');
928
+ });
929
+
930
+ test('failed POST does NOT issue a code', async () => {
931
+ const ctx = makeCtx();
932
+ const clientId = await registerTestClient(ctx);
933
+ await handleAuthorizePost(
934
+ authorizePost({
935
+ client_id: clientId,
936
+ redirect_uri: 'http://localhost:53682/callback',
937
+ response_type: 'code',
938
+ code_challenge: 'CHAL',
939
+ code_challenge_method: 'S256',
940
+ state: '',
941
+ scope: '',
942
+ api_key: 'wrong',
943
+ }),
944
+ ctx
945
+ );
946
+ // No code was issued, so consuming any string returns undefined.
947
+ // (We can't easily enumerate codes from the store; this is a sanity
948
+ // check that the store has nothing newly added.)
949
+ expect(ctx.oauthStore.consumeCode('arbitrary')).toBeUndefined();
950
+ });
951
+
952
+ test('unknown client_id → 400, no form rendered', async () => {
953
+ const ctx = makeCtx();
954
+ const res = await handleAuthorizePost(
955
+ authorizePost({
956
+ client_id: 'cli_unknown',
957
+ redirect_uri: 'http://localhost/cb',
958
+ response_type: 'code',
959
+ code_challenge: 'X',
960
+ code_challenge_method: 'S256',
961
+ state: '',
962
+ scope: '',
963
+ api_key: TEST_API_KEY,
964
+ }),
965
+ ctx
966
+ );
967
+ expect(res.status).toBe(400);
968
+ });
969
+
970
+ test('mismatched redirect_uri → 400 (cannot inject attacker URL via POST)', async () => {
971
+ const ctx = makeCtx();
972
+ const clientId = await registerTestClient(ctx);
973
+ const res = await handleAuthorizePost(
974
+ authorizePost({
975
+ client_id: clientId,
976
+ redirect_uri: 'http://attacker.example.com/cb',
977
+ response_type: 'code',
978
+ code_challenge: 'X',
979
+ code_challenge_method: 'S256',
980
+ state: '',
981
+ scope: '',
982
+ api_key: TEST_API_KEY,
983
+ }),
984
+ ctx
985
+ );
986
+ expect(res.status).toBe(400);
987
+ });
988
+
989
+ test('redirect_uri with existing query string is preserved alongside ?code=', async () => {
990
+ const ctx = makeCtx();
991
+ await ctx.oauthStore.registerClient({
992
+ clientName: 'Test',
993
+ redirectUris: ['http://localhost:53682/callback?existing=1'],
994
+ });
995
+ const cid = ctx.oauthStore.listClients()[0].clientId;
996
+ const res = await handleAuthorizePost(
997
+ authorizePost({
998
+ client_id: cid,
999
+ redirect_uri: 'http://localhost:53682/callback?existing=1',
1000
+ response_type: 'code',
1001
+ code_challenge: 'X',
1002
+ code_challenge_method: 'S256',
1003
+ state: 'S',
1004
+ scope: '',
1005
+ api_key: TEST_API_KEY,
1006
+ }),
1007
+ ctx
1008
+ );
1009
+ expect(res.status).toBe(302);
1010
+ const location = new URL(res.headers.get('location')!);
1011
+ expect(location.searchParams.get('existing')).toBe('1');
1012
+ expect(location.searchParams.get('code')).toBeDefined();
1013
+ expect(location.searchParams.get('state')).toBe('S');
1014
+ });
1015
+ });
1016
+
1017
+ /* ------------------------------------------------------------------ */
1018
+ /* computeS256Challenge — RFC 7636 reference vector */
1019
+ /* ------------------------------------------------------------------ */
1020
+
1021
+ describe('computeS256Challenge', () => {
1022
+ test('matches the RFC 7636 Appendix B reference vector', () => {
1023
+ // From https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
1024
+ // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
1025
+ // challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
1026
+ expect(
1027
+ computeS256Challenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')
1028
+ ).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
1029
+ });
1030
+
1031
+ test('different verifiers produce different challenges', () => {
1032
+ expect(computeS256Challenge('a'.repeat(64))).not.toBe(
1033
+ computeS256Challenge('b'.repeat(64))
1034
+ );
1035
+ });
1036
+ });
1037
+
1038
+ /* ------------------------------------------------------------------ */
1039
+ /* handleToken — happy path */
1040
+ /* ------------------------------------------------------------------ */
1041
+
1042
+ describe('handleToken — happy path', () => {
1043
+ test('valid exchange returns access_token + token_type + expires_in', async () => {
1044
+ const ctx = makeCtx();
1045
+ const { clientId, redirectUri, code, verifier } = await getAuthCode(ctx);
1046
+
1047
+ const res = await handleToken(
1048
+ tokenPost({
1049
+ grant_type: 'authorization_code',
1050
+ code,
1051
+ client_id: clientId,
1052
+ redirect_uri: redirectUri,
1053
+ code_verifier: verifier,
1054
+ }),
1055
+ ctx
1056
+ );
1057
+ expect(res.status).toBe(200);
1058
+ const body = (await res.json()) as Record<string, unknown>;
1059
+ expect(body.access_token).toMatch(/^[A-Za-z0-9_-]+$/);
1060
+ expect((body.access_token as string).length).toBe(43);
1061
+ expect(body.token_type).toBe('Bearer');
1062
+ expect(body.expires_in).toBe(30 * 24 * 60 * 60);
1063
+ expect(body.scope).toBe('mcp');
1064
+ });
1065
+
1066
+ test('issued token is findable in the store', async () => {
1067
+ const ctx = makeCtx();
1068
+ const { clientId, redirectUri, code, verifier } = await getAuthCode(ctx);
1069
+ const res = await handleToken(
1070
+ tokenPost({
1071
+ grant_type: 'authorization_code',
1072
+ code,
1073
+ client_id: clientId,
1074
+ redirect_uri: redirectUri,
1075
+ code_verifier: verifier,
1076
+ }),
1077
+ ctx
1078
+ );
1079
+ const body = (await res.json()) as Record<string, unknown>;
1080
+ const t = ctx.oauthStore.findToken(body.access_token as string);
1081
+ expect(t).toBeDefined();
1082
+ expect(t!.clientId).toBe(clientId);
1083
+ expect(t!.scope).toBe('mcp');
1084
+ });
1085
+
1086
+ test('the auth code is one-shot — second use of the same code fails', async () => {
1087
+ const ctx = makeCtx();
1088
+ const { clientId, redirectUri, code, verifier } = await getAuthCode(ctx);
1089
+
1090
+ const first = await handleToken(
1091
+ tokenPost({
1092
+ grant_type: 'authorization_code',
1093
+ code,
1094
+ client_id: clientId,
1095
+ redirect_uri: redirectUri,
1096
+ code_verifier: verifier,
1097
+ }),
1098
+ ctx
1099
+ );
1100
+ expect(first.status).toBe(200);
1101
+
1102
+ const second = await handleToken(
1103
+ tokenPost({
1104
+ grant_type: 'authorization_code',
1105
+ code,
1106
+ client_id: clientId,
1107
+ redirect_uri: redirectUri,
1108
+ code_verifier: verifier,
1109
+ }),
1110
+ ctx
1111
+ );
1112
+ expect(second.status).toBe(400);
1113
+ const body = (await second.json()) as Record<string, unknown>;
1114
+ expect(body.error).toBe('invalid_grant');
1115
+ });
1116
+ });
1117
+
1118
+ /* ------------------------------------------------------------------ */
1119
+ /* handleToken — adversarial */
1120
+ /* ------------------------------------------------------------------ */
1121
+
1122
+ describe('handleToken — adversarial', () => {
1123
+ test('grant_type missing → 400 unsupported_grant_type', async () => {
1124
+ const ctx = makeCtx();
1125
+ const res = await handleToken(
1126
+ tokenPost({ code: 'x', client_id: 'y' }),
1127
+ ctx
1128
+ );
1129
+ expect(res.status).toBe(400);
1130
+ const body = (await res.json()) as Record<string, unknown>;
1131
+ expect(body.error).toBe('unsupported_grant_type');
1132
+ });
1133
+
1134
+ test('grant_type=password → 400 unsupported_grant_type', async () => {
1135
+ const ctx = makeCtx();
1136
+ const res = await handleToken(
1137
+ tokenPost({ grant_type: 'password' }),
1138
+ ctx
1139
+ );
1140
+ expect(res.status).toBe(400);
1141
+ const body = (await res.json()) as Record<string, unknown>;
1142
+ expect(body.error).toBe('unsupported_grant_type');
1143
+ });
1144
+
1145
+ test('grant_type=refresh_token → 400 unsupported_grant_type', async () => {
1146
+ const ctx = makeCtx();
1147
+ const res = await handleToken(
1148
+ tokenPost({
1149
+ grant_type: 'refresh_token',
1150
+ refresh_token: 'doesnt_matter',
1151
+ }),
1152
+ ctx
1153
+ );
1154
+ expect(res.status).toBe(400);
1155
+ });
1156
+
1157
+ test('missing code → 400 invalid_request', async () => {
1158
+ const ctx = makeCtx();
1159
+ const res = await handleToken(
1160
+ tokenPost({
1161
+ grant_type: 'authorization_code',
1162
+ client_id: 'cli_x',
1163
+ redirect_uri: 'http://x/cb',
1164
+ code_verifier: 'a'.repeat(64),
1165
+ }),
1166
+ ctx
1167
+ );
1168
+ expect(res.status).toBe(400);
1169
+ const body = (await res.json()) as Record<string, unknown>;
1170
+ expect(body.error).toBe('invalid_request');
1171
+ });
1172
+
1173
+ test('missing client_id → 400 invalid_request', async () => {
1174
+ const ctx = makeCtx();
1175
+ const { code, redirectUri, verifier } = await getAuthCode(ctx);
1176
+ const res = await handleToken(
1177
+ tokenPost({
1178
+ grant_type: 'authorization_code',
1179
+ code,
1180
+ redirect_uri: redirectUri,
1181
+ code_verifier: verifier,
1182
+ }),
1183
+ ctx
1184
+ );
1185
+ expect(res.status).toBe(400);
1186
+ });
1187
+
1188
+ test('missing redirect_uri → 400 invalid_request', async () => {
1189
+ const ctx = makeCtx();
1190
+ const { clientId, code, verifier } = await getAuthCode(ctx);
1191
+ const res = await handleToken(
1192
+ tokenPost({
1193
+ grant_type: 'authorization_code',
1194
+ code,
1195
+ client_id: clientId,
1196
+ code_verifier: verifier,
1197
+ }),
1198
+ ctx
1199
+ );
1200
+ expect(res.status).toBe(400);
1201
+ });
1202
+
1203
+ test('missing code_verifier → 400', async () => {
1204
+ const ctx = makeCtx();
1205
+ const { clientId, redirectUri, code } = await getAuthCode(ctx);
1206
+ const res = await handleToken(
1207
+ tokenPost({
1208
+ grant_type: 'authorization_code',
1209
+ code,
1210
+ client_id: clientId,
1211
+ redirect_uri: redirectUri,
1212
+ }),
1213
+ ctx
1214
+ );
1215
+ expect(res.status).toBe(400);
1216
+ });
1217
+
1218
+ test('code_verifier too short (<43 chars) → 400 invalid_grant', async () => {
1219
+ const ctx = makeCtx();
1220
+ const { clientId, redirectUri, code } = await getAuthCode(ctx);
1221
+ const res = await handleToken(
1222
+ tokenPost({
1223
+ grant_type: 'authorization_code',
1224
+ code,
1225
+ client_id: clientId,
1226
+ redirect_uri: redirectUri,
1227
+ code_verifier: 'a'.repeat(42),
1228
+ }),
1229
+ ctx
1230
+ );
1231
+ expect(res.status).toBe(400);
1232
+ const body = (await res.json()) as Record<string, unknown>;
1233
+ expect(body.error).toBe('invalid_grant');
1234
+ });
1235
+
1236
+ test('code_verifier too long (>128 chars) → 400 invalid_grant', async () => {
1237
+ const ctx = makeCtx();
1238
+ const { clientId, redirectUri, code } = await getAuthCode(ctx);
1239
+ const res = await handleToken(
1240
+ tokenPost({
1241
+ grant_type: 'authorization_code',
1242
+ code,
1243
+ client_id: clientId,
1244
+ redirect_uri: redirectUri,
1245
+ code_verifier: 'a'.repeat(129),
1246
+ }),
1247
+ ctx
1248
+ );
1249
+ expect(res.status).toBe(400);
1250
+ });
1251
+
1252
+ test('code_verifier with disallowed characters → 400 invalid_grant', async () => {
1253
+ const ctx = makeCtx();
1254
+ const { clientId, redirectUri, code } = await getAuthCode(ctx);
1255
+ const res = await handleToken(
1256
+ tokenPost({
1257
+ grant_type: 'authorization_code',
1258
+ code,
1259
+ client_id: clientId,
1260
+ redirect_uri: redirectUri,
1261
+ // contains '/' which is reserved
1262
+ code_verifier: '/'.repeat(64),
1263
+ }),
1264
+ ctx
1265
+ );
1266
+ expect(res.status).toBe(400);
1267
+ });
1268
+
1269
+ test('unknown code → 400 invalid_grant', async () => {
1270
+ const ctx = makeCtx();
1271
+ const clientId = await registerTestClient(ctx);
1272
+ const res = await handleToken(
1273
+ tokenPost({
1274
+ grant_type: 'authorization_code',
1275
+ code: 'completely_made_up_code',
1276
+ client_id: clientId,
1277
+ redirect_uri: 'http://localhost:53682/callback',
1278
+ code_verifier: 'a'.repeat(64),
1279
+ }),
1280
+ ctx
1281
+ );
1282
+ expect(res.status).toBe(400);
1283
+ const body = (await res.json()) as Record<string, unknown>;
1284
+ expect(body.error).toBe('invalid_grant');
1285
+ });
1286
+
1287
+ test('client_id does not match the one bound to the code → 400 invalid_grant', async () => {
1288
+ const ctx = makeCtx();
1289
+ const { code, redirectUri, verifier } = await getAuthCode(ctx);
1290
+ // Register a second client and try to use its id with the first's code.
1291
+ const otherClientId = await registerTestClient(
1292
+ ctx,
1293
+ 'http://localhost:53682/callback',
1294
+ 'Other Client'
1295
+ );
1296
+ const res = await handleToken(
1297
+ tokenPost({
1298
+ grant_type: 'authorization_code',
1299
+ code,
1300
+ client_id: otherClientId,
1301
+ redirect_uri: redirectUri,
1302
+ code_verifier: verifier,
1303
+ }),
1304
+ ctx
1305
+ );
1306
+ expect(res.status).toBe(400);
1307
+ const body = (await res.json()) as Record<string, unknown>;
1308
+ expect(body.error).toBe('invalid_grant');
1309
+ });
1310
+
1311
+ test('redirect_uri does not match the one used at /authorize → 400 invalid_grant', async () => {
1312
+ const ctx = makeCtx();
1313
+ const { clientId, code, verifier } = await getAuthCode(ctx);
1314
+ const res = await handleToken(
1315
+ tokenPost({
1316
+ grant_type: 'authorization_code',
1317
+ code,
1318
+ client_id: clientId,
1319
+ redirect_uri: 'http://different.example.com/cb',
1320
+ code_verifier: verifier,
1321
+ }),
1322
+ ctx
1323
+ );
1324
+ expect(res.status).toBe(400);
1325
+ });
1326
+
1327
+ test('wrong code_verifier (right format, wrong value) → 400 invalid_grant', async () => {
1328
+ const ctx = makeCtx();
1329
+ const { clientId, redirectUri, code } = await getAuthCode(ctx);
1330
+ const wrongVerifier = 'b'.repeat(64);
1331
+ const res = await handleToken(
1332
+ tokenPost({
1333
+ grant_type: 'authorization_code',
1334
+ code,
1335
+ client_id: clientId,
1336
+ redirect_uri: redirectUri,
1337
+ code_verifier: wrongVerifier,
1338
+ }),
1339
+ ctx
1340
+ );
1341
+ expect(res.status).toBe(400);
1342
+ const body = (await res.json()) as Record<string, unknown>;
1343
+ expect(body.error).toBe('invalid_grant');
1344
+ });
1345
+
1346
+ test('failed PKCE attempt still consumes the code (one-shot)', async () => {
1347
+ const ctx = makeCtx();
1348
+ const { clientId, redirectUri, code, verifier } = await getAuthCode(ctx);
1349
+
1350
+ // Attempt #1: wrong verifier — fails AND consumes the code.
1351
+ const wrong = await handleToken(
1352
+ tokenPost({
1353
+ grant_type: 'authorization_code',
1354
+ code,
1355
+ client_id: clientId,
1356
+ redirect_uri: redirectUri,
1357
+ code_verifier: 'b'.repeat(64),
1358
+ }),
1359
+ ctx
1360
+ );
1361
+ expect(wrong.status).toBe(400);
1362
+
1363
+ // Attempt #2: correct verifier — code is already gone.
1364
+ const right = await handleToken(
1365
+ tokenPost({
1366
+ grant_type: 'authorization_code',
1367
+ code,
1368
+ client_id: clientId,
1369
+ redirect_uri: redirectUri,
1370
+ code_verifier: verifier,
1371
+ }),
1372
+ ctx
1373
+ );
1374
+ expect(right.status).toBe(400);
1375
+ });
1376
+ });
1377
+
1378
+ /* ------------------------------------------------------------------ */
1379
+ /* requireBearer */
1380
+ /* ------------------------------------------------------------------ */
1381
+
1382
+ describe('requireBearer', () => {
1383
+ test('valid bearer → ok with the matched token', async () => {
1384
+ const ctx = makeCtx();
1385
+ const issued = await ctx.oauthStore.issueToken({
1386
+ clientId: 'cli_x',
1387
+ scope: 'mcp',
1388
+ });
1389
+ const result = requireBearer(
1390
+ new Request('http://localhost:9999/mcp', {
1391
+ headers: { authorization: `Bearer ${issued.token}` },
1392
+ }),
1393
+ ctx
1394
+ );
1395
+ expect(result.ok).toBe(true);
1396
+ if (result.ok) {
1397
+ expect(result.token!.token).toBe(issued.token);
1398
+ }
1399
+ });
1400
+
1401
+ test('case-insensitive Bearer scheme', async () => {
1402
+ const ctx = makeCtx();
1403
+ const issued = await ctx.oauthStore.issueToken({
1404
+ clientId: 'cli_x',
1405
+ scope: 'mcp',
1406
+ });
1407
+ const result = requireBearer(
1408
+ new Request('http://localhost:9999/mcp', {
1409
+ headers: { authorization: `bearer ${issued.token}` },
1410
+ }),
1411
+ ctx
1412
+ );
1413
+ expect(result.ok).toBe(true);
1414
+ });
1415
+
1416
+ test('missing Authorization header → 401 with WWW-Authenticate', async () => {
1417
+ const ctx = makeCtx();
1418
+ const result = requireBearer(
1419
+ new Request('http://localhost:9999/mcp'),
1420
+ ctx
1421
+ );
1422
+ expect(result.ok).toBe(false);
1423
+ if (!result.ok) {
1424
+ expect(result.response.status).toBe(401);
1425
+ const wwwAuth = result.response.headers.get('www-authenticate');
1426
+ expect(wwwAuth).toContain('Bearer');
1427
+ expect(wwwAuth).toContain(
1428
+ 'resource_metadata="http://localhost:9999/.well-known/oauth-protected-resource"'
1429
+ );
1430
+ }
1431
+ });
1432
+
1433
+ test('Authorization header with non-Bearer scheme → 401', async () => {
1434
+ const ctx = makeCtx();
1435
+ const result = requireBearer(
1436
+ new Request('http://localhost:9999/mcp', {
1437
+ headers: { authorization: 'Basic dXNlcjpwYXNz' },
1438
+ }),
1439
+ ctx
1440
+ );
1441
+ expect(result.ok).toBe(false);
1442
+ });
1443
+
1444
+ test('empty bearer (just "Bearer ") → 401', async () => {
1445
+ const ctx = makeCtx();
1446
+ const result = requireBearer(
1447
+ new Request('http://localhost:9999/mcp', {
1448
+ headers: { authorization: 'Bearer ' },
1449
+ }),
1450
+ ctx
1451
+ );
1452
+ expect(result.ok).toBe(false);
1453
+ });
1454
+
1455
+ test('unknown token → 401 with error_description', async () => {
1456
+ const ctx = makeCtx();
1457
+ const result = requireBearer(
1458
+ new Request('http://localhost:9999/mcp', {
1459
+ headers: { authorization: 'Bearer this_token_does_not_exist' },
1460
+ }),
1461
+ ctx
1462
+ );
1463
+ expect(result.ok).toBe(false);
1464
+ if (!result.ok) {
1465
+ const wwwAuth = result.response.headers.get('www-authenticate');
1466
+ expect(wwwAuth).toContain('error="invalid_token"');
1467
+ }
1468
+ });
1469
+
1470
+ test('revoked token → 401', async () => {
1471
+ const ctx = makeCtx();
1472
+ const issued = await ctx.oauthStore.issueToken({
1473
+ clientId: 'cli_x',
1474
+ scope: 'mcp',
1475
+ });
1476
+ await ctx.oauthStore.revokeToken(issued.token);
1477
+ const result = requireBearer(
1478
+ new Request('http://localhost:9999/mcp', {
1479
+ headers: { authorization: `Bearer ${issued.token}` },
1480
+ }),
1481
+ ctx
1482
+ );
1483
+ expect(result.ok).toBe(false);
1484
+ });
1485
+
1486
+ test('WWW-Authenticate metadata URL respects Forwarded header', async () => {
1487
+ const ctx = makeCtx();
1488
+ const result = requireBearer(
1489
+ new Request('http://10.0.0.5:9999/mcp', {
1490
+ headers: { forwarded: 'proto=https;host=agentio.example.com' },
1491
+ }),
1492
+ ctx
1493
+ );
1494
+ expect(result.ok).toBe(false);
1495
+ if (!result.ok) {
1496
+ const wwwAuth = result.response.headers.get('www-authenticate');
1497
+ expect(wwwAuth).toContain(
1498
+ 'https://agentio.example.com/.well-known/oauth-protected-resource'
1499
+ );
1500
+ }
1501
+ });
1502
+ });