@plosson/agentio 0.7.2 → 0.7.4
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/package.json +1 -1
- package/src/commands/config-import.test.ts +330 -0
- package/src/commands/config.ts +21 -2
- package/src/commands/mcp.ts +5 -0
- package/src/commands/server-tokens.test.ts +269 -0
- package/src/commands/server.ts +514 -0
- package/src/commands/teleport.test.ts +1462 -0
- package/src/commands/teleport.ts +882 -0
- package/src/index.ts +2 -0
- package/src/mcp/server.test.ts +89 -0
- package/src/mcp/server.ts +51 -30
- package/src/server/daemon.test.ts +637 -0
- package/src/server/daemon.ts +177 -0
- package/src/server/dockerfile-gen.test.ts +218 -0
- package/src/server/dockerfile-gen.ts +108 -0
- package/src/server/dockerfile-teleport.test.ts +184 -0
- package/src/server/http.test.ts +256 -0
- package/src/server/http.ts +54 -0
- package/src/server/mcp-adversarial.test.ts +643 -0
- package/src/server/mcp-e2e.test.ts +397 -0
- package/src/server/mcp-http.test.ts +364 -0
- package/src/server/mcp-http.ts +339 -0
- package/src/server/oauth-e2e.test.ts +466 -0
- package/src/server/oauth-store.test.ts +423 -0
- package/src/server/oauth-store.ts +216 -0
- package/src/server/oauth.test.ts +1502 -0
- package/src/server/oauth.ts +800 -0
- package/src/server/siteio-runner.test.ts +766 -0
- package/src/server/siteio-runner.ts +352 -0
- package/src/server/test-helpers.ts +201 -0
- package/src/types/config.ts +3 -0
- package/src/types/server.ts +61 -0
|
@@ -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('"><script>alert(1)</script>');
|
|
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('<img src=x onerror=alert(1)>');
|
|
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('<script>');
|
|
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
|
+
});
|