@openparachute/vault 0.4.8-rc.8 → 0.4.8

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/src/oauth.test.ts DELETED
@@ -1,2156 +0,0 @@
1
- /**
2
- * Tests for the OAuth 2.1 provider.
3
- */
4
-
5
- import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
- import { Database } from "bun:sqlite";
7
- import crypto from "node:crypto";
8
- import fs from "node:fs";
9
- import os from "node:os";
10
- import path from "node:path";
11
- import { initSchema } from "../core/src/schema.ts";
12
- import { generateToken, createToken, resolveToken } from "./token-store.ts";
13
- import {
14
- handleProtectedResource,
15
- handleAuthorizationServer,
16
- handleRegister,
17
- handleAuthorizeGet,
18
- handleAuthorizePost,
19
- handleToken,
20
- } from "./oauth.ts";
21
- import * as OTPAuth from "otpauth";
22
-
23
- let db: Database;
24
-
25
- beforeEach(() => {
26
- db = new Database(":memory:");
27
- initSchema(db);
28
- });
29
-
30
- afterEach(() => {
31
- db.close();
32
- });
33
-
34
- // ---------------------------------------------------------------------------
35
- // Helpers
36
- // ---------------------------------------------------------------------------
37
-
38
- function makeRequest(url: string, init?: RequestInit): Request {
39
- return new Request(url, init);
40
- }
41
-
42
- /** Generate a PKCE code_verifier and its S256 code_challenge. */
43
- function generatePkce(): { codeVerifier: string; codeChallenge: string } {
44
- const codeVerifier = crypto.randomBytes(32).toString("base64url");
45
- const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
46
- return { codeVerifier, codeChallenge };
47
- }
48
-
49
- /** Create a valid owner token in the DB and return the raw token string. */
50
- function createOwnerToken(): string {
51
- const { fullToken } = generateToken();
52
- createToken(db, fullToken, { label: "owner", permission: "full" });
53
- return fullToken;
54
- }
55
-
56
- /** Register a client and return the client_id. */
57
- async function registerClient(name = "Test Client", redirectUris = ["https://example.com/callback"]): Promise<string> {
58
- const req = makeRequest("https://vault.test/oauth/register", {
59
- method: "POST",
60
- headers: { "Content-Type": "application/json" },
61
- body: JSON.stringify({ client_name: name, redirect_uris: redirectUris }),
62
- });
63
- const res = await handleRegister(req, db);
64
- const body = await res.json();
65
- return body.client_id;
66
- }
67
-
68
- /** Run the full OAuth flow and return the access_token. */
69
- async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
70
- const ownerToken = createOwnerToken();
71
- const clientId = await registerClient();
72
- const { codeVerifier, codeChallenge } = generatePkce();
73
- const redirectUri = "https://example.com/callback";
74
- const scope = opts?.scope || "full";
75
-
76
- // POST authorize (simulate user clicking Authorize with valid owner token)
77
- const authReq = makeRequest("https://vault.test/oauth/authorize", {
78
- method: "POST",
79
- body: new URLSearchParams({
80
- action: "authorize",
81
- client_id: clientId,
82
- redirect_uri: redirectUri,
83
- code_challenge: codeChallenge,
84
- code_challenge_method: "S256",
85
- scope,
86
- state: "test-state",
87
- owner_token: ownerToken,
88
- }),
89
- });
90
- const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
91
- expect(authRes.status).toBe(302);
92
- const location = new URL(authRes.headers.get("location")!);
93
- const code = location.searchParams.get("code")!;
94
- expect(code).toBeTruthy();
95
-
96
- // Exchange code for token
97
- const tokenReq = makeRequest("https://vault.test/oauth/token", {
98
- method: "POST",
99
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
100
- body: new URLSearchParams({
101
- grant_type: "authorization_code",
102
- code,
103
- code_verifier: codeVerifier,
104
- client_id: clientId,
105
- redirect_uri: redirectUri,
106
- }).toString(),
107
- });
108
- const tokenRes = await handleToken(tokenReq, db, "default");
109
- const tokenBody = await tokenRes.json();
110
- return tokenBody.access_token;
111
- }
112
-
113
- // ---------------------------------------------------------------------------
114
- // Discovery
115
- // ---------------------------------------------------------------------------
116
-
117
- describe("OAuth discovery", () => {
118
- test("protected resource metadata", async () => {
119
- const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-protected-resource");
120
- const res = handleProtectedResource(req, "default");
121
- expect(res.status).toBe(200);
122
- const body = await res.json();
123
- expect(body.resource).toBe("https://vault.test/vault/default/mcp");
124
- expect(body.authorization_servers).toEqual(["https://vault.test/vault/default"]);
125
- expect(body.scopes_supported).toContain("full");
126
- expect(body.scopes_supported).toContain("read");
127
- });
128
-
129
- test("authorization server metadata", async () => {
130
- const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
131
- const res = handleAuthorizationServer(req, "default");
132
- expect(res.status).toBe(200);
133
- const body = await res.json();
134
- expect(body.issuer).toBe("https://vault.test/vault/default");
135
- expect(body.authorization_endpoint).toBe("https://vault.test/vault/default/oauth/authorize");
136
- expect(body.token_endpoint).toBe("https://vault.test/vault/default/oauth/token");
137
- expect(body.registration_endpoint).toBe("https://vault.test/vault/default/oauth/register");
138
- expect(body.code_challenge_methods_supported).toEqual(["S256"]);
139
- });
140
-
141
- test("resource URL reflects the vault name", async () => {
142
- const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-protected-resource");
143
- const res = handleProtectedResource(req, "work");
144
- const body = await res.json();
145
- expect(body.resource).toBe("https://vault.test/vault/work/mcp");
146
- });
147
-
148
- test("uses x-forwarded-proto and x-forwarded-host", async () => {
149
- const req = makeRequest("http://localhost:1940/vault/default/.well-known/oauth-protected-resource", {
150
- headers: {
151
- "x-forwarded-proto": "https",
152
- "x-forwarded-host": "vault.example.com",
153
- },
154
- });
155
- const res = handleProtectedResource(req, "default");
156
- const body = await res.json();
157
- expect(body.resource).toBe("https://vault.example.com/vault/default/mcp");
158
- });
159
- });
160
-
161
- // ---------------------------------------------------------------------------
162
- // Client Registration
163
- // ---------------------------------------------------------------------------
164
-
165
- describe("OAuth client registration", () => {
166
- test("registers a client", async () => {
167
- const req = makeRequest("https://vault.test/oauth/register", {
168
- method: "POST",
169
- headers: { "Content-Type": "application/json" },
170
- body: JSON.stringify({
171
- client_name: "Claude Web",
172
- redirect_uris: ["https://claude.ai/callback"],
173
- }),
174
- });
175
- const res = await handleRegister(req, db);
176
- expect(res.status).toBe(201);
177
- const body = await res.json();
178
- expect(body.client_id).toBeTruthy();
179
- expect(body.client_name).toBe("Claude Web");
180
- expect(body.redirect_uris).toEqual(["https://claude.ai/callback"]);
181
- });
182
-
183
- test("rejects missing redirect_uris", async () => {
184
- const req = makeRequest("https://vault.test/oauth/register", {
185
- method: "POST",
186
- headers: { "Content-Type": "application/json" },
187
- body: JSON.stringify({ client_name: "Bad Client" }),
188
- });
189
- const res = await handleRegister(req, db);
190
- expect(res.status).toBe(400);
191
- const body = await res.json();
192
- expect(body.error).toBe("invalid_client_metadata");
193
- });
194
-
195
- test("rejects non-POST", async () => {
196
- const req = makeRequest("https://vault.test/oauth/register");
197
- const res = await handleRegister(req, db);
198
- expect(res.status).toBe(405);
199
- });
200
-
201
- test("defaults client_name to Unknown Client", async () => {
202
- const req = makeRequest("https://vault.test/oauth/register", {
203
- method: "POST",
204
- headers: { "Content-Type": "application/json" },
205
- body: JSON.stringify({ redirect_uris: ["https://example.com/cb"] }),
206
- });
207
- const res = await handleRegister(req, db);
208
- const body = await res.json();
209
- expect(body.client_name).toBe("Unknown Client");
210
- });
211
- });
212
-
213
- // ---------------------------------------------------------------------------
214
- // Authorization
215
- // ---------------------------------------------------------------------------
216
-
217
- describe("OAuth authorization", () => {
218
- test("renders consent page with valid params", async () => {
219
- const clientId = await registerClient();
220
- const { codeChallenge } = generatePkce();
221
-
222
- const req = makeRequest(
223
- `https://vault.test/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://example.com/callback&code_challenge=${codeChallenge}&code_challenge_method=S256&scope=full&state=abc`,
224
- );
225
- const res = handleAuthorizeGet(req, db, "default");
226
- expect(res.status).toBe(200);
227
- const html = await res.text();
228
- expect(html).toContain("Authorize access");
229
- expect(html).toContain("Test Client");
230
- expect(html).toContain("Full access");
231
- });
232
-
233
- test("rejects missing client_id", () => {
234
- const req = makeRequest("https://vault.test/oauth/authorize?response_type=code&redirect_uri=x&code_challenge=y");
235
- const res = handleAuthorizeGet(req, db, "default");
236
- expect(res.status).toBe(400);
237
- });
238
-
239
- test("rejects unknown client", () => {
240
- const req = makeRequest(
241
- "https://vault.test/oauth/authorize?response_type=code&client_id=unknown&redirect_uri=x&code_challenge=y",
242
- );
243
- const res = handleAuthorizeGet(req, db, "default");
244
- expect(res.status).toBe(400);
245
- });
246
-
247
- test("rejects mismatched redirect_uri", async () => {
248
- const clientId = await registerClient();
249
- const req = makeRequest(
250
- `https://vault.test/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://evil.com/callback&code_challenge=abc`,
251
- );
252
- const res = handleAuthorizeGet(req, db, "default");
253
- expect(res.status).toBe(400);
254
- });
255
-
256
- test("POST authorize (approve) redirects with code", async () => {
257
- const ownerToken = createOwnerToken();
258
- const clientId = await registerClient();
259
- const { codeChallenge } = generatePkce();
260
-
261
- const req = makeRequest("https://vault.test/oauth/authorize", {
262
- method: "POST",
263
- body: new URLSearchParams({
264
- action: "authorize",
265
- client_id: clientId,
266
- redirect_uri: "https://example.com/callback",
267
- code_challenge: codeChallenge,
268
- code_challenge_method: "S256",
269
- scope: "full",
270
- state: "mystate",
271
- owner_token: ownerToken,
272
- }),
273
- });
274
- const res = await handleAuthorizePost(req, db);
275
- expect(res.status).toBe(302);
276
- const location = new URL(res.headers.get("location")!);
277
- expect(location.origin).toBe("https://example.com");
278
- expect(location.pathname).toBe("/callback");
279
- expect(location.searchParams.get("code")).toBeTruthy();
280
- expect(location.searchParams.get("state")).toBe("mystate");
281
- });
282
-
283
- test("POST authorize without scope is rejected (no silent default to 'full', #197)", async () => {
284
- const ownerToken = createOwnerToken();
285
- const clientId = await registerClient();
286
- const { codeChallenge } = generatePkce();
287
- const req = makeRequest("https://vault.test/oauth/authorize", {
288
- method: "POST",
289
- body: new URLSearchParams({
290
- action: "authorize",
291
- client_id: clientId,
292
- redirect_uri: "https://example.com/callback",
293
- code_challenge: codeChallenge,
294
- code_challenge_method: "S256",
295
- // No scope field — pre-#197 this silently consented to "full".
296
- owner_token: ownerToken,
297
- }),
298
- });
299
- const res = await handleAuthorizePost(req, db);
300
- expect(res.status).toBe(400);
301
- const body = (await res.json()) as { error?: string };
302
- expect(body.error).toBe("invalid_request");
303
- });
304
-
305
- test("POST authorize (deny) redirects with error", async () => {
306
- const clientId = await registerClient();
307
- const { codeChallenge } = generatePkce();
308
- const req = makeRequest("https://vault.test/oauth/authorize", {
309
- method: "POST",
310
- body: new URLSearchParams({
311
- action: "deny",
312
- client_id: clientId,
313
- redirect_uri: "https://example.com/callback",
314
- code_challenge: codeChallenge,
315
- code_challenge_method: "S256",
316
- state: "s",
317
- }),
318
- });
319
- const res = await handleAuthorizePost(req, db);
320
- expect(res.status).toBe(302);
321
- const location = new URL(res.headers.get("location")!);
322
- expect(location.searchParams.get("error")).toBe("access_denied");
323
- });
324
-
325
- test("POST authorize rejects unregistered redirect_uri (prevents open redirect)", async () => {
326
- const clientId = await registerClient();
327
- const { codeChallenge } = generatePkce();
328
- const req = makeRequest("https://vault.test/oauth/authorize", {
329
- method: "POST",
330
- body: new URLSearchParams({
331
- action: "deny",
332
- client_id: clientId,
333
- redirect_uri: "https://evil.com/steal",
334
- code_challenge: codeChallenge,
335
- code_challenge_method: "S256",
336
- }),
337
- });
338
- const res = await handleAuthorizePost(req, db);
339
- // Should NOT redirect — returns 400 instead
340
- expect(res.status).toBe(400);
341
- });
342
-
343
- test("POST authorize rejects unknown client_id", async () => {
344
- const { codeChallenge } = generatePkce();
345
- const req = makeRequest("https://vault.test/oauth/authorize", {
346
- method: "POST",
347
- body: new URLSearchParams({
348
- action: "authorize",
349
- client_id: "nonexistent",
350
- redirect_uri: "https://evil.com/steal",
351
- code_challenge: codeChallenge,
352
- code_challenge_method: "S256",
353
- }),
354
- });
355
- const res = await handleAuthorizePost(req, db);
356
- expect(res.status).toBe(400);
357
- });
358
-
359
- test("POST authorize rejects missing owner token", async () => {
360
- const clientId = await registerClient();
361
- const { codeChallenge } = generatePkce();
362
- const req = makeRequest("https://vault.test/oauth/authorize", {
363
- method: "POST",
364
- body: new URLSearchParams({
365
- action: "authorize",
366
- client_id: clientId,
367
- redirect_uri: "https://example.com/callback",
368
- code_challenge: codeChallenge,
369
- code_challenge_method: "S256",
370
- scope: "full",
371
- }),
372
- });
373
- const res = await handleAuthorizePost(req, db, { vaultName: "default" });
374
- // Should re-render consent page with error, not redirect
375
- expect(res.status).toBe(200);
376
- const html = await res.text();
377
- expect(html).toContain("Vault token is required");
378
- });
379
-
380
- test("POST authorize rejects invalid owner token", async () => {
381
- const clientId = await registerClient();
382
- const { codeChallenge } = generatePkce();
383
- const req = makeRequest("https://vault.test/oauth/authorize", {
384
- method: "POST",
385
- body: new URLSearchParams({
386
- action: "authorize",
387
- client_id: clientId,
388
- redirect_uri: "https://example.com/callback",
389
- code_challenge: codeChallenge,
390
- code_challenge_method: "S256",
391
- scope: "full",
392
- owner_token: "pvt_invalid_token_value",
393
- }),
394
- });
395
- const res = await handleAuthorizePost(req, db, { vaultName: "default" });
396
- expect(res.status).toBe(200);
397
- const html = await res.text();
398
- expect(html).toContain("Invalid vault token");
399
- });
400
- });
401
-
402
- // ---------------------------------------------------------------------------
403
- // Token exchange
404
- // ---------------------------------------------------------------------------
405
-
406
- describe("OAuth token exchange", () => {
407
- test("full flow: register → authorize → token", async () => {
408
- const token = await fullOAuthFlow();
409
- expect(token.startsWith("pvt_")).toBe(true);
410
-
411
- // The token should resolve in the vault's token table
412
- const resolved = resolveToken(db, token);
413
- expect(resolved).not.toBeNull();
414
- expect(resolved!.permission).toBe("full");
415
- });
416
-
417
- test("read scope produces read-only token", async () => {
418
- const token = await fullOAuthFlow({ scope: "read" });
419
- const resolved = resolveToken(db, token);
420
- expect(resolved!.permission).toBe("read");
421
- });
422
-
423
- test("rejects invalid code", async () => {
424
- const clientId = await registerClient();
425
- const req = makeRequest("https://vault.test/oauth/token", {
426
- method: "POST",
427
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
428
- body: new URLSearchParams({
429
- grant_type: "authorization_code",
430
- code: "bogus",
431
- code_verifier: "whatever",
432
- client_id: clientId,
433
- redirect_uri: "https://example.com/callback",
434
- }).toString(),
435
- });
436
- const res = await handleToken(req, db, "default");
437
- expect(res.status).toBe(400);
438
- const body = await res.json();
439
- expect(body.error).toBe("invalid_grant");
440
- });
441
-
442
- test("rejects wrong PKCE verifier", async () => {
443
- const ownerToken = createOwnerToken();
444
- const clientId = await registerClient();
445
- const { codeChallenge } = generatePkce();
446
- const redirectUri = "https://example.com/callback";
447
-
448
- // Get a real code
449
- const authReq = makeRequest("https://vault.test/oauth/authorize", {
450
- method: "POST",
451
- body: new URLSearchParams({
452
- action: "authorize",
453
- client_id: clientId,
454
- redirect_uri: redirectUri,
455
- code_challenge: codeChallenge,
456
- code_challenge_method: "S256",
457
- scope: "full",
458
- owner_token: ownerToken,
459
- }),
460
- });
461
- const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
462
- const location = new URL(authRes.headers.get("location")!);
463
- const code = location.searchParams.get("code")!;
464
-
465
- // Try to exchange with wrong verifier
466
- const tokenReq = makeRequest("https://vault.test/oauth/token", {
467
- method: "POST",
468
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
469
- body: new URLSearchParams({
470
- grant_type: "authorization_code",
471
- code,
472
- code_verifier: "wrong-verifier",
473
- client_id: clientId,
474
- redirect_uri: redirectUri,
475
- }).toString(),
476
- });
477
- const res = await handleToken(tokenReq, db, "default");
478
- expect(res.status).toBe(400);
479
- const body = await res.json();
480
- expect(body.error_description).toContain("PKCE");
481
- });
482
-
483
- test("rejects already-used code", async () => {
484
- const ownerToken = createOwnerToken();
485
- const clientId = await registerClient();
486
- const { codeVerifier, codeChallenge } = generatePkce();
487
- const redirectUri = "https://example.com/callback";
488
-
489
- // Get code
490
- const authReq = makeRequest("https://vault.test/oauth/authorize", {
491
- method: "POST",
492
- body: new URLSearchParams({
493
- action: "authorize",
494
- client_id: clientId,
495
- redirect_uri: redirectUri,
496
- code_challenge: codeChallenge,
497
- code_challenge_method: "S256",
498
- scope: "full",
499
- owner_token: ownerToken,
500
- }),
501
- });
502
- const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
503
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
504
-
505
- const tokenParams = new URLSearchParams({
506
- grant_type: "authorization_code",
507
- code,
508
- code_verifier: codeVerifier,
509
- client_id: clientId,
510
- redirect_uri: redirectUri,
511
- }).toString();
512
-
513
- // First exchange — succeeds
514
- const res1 = await handleToken(
515
- makeRequest("https://vault.test/oauth/token", {
516
- method: "POST",
517
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
518
- body: tokenParams,
519
- }),
520
- db,
521
- "default",
522
- );
523
- expect(res1.status).toBe(200);
524
-
525
- // Second exchange — fails (code already used)
526
- const res2 = await handleToken(
527
- makeRequest("https://vault.test/oauth/token", {
528
- method: "POST",
529
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
530
- body: tokenParams,
531
- }),
532
- db,
533
- "default",
534
- );
535
- expect(res2.status).toBe(400);
536
- const body = await res2.json();
537
- expect(body.error_description).toContain("already used");
538
- });
539
-
540
- test("rejects expired code", async () => {
541
- const clientId = await registerClient();
542
- const { codeVerifier, codeChallenge } = generatePkce();
543
- const redirectUri = "https://example.com/callback";
544
-
545
- // Insert an expired code directly
546
- const code = crypto.randomBytes(32).toString("base64url");
547
- db.prepare(`
548
- INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at)
549
- VALUES (?, ?, ?, 'S256', 'full', ?, ?, ?)
550
- `).run(code, clientId, codeChallenge, redirectUri, "2020-01-01T00:00:00.000Z", new Date().toISOString());
551
-
552
- const res = await handleToken(
553
- makeRequest("https://vault.test/oauth/token", {
554
- method: "POST",
555
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
556
- body: new URLSearchParams({
557
- grant_type: "authorization_code",
558
- code,
559
- code_verifier: codeVerifier,
560
- client_id: clientId,
561
- redirect_uri: redirectUri,
562
- }).toString(),
563
- }),
564
- db,
565
- "default",
566
- );
567
- expect(res.status).toBe(400);
568
- const body = await res.json();
569
- expect(body.error_description).toContain("expired");
570
- });
571
-
572
- test("rejects mismatched client_id", async () => {
573
- const ownerToken = createOwnerToken();
574
- const clientId = await registerClient();
575
- const otherClientId = await registerClient("Other Client");
576
- const { codeVerifier, codeChallenge } = generatePkce();
577
- const redirectUri = "https://example.com/callback";
578
-
579
- // Get code for clientId
580
- const authRes = await handleAuthorizePost(
581
- makeRequest("https://vault.test/oauth/authorize", {
582
- method: "POST",
583
- body: new URLSearchParams({
584
- action: "authorize",
585
- client_id: clientId,
586
- redirect_uri: redirectUri,
587
- code_challenge: codeChallenge,
588
- code_challenge_method: "S256",
589
- scope: "full",
590
- owner_token: ownerToken,
591
- }),
592
- }),
593
- db,
594
- { vaultName: "default" },
595
- );
596
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
597
-
598
- // Try to exchange with different client_id
599
- const res = await handleToken(
600
- makeRequest("https://vault.test/oauth/token", {
601
- method: "POST",
602
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
603
- body: new URLSearchParams({
604
- grant_type: "authorization_code",
605
- code,
606
- code_verifier: codeVerifier,
607
- client_id: otherClientId,
608
- redirect_uri: redirectUri,
609
- }).toString(),
610
- }),
611
- db,
612
- "default",
613
- );
614
- expect(res.status).toBe(400);
615
- const body = await res.json();
616
- expect(body.error).toBe("invalid_grant");
617
- });
618
-
619
- test("rejects unsupported grant_type", async () => {
620
- const res = await handleToken(
621
- makeRequest("https://vault.test/oauth/token", {
622
- method: "POST",
623
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
624
- body: "grant_type=client_credentials",
625
- }),
626
- db,
627
- "default",
628
- );
629
- expect(res.status).toBe(400);
630
- const body = await res.json();
631
- expect(body.error).toBe("unsupported_grant_type");
632
- });
633
-
634
- test("accepts JSON body", async () => {
635
- const token = await fullOAuthFlow();
636
- // fullOAuthFlow uses form-encoded. Let's also test JSON for token endpoint
637
- expect(token.startsWith("pvt_")).toBe(true);
638
- });
639
-
640
- test("rejects missing redirect_uri", async () => {
641
- const clientId = await registerClient();
642
- const res = await handleToken(
643
- makeRequest("https://vault.test/oauth/token", {
644
- method: "POST",
645
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
646
- body: new URLSearchParams({
647
- grant_type: "authorization_code",
648
- code: "some-code",
649
- code_verifier: "some-verifier",
650
- client_id: clientId,
651
- }).toString(),
652
- }),
653
- db,
654
- "default",
655
- );
656
- expect(res.status).toBe(400);
657
- const body = await res.json();
658
- expect(body.error).toBe("invalid_request");
659
- });
660
-
661
- test("rejects non-POST", async () => {
662
- const res = await handleToken(
663
- makeRequest("https://vault.test/oauth/token"),
664
- db,
665
- "default",
666
- );
667
- expect(res.status).toBe(405);
668
- });
669
- });
670
-
671
- // ---------------------------------------------------------------------------
672
- // Password-based owner auth
673
- // ---------------------------------------------------------------------------
674
-
675
- describe("OAuth consent — password mode", () => {
676
- // Use bcrypt cost 4 in tests to keep them fast
677
- async function hashPassword(pw: string): Promise<string> {
678
- return await Bun.password.hash(pw, { algorithm: "bcrypt", cost: 4 });
679
- }
680
-
681
- test("GET renders password field when password is set", async () => {
682
- const clientId = await registerClient();
683
- const { codeChallenge } = generatePkce();
684
- const url = new URL("https://vault.test/oauth/authorize");
685
- url.searchParams.set("client_id", clientId);
686
- url.searchParams.set("redirect_uri", "https://example.com/callback");
687
- url.searchParams.set("code_challenge", codeChallenge);
688
- url.searchParams.set("response_type", "code");
689
- url.searchParams.set("scope", "full");
690
- const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", "$2a$fake");
691
- expect(res.status).toBe(200);
692
- const html = await res.text();
693
- expect(html).toContain('name="password"');
694
- expect(html).not.toContain('name="owner_token"');
695
- });
696
-
697
- test("GET renders owner_token field when no password is set", async () => {
698
- const clientId = await registerClient();
699
- const { codeChallenge } = generatePkce();
700
- const url = new URL("https://vault.test/oauth/authorize");
701
- url.searchParams.set("client_id", clientId);
702
- url.searchParams.set("redirect_uri", "https://example.com/callback");
703
- url.searchParams.set("code_challenge", codeChallenge);
704
- url.searchParams.set("response_type", "code");
705
- url.searchParams.set("scope", "full");
706
- const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", null);
707
- expect(res.status).toBe(200);
708
- const html = await res.text();
709
- expect(html).toContain('name="owner_token"');
710
- expect(html).not.toContain('name="password"');
711
- });
712
-
713
- test("POST accepts correct password and mints a token", async () => {
714
- const password = "correcthorsebatterystaple";
715
- const passwordHash = await hashPassword(password);
716
- const clientId = await registerClient();
717
- const { codeVerifier, codeChallenge } = generatePkce();
718
- const redirectUri = "https://example.com/callback";
719
-
720
- const authRes = await handleAuthorizePost(
721
- makeRequest("https://vault.test/oauth/authorize", {
722
- method: "POST",
723
- body: new URLSearchParams({
724
- action: "authorize",
725
- client_id: clientId,
726
- redirect_uri: redirectUri,
727
- code_challenge: codeChallenge,
728
- code_challenge_method: "S256",
729
- scope: "full",
730
- password,
731
- }),
732
- }),
733
- db,
734
- { ownerPasswordHash: passwordHash, vaultName: "default" },
735
- );
736
-
737
- expect(authRes.status).toBe(302);
738
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
739
- expect(code).toBeTruthy();
740
-
741
- const tokenRes = await handleToken(
742
- makeRequest("https://vault.test/oauth/token", {
743
- method: "POST",
744
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
745
- body: new URLSearchParams({
746
- grant_type: "authorization_code",
747
- code,
748
- code_verifier: codeVerifier,
749
- client_id: clientId,
750
- redirect_uri: redirectUri,
751
- }).toString(),
752
- }),
753
- db,
754
- "default",
755
- );
756
- const body = await tokenRes.json();
757
- expect(body.access_token.startsWith("pvt_")).toBe(true);
758
- });
759
-
760
- test("POST rejects wrong password with re-rendered consent", async () => {
761
- const passwordHash = await hashPassword("correcthorsebatterystaple");
762
- const clientId = await registerClient();
763
- const { codeChallenge } = generatePkce();
764
-
765
- const res = await handleAuthorizePost(
766
- makeRequest("https://vault.test/oauth/authorize", {
767
- method: "POST",
768
- body: new URLSearchParams({
769
- action: "authorize",
770
- client_id: clientId,
771
- redirect_uri: "https://example.com/callback",
772
- code_challenge: codeChallenge,
773
- code_challenge_method: "S256",
774
- scope: "full",
775
- password: "wrongpassword",
776
- }),
777
- }),
778
- db,
779
- { ownerPasswordHash: passwordHash },
780
- );
781
-
782
- expect(res.status).toBe(200);
783
- const html = await res.text();
784
- expect(html).toContain("Invalid credentials");
785
- // Should render password field, not owner_token
786
- expect(html).toContain('name="password"');
787
- });
788
-
789
- test("POST rejects missing password in password mode", async () => {
790
- const passwordHash = await hashPassword("correcthorsebatterystaple");
791
- const clientId = await registerClient();
792
- const { codeChallenge } = generatePkce();
793
-
794
- const res = await handleAuthorizePost(
795
- makeRequest("https://vault.test/oauth/authorize", {
796
- method: "POST",
797
- body: new URLSearchParams({
798
- action: "authorize",
799
- client_id: clientId,
800
- redirect_uri: "https://example.com/callback",
801
- code_challenge: codeChallenge,
802
- code_challenge_method: "S256",
803
- scope: "full",
804
- }),
805
- }),
806
- db,
807
- { ownerPasswordHash: passwordHash },
808
- );
809
-
810
- expect(res.status).toBe(200);
811
- const html = await res.text();
812
- expect(html).toContain("Password is required");
813
- });
814
-
815
- test("owner_token is ignored when password is configured", async () => {
816
- const passwordHash = await hashPassword("correcthorsebatterystaple");
817
- const ownerToken = createOwnerToken();
818
- const clientId = await registerClient();
819
- const { codeChallenge } = generatePkce();
820
-
821
- // In password mode, providing a valid owner_token is insufficient —
822
- // only the password is accepted.
823
- const res = await handleAuthorizePost(
824
- makeRequest("https://vault.test/oauth/authorize", {
825
- method: "POST",
826
- body: new URLSearchParams({
827
- action: "authorize",
828
- client_id: clientId,
829
- redirect_uri: "https://example.com/callback",
830
- code_challenge: codeChallenge,
831
- code_challenge_method: "S256",
832
- scope: "full",
833
- owner_token: ownerToken,
834
- // no password
835
- }),
836
- }),
837
- db,
838
- { ownerPasswordHash: passwordHash },
839
- );
840
-
841
- expect(res.status).toBe(200);
842
- const html = await res.text();
843
- expect(html).toContain("Password is required");
844
- });
845
- });
846
-
847
- // ---------------------------------------------------------------------------
848
- // Rate limiting
849
- // ---------------------------------------------------------------------------
850
-
851
- describe("OAuth consent — rate limiting", () => {
852
- test("locks out an IP after threshold failures", async () => {
853
- const { RateLimiter } = await import("./owner-auth.ts");
854
- const limiter = new RateLimiter(3, 60_000, 60_000); // 3 fails = lock
855
- const passwordHash = await Bun.password.hash("correcthorsebatterystaple", {
856
- algorithm: "bcrypt",
857
- cost: 4,
858
- });
859
- const clientId = await registerClient();
860
- const { codeChallenge } = generatePkce();
861
- const clientIp = "192.0.2.42";
862
-
863
- const makeAttempt = () => handleAuthorizePost(
864
- makeRequest("https://vault.test/oauth/authorize", {
865
- method: "POST",
866
- body: new URLSearchParams({
867
- action: "authorize",
868
- client_id: clientId,
869
- redirect_uri: "https://example.com/callback",
870
- code_challenge: codeChallenge,
871
- code_challenge_method: "S256",
872
- scope: "full",
873
- password: "wrongwrongwrong",
874
- }),
875
- }),
876
- db,
877
- { ownerPasswordHash: passwordHash, clientIp, rateLimiter: limiter },
878
- );
879
-
880
- // First 3 attempts: 200 with "Invalid credentials"
881
- for (let i = 0; i < 3; i++) {
882
- const res = await makeAttempt();
883
- expect(res.status).toBe(200);
884
- }
885
- // 4th attempt should be locked out with 429
886
- const res = await makeAttempt();
887
- expect(res.status).toBe(429);
888
- expect(res.headers.get("Retry-After")).toBeTruthy();
889
- });
890
-
891
- test("successful auth clears the failure counter", async () => {
892
- const { RateLimiter } = await import("./owner-auth.ts");
893
- const limiter = new RateLimiter(3, 60_000, 60_000);
894
- const password = "correcthorsebatterystaple";
895
- const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 4 });
896
- const clientId = await registerClient();
897
- const { codeChallenge } = generatePkce();
898
- const clientIp = "192.0.2.43";
899
-
900
- const attempt = (pw: string) => handleAuthorizePost(
901
- makeRequest("https://vault.test/oauth/authorize", {
902
- method: "POST",
903
- body: new URLSearchParams({
904
- action: "authorize",
905
- client_id: clientId,
906
- redirect_uri: "https://example.com/callback",
907
- code_challenge: codeChallenge,
908
- code_challenge_method: "S256",
909
- scope: "full",
910
- password: pw,
911
- }),
912
- }),
913
- db,
914
- { ownerPasswordHash: passwordHash, clientIp, rateLimiter: limiter },
915
- );
916
-
917
- await attempt("wrong1");
918
- await attempt("wrong2");
919
- const good = await attempt(password);
920
- expect(good.status).toBe(302);
921
-
922
- // Counter reset — we can still do more wrong attempts without lockout
923
- const r1 = await attempt("wrong3");
924
- expect(r1.status).toBe(200);
925
- const r2 = await attempt("wrong4");
926
- expect(r2.status).toBe(200);
927
- });
928
-
929
- test("locks out an IP after threshold 2FA failures (valid password, bad TOTP)", async () => {
930
- const { RateLimiter } = await import("./owner-auth.ts");
931
- const limiter = new RateLimiter(3, 60_000, 60_000); // 3 fails = lock
932
- const password = "correcthorsebatterystaple";
933
- const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 4 });
934
- const secret = new OTPAuth.Secret({ size: 20 }).base32;
935
- const clientId = await registerClient();
936
- const { codeChallenge } = generatePkce();
937
- const clientIp = "192.0.2.44";
938
-
939
- const makeAttempt = () => handleAuthorizePost(
940
- makeRequest("https://vault.test/oauth/authorize", {
941
- method: "POST",
942
- body: new URLSearchParams({
943
- action: "authorize",
944
- client_id: clientId,
945
- redirect_uri: "https://example.com/callback",
946
- code_challenge: codeChallenge,
947
- code_challenge_method: "S256",
948
- scope: "full",
949
- password,
950
- totp_code: "000000", // always invalid
951
- }),
952
- }),
953
- db,
954
- { ownerPasswordHash: passwordHash, totpSecret: secret, clientIp, rateLimiter: limiter },
955
- );
956
-
957
- // First 3 attempts: 200 with the unified "Invalid credentials" error
958
- for (let i = 0; i < 3; i++) {
959
- const res = await makeAttempt();
960
- expect(res.status).toBe(200);
961
- const html = await res.text();
962
- expect(html).toContain("Invalid credentials");
963
- }
964
- // 4th attempt should be locked out with 429
965
- const res = await makeAttempt();
966
- expect(res.status).toBe(429);
967
- expect(res.headers.get("Retry-After")).toBeTruthy();
968
- });
969
- });
970
-
971
- // ---------------------------------------------------------------------------
972
- // Scope selection
973
- // ---------------------------------------------------------------------------
974
-
975
- describe("OAuth consent — scope selection", () => {
976
- test("user can downgrade from full to read via radio", async () => {
977
- const ownerToken = createOwnerToken();
978
- const clientId = await registerClient();
979
- const { codeVerifier, codeChallenge } = generatePkce();
980
- const redirectUri = "https://example.com/callback";
981
-
982
- const authRes = await handleAuthorizePost(
983
- makeRequest("https://vault.test/oauth/authorize", {
984
- method: "POST",
985
- body: new URLSearchParams({
986
- action: "authorize",
987
- client_id: clientId,
988
- redirect_uri: redirectUri,
989
- code_challenge: codeChallenge,
990
- code_challenge_method: "S256",
991
- scope: "full", // requested
992
- selected_scope: "read", // user chose read-only
993
- owner_token: ownerToken,
994
- }),
995
- }),
996
- db,
997
- { vaultName: "default" },
998
- );
999
- expect(authRes.status).toBe(302);
1000
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1001
-
1002
- const tokenRes = await handleToken(
1003
- makeRequest("https://vault.test/oauth/token", {
1004
- method: "POST",
1005
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1006
- body: new URLSearchParams({
1007
- grant_type: "authorization_code",
1008
- code,
1009
- code_verifier: codeVerifier,
1010
- client_id: clientId,
1011
- redirect_uri: redirectUri,
1012
- }).toString(),
1013
- }),
1014
- db,
1015
- "default",
1016
- );
1017
- const body = await tokenRes.json();
1018
- expect(body.scope).toBe("vault:read");
1019
- });
1020
-
1021
- test("defaults selected_scope to requested scope when not provided", async () => {
1022
- const ownerToken = createOwnerToken();
1023
- const clientId = await registerClient();
1024
- const { codeVerifier, codeChallenge } = generatePkce();
1025
- const redirectUri = "https://example.com/callback";
1026
-
1027
- const authRes = await handleAuthorizePost(
1028
- makeRequest("https://vault.test/oauth/authorize", {
1029
- method: "POST",
1030
- body: new URLSearchParams({
1031
- action: "authorize",
1032
- client_id: clientId,
1033
- redirect_uri: redirectUri,
1034
- code_challenge: codeChallenge,
1035
- code_challenge_method: "S256",
1036
- scope: "read", // requested only, no radio selection
1037
- owner_token: ownerToken,
1038
- }),
1039
- }),
1040
- db,
1041
- { vaultName: "default" },
1042
- );
1043
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1044
-
1045
- const tokenRes = await handleToken(
1046
- makeRequest("https://vault.test/oauth/token", {
1047
- method: "POST",
1048
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1049
- body: new URLSearchParams({
1050
- grant_type: "authorization_code",
1051
- code,
1052
- code_verifier: codeVerifier,
1053
- client_id: clientId,
1054
- redirect_uri: redirectUri,
1055
- }).toString(),
1056
- }),
1057
- db,
1058
- "default",
1059
- );
1060
- const body = await tokenRes.json();
1061
- expect(body.scope).toBe("vault:read");
1062
- });
1063
-
1064
- test("consent HTML includes both scope radio buttons", async () => {
1065
- const clientId = await registerClient();
1066
- const { codeChallenge } = generatePkce();
1067
- const url = new URL("https://vault.test/oauth/authorize");
1068
- url.searchParams.set("client_id", clientId);
1069
- url.searchParams.set("redirect_uri", "https://example.com/callback");
1070
- url.searchParams.set("code_challenge", codeChallenge);
1071
- url.searchParams.set("response_type", "code");
1072
- url.searchParams.set("scope", "full");
1073
- const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default");
1074
- const html = await res.text();
1075
- expect(html).toContain('name="selected_scope"');
1076
- expect(html).toContain('value="full"');
1077
- expect(html).toContain('value="read"');
1078
- // The requested scope should be pre-checked
1079
- expect(html).toMatch(/value="full"\s+checked/);
1080
- });
1081
- });
1082
-
1083
- // ---------------------------------------------------------------------------
1084
- // 2FA (TOTP) on consent
1085
- // ---------------------------------------------------------------------------
1086
-
1087
- describe("OAuth consent — 2FA (TOTP)", () => {
1088
- async function hashPassword(pw: string): Promise<string> {
1089
- return await Bun.password.hash(pw, { algorithm: "bcrypt", cost: 4 });
1090
- }
1091
-
1092
- function makeTotp(secretBase32: string) {
1093
- return new OTPAuth.TOTP({
1094
- issuer: "Parachute Vault",
1095
- label: "owner",
1096
- algorithm: "SHA1",
1097
- digits: 6,
1098
- period: 30,
1099
- secret: OTPAuth.Secret.fromBase32(secretBase32),
1100
- });
1101
- }
1102
-
1103
- test("GET renders TOTP field when 2FA enrolled", async () => {
1104
- const passwordHash = await hashPassword("correcthorsebatterystaple");
1105
- const clientId = await registerClient();
1106
- const { codeChallenge } = generatePkce();
1107
- const url = new URL("https://vault.test/oauth/authorize");
1108
- url.searchParams.set("client_id", clientId);
1109
- url.searchParams.set("redirect_uri", "https://example.com/callback");
1110
- url.searchParams.set("code_challenge", codeChallenge);
1111
- url.searchParams.set("response_type", "code");
1112
-
1113
- const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", passwordHash, true);
1114
- const html = await res.text();
1115
- expect(html).toContain('name="totp_code"');
1116
- expect(html).toContain('name="backup_code"');
1117
- });
1118
-
1119
- test("GET omits TOTP field when 2FA not enrolled", async () => {
1120
- const passwordHash = await hashPassword("correcthorsebatterystaple");
1121
- const clientId = await registerClient();
1122
- const { codeChallenge } = generatePkce();
1123
- const url = new URL("https://vault.test/oauth/authorize");
1124
- url.searchParams.set("client_id", clientId);
1125
- url.searchParams.set("redirect_uri", "https://example.com/callback");
1126
- url.searchParams.set("code_challenge", codeChallenge);
1127
- url.searchParams.set("response_type", "code");
1128
-
1129
- const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", passwordHash, false);
1130
- const html = await res.text();
1131
- expect(html).not.toContain('name="totp_code"');
1132
- });
1133
-
1134
- test("POST accepts valid TOTP + password and mints a token", async () => {
1135
- const password = "correcthorsebatterystaple";
1136
- const passwordHash = await hashPassword(password);
1137
- const secret = new OTPAuth.Secret({ size: 20 }).base32;
1138
- const code = makeTotp(secret).generate();
1139
-
1140
- const clientId = await registerClient();
1141
- const { codeVerifier, codeChallenge } = generatePkce();
1142
- const redirectUri = "https://example.com/callback";
1143
-
1144
- const res = await handleAuthorizePost(
1145
- makeRequest("https://vault.test/oauth/authorize", {
1146
- method: "POST",
1147
- body: new URLSearchParams({
1148
- action: "authorize",
1149
- client_id: clientId,
1150
- redirect_uri: redirectUri,
1151
- code_challenge: codeChallenge,
1152
- code_challenge_method: "S256",
1153
- scope: "full",
1154
- state: "",
1155
- password,
1156
- totp_code: code,
1157
- }),
1158
- }),
1159
- db,
1160
- { ownerPasswordHash: passwordHash, totpSecret: secret, vaultName: "default" },
1161
- );
1162
- expect(res.status).toBe(302);
1163
- const authCode = new URL(res.headers.get("location")!).searchParams.get("code")!;
1164
- expect(authCode).toBeTruthy();
1165
-
1166
- // Exchange works end-to-end
1167
- const tokenRes = await handleToken(
1168
- makeRequest("https://vault.test/oauth/token", {
1169
- method: "POST",
1170
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1171
- body: new URLSearchParams({
1172
- grant_type: "authorization_code",
1173
- code: authCode,
1174
- code_verifier: codeVerifier,
1175
- client_id: clientId,
1176
- redirect_uri: redirectUri,
1177
- }).toString(),
1178
- }),
1179
- db,
1180
- "default",
1181
- );
1182
- expect(tokenRes.status).toBe(200);
1183
- const body = await tokenRes.json();
1184
- expect(body.access_token).toBeTruthy();
1185
- });
1186
-
1187
- test("POST rejects wrong TOTP with re-rendered consent (no code issued)", async () => {
1188
- const password = "correcthorsebatterystaple";
1189
- const passwordHash = await hashPassword(password);
1190
- const secret = new OTPAuth.Secret({ size: 20 }).base32;
1191
-
1192
- const clientId = await registerClient();
1193
- const { codeChallenge } = generatePkce();
1194
-
1195
- const res = await handleAuthorizePost(
1196
- makeRequest("https://vault.test/oauth/authorize", {
1197
- method: "POST",
1198
- body: new URLSearchParams({
1199
- action: "authorize",
1200
- client_id: clientId,
1201
- redirect_uri: "https://example.com/callback",
1202
- code_challenge: codeChallenge,
1203
- code_challenge_method: "S256",
1204
- scope: "full",
1205
- state: "",
1206
- password,
1207
- totp_code: "000000",
1208
- }),
1209
- }),
1210
- db,
1211
- { ownerPasswordHash: passwordHash, totpSecret: secret },
1212
- );
1213
- expect(res.status).toBe(200);
1214
- const html = await res.text();
1215
- expect(html).toContain("Invalid credentials");
1216
- // No auth code was created
1217
- const rows = db.prepare("SELECT COUNT(*) as n FROM oauth_codes").get() as { n: number };
1218
- expect(rows.n).toBe(0);
1219
- });
1220
-
1221
- test("POST rejects missing TOTP when 2FA enrolled", async () => {
1222
- const password = "correcthorsebatterystaple";
1223
- const passwordHash = await hashPassword(password);
1224
- const secret = new OTPAuth.Secret({ size: 20 }).base32;
1225
-
1226
- const clientId = await registerClient();
1227
- const { codeChallenge } = generatePkce();
1228
-
1229
- const res = await handleAuthorizePost(
1230
- makeRequest("https://vault.test/oauth/authorize", {
1231
- method: "POST",
1232
- body: new URLSearchParams({
1233
- action: "authorize",
1234
- client_id: clientId,
1235
- redirect_uri: "https://example.com/callback",
1236
- code_challenge: codeChallenge,
1237
- code_challenge_method: "S256",
1238
- scope: "full",
1239
- state: "",
1240
- password,
1241
- // no totp_code, no backup_code
1242
- }),
1243
- }),
1244
- db,
1245
- { ownerPasswordHash: passwordHash, totpSecret: secret },
1246
- );
1247
- expect(res.status).toBe(200);
1248
- const html = await res.text();
1249
- expect(html).toContain("Enter a 6-digit code");
1250
- });
1251
-
1252
- test("POST rejects TOTP when password itself is wrong (TOTP not consulted)", async () => {
1253
- const passwordHash = await hashPassword("correcthorsebatterystaple");
1254
- const secret = new OTPAuth.Secret({ size: 20 }).base32;
1255
- const validCode = makeTotp(secret).generate();
1256
-
1257
- const clientId = await registerClient();
1258
- const { codeChallenge } = generatePkce();
1259
-
1260
- const res = await handleAuthorizePost(
1261
- makeRequest("https://vault.test/oauth/authorize", {
1262
- method: "POST",
1263
- body: new URLSearchParams({
1264
- action: "authorize",
1265
- client_id: clientId,
1266
- redirect_uri: "https://example.com/callback",
1267
- code_challenge: codeChallenge,
1268
- code_challenge_method: "S256",
1269
- scope: "full",
1270
- state: "",
1271
- password: "wrongwrongwrong",
1272
- totp_code: validCode,
1273
- }),
1274
- }),
1275
- db,
1276
- { ownerPasswordHash: passwordHash, totpSecret: secret },
1277
- );
1278
- expect(res.status).toBe(200);
1279
- const html = await res.text();
1280
- expect(html).toContain("Invalid credentials");
1281
- });
1282
- });
1283
-
1284
- // ---------------------------------------------------------------------------
1285
- // Token response — honest vault name (Fix 1)
1286
- // ---------------------------------------------------------------------------
1287
-
1288
- describe("OAuth token response — vault name", () => {
1289
- test("includes vault name for the default (unscoped) flow", async () => {
1290
- const ownerToken = createOwnerToken();
1291
- const clientId = await registerClient();
1292
- const { codeVerifier, codeChallenge } = generatePkce();
1293
- const redirectUri = "https://example.com/callback";
1294
-
1295
- const authRes = await handleAuthorizePost(
1296
- makeRequest("https://vault.test/oauth/authorize", {
1297
- method: "POST",
1298
- body: new URLSearchParams({
1299
- action: "authorize",
1300
- client_id: clientId,
1301
- redirect_uri: redirectUri,
1302
- code_challenge: codeChallenge,
1303
- code_challenge_method: "S256",
1304
- scope: "full",
1305
- owner_token: ownerToken,
1306
- }),
1307
- }),
1308
- db,
1309
- { vaultName: "default" },
1310
- );
1311
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1312
-
1313
- const tokenRes = await handleToken(
1314
- makeRequest("https://vault.test/oauth/token", {
1315
- method: "POST",
1316
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1317
- body: new URLSearchParams({
1318
- grant_type: "authorization_code",
1319
- code,
1320
- code_verifier: codeVerifier,
1321
- client_id: clientId,
1322
- redirect_uri: redirectUri,
1323
- }).toString(),
1324
- }),
1325
- db,
1326
- "default",
1327
- );
1328
- const body = await tokenRes.json();
1329
- expect(body.access_token).toMatch(/^pvt_/);
1330
- expect(body.vault).toBe("default");
1331
- expect(body.token_type).toBe("bearer");
1332
- expect(body.scope).toBe("vault:read vault:write vault:admin");
1333
- });
1334
-
1335
- test("includes vault name for a scoped (named-vault) flow", async () => {
1336
- // The vaultName is purely a response-shape concern; the DB is the same
1337
- // in-memory DB here. The point is that handleToken echoes the name it
1338
- // was called with, so the client can trust which vault it just connected to.
1339
- const ownerToken = createOwnerToken();
1340
- const clientId = await registerClient();
1341
- const { codeVerifier, codeChallenge } = generatePkce();
1342
- const redirectUri = "https://example.com/callback";
1343
-
1344
- const authRes = await handleAuthorizePost(
1345
- makeRequest("https://vault.test/vault/work/oauth/authorize", {
1346
- method: "POST",
1347
- body: new URLSearchParams({
1348
- action: "authorize",
1349
- client_id: clientId,
1350
- redirect_uri: redirectUri,
1351
- code_challenge: codeChallenge,
1352
- code_challenge_method: "S256",
1353
- scope: "full",
1354
- owner_token: ownerToken,
1355
- }),
1356
- }),
1357
- db,
1358
- { vaultName: "work" },
1359
- );
1360
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1361
-
1362
- const tokenRes = await handleToken(
1363
- makeRequest("https://vault.test/vault/work/oauth/token", {
1364
- method: "POST",
1365
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1366
- body: new URLSearchParams({
1367
- grant_type: "authorization_code",
1368
- code,
1369
- code_verifier: codeVerifier,
1370
- client_id: clientId,
1371
- redirect_uri: redirectUri,
1372
- }).toString(),
1373
- }),
1374
- db,
1375
- "work",
1376
- );
1377
- const body = await tokenRes.json();
1378
- expect(body.vault).toBe("work");
1379
- });
1380
- });
1381
-
1382
- // ---------------------------------------------------------------------------
1383
- // Vault-scoped discovery (Fix 3 — routing coherence)
1384
- // ---------------------------------------------------------------------------
1385
-
1386
- describe("OAuth discovery — vault-scoped", () => {
1387
- test("authorization-server metadata scopes all endpoints to the vault", async () => {
1388
- const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-authorization-server");
1389
- const res = handleAuthorizationServer(req, "work");
1390
- const body = await res.json();
1391
- // Issuer and endpoints all live under /vault/work. A client following the
1392
- // scoped discovery gets redirected to the scoped authorize/token endpoints,
1393
- // which in turn mint the token into the named vault's DB.
1394
- expect(body.issuer).toBe("https://vault.test/vault/work");
1395
- expect(body.authorization_endpoint).toBe("https://vault.test/vault/work/oauth/authorize");
1396
- expect(body.token_endpoint).toBe("https://vault.test/vault/work/oauth/token");
1397
- expect(body.registration_endpoint).toBe("https://vault.test/vault/work/oauth/register");
1398
- expect(body.code_challenge_methods_supported).toEqual(["S256"]);
1399
- });
1400
-
1401
- test("protected-resource advertises a vault-scoped authorization server", async () => {
1402
- const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-protected-resource");
1403
- const res = handleProtectedResource(req, "work");
1404
- const body = await res.json();
1405
- expect(body.resource).toBe("https://vault.test/vault/work/mcp");
1406
- // The authorization server the client should fetch next is the scoped one,
1407
- // so the client discovers the scoped authorize/token endpoints.
1408
- expect(body.authorization_servers).toEqual(["https://vault.test/vault/work"]);
1409
- });
1410
-
1411
- test("scoped discovery honors x-forwarded-host", async () => {
1412
- const req = makeRequest("http://localhost:1940/vault/work/.well-known/oauth-authorization-server", {
1413
- headers: {
1414
- "x-forwarded-proto": "https",
1415
- "x-forwarded-host": "vault.example.com",
1416
- },
1417
- });
1418
- const res = handleAuthorizationServer(req, "work");
1419
- const body = await res.json();
1420
- expect(body.issuer).toBe("https://vault.example.com/vault/work");
1421
- expect(body.authorization_endpoint).toBe("https://vault.example.com/vault/work/oauth/authorize");
1422
- });
1423
- });
1424
-
1425
- // ---------------------------------------------------------------------------
1426
- // Cross-vault code replay defense
1427
- // ---------------------------------------------------------------------------
1428
-
1429
- describe("OAuth token — cross-vault code replay", () => {
1430
- // The in-memory DB in this suite is shared, but handleToken is passed the
1431
- // vaultName it was invoked under. That's the check: a code issued for
1432
- // vault A must not mint a token when presented to vault B's token endpoint,
1433
- // even if both endpoints share storage.
1434
-
1435
- test("code issued for vault A rejected at vault B's token endpoint", async () => {
1436
- const ownerToken = createOwnerToken();
1437
- const clientId = await registerClient();
1438
- const { codeVerifier, codeChallenge } = generatePkce();
1439
- const redirectUri = "https://example.com/callback";
1440
-
1441
- // Issue a code under vault A's authorize endpoint
1442
- const authRes = await handleAuthorizePost(
1443
- makeRequest("https://vault.test/vault/vault-a/oauth/authorize", {
1444
- method: "POST",
1445
- body: new URLSearchParams({
1446
- action: "authorize",
1447
- client_id: clientId,
1448
- redirect_uri: redirectUri,
1449
- code_challenge: codeChallenge,
1450
- code_challenge_method: "S256",
1451
- scope: "full",
1452
- owner_token: ownerToken,
1453
- }),
1454
- }),
1455
- db,
1456
- { vaultName: "vault-a" },
1457
- );
1458
- expect(authRes.status).toBe(302);
1459
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1460
-
1461
- // Try to redeem it at vault B's token endpoint — must reject with
1462
- // invalid_grant per RFC 6749 §5.2. This is the privilege-escalation
1463
- // barrier: without the vault_name pinning, the code would mint a token
1464
- // into whichever vault's DB this handleToken was called against.
1465
- const tokenRes = await handleToken(
1466
- makeRequest("https://vault.test/vault/vault-b/oauth/token", {
1467
- method: "POST",
1468
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1469
- body: new URLSearchParams({
1470
- grant_type: "authorization_code",
1471
- code,
1472
- code_verifier: codeVerifier,
1473
- client_id: clientId,
1474
- redirect_uri: redirectUri,
1475
- }).toString(),
1476
- }),
1477
- db,
1478
- "vault-b",
1479
- );
1480
- expect(tokenRes.status).toBe(400);
1481
- const body = await tokenRes.json();
1482
- expect(body.error).toBe("invalid_grant");
1483
- });
1484
-
1485
- test("code issued for vault A still redeems successfully at vault A's token endpoint", async () => {
1486
- // Control case — same setup as the rejection test, but the token
1487
- // endpoint matches the authorize endpoint. Must succeed.
1488
- const ownerToken = createOwnerToken();
1489
- const clientId = await registerClient();
1490
- const { codeVerifier, codeChallenge } = generatePkce();
1491
- const redirectUri = "https://example.com/callback";
1492
-
1493
- const authRes = await handleAuthorizePost(
1494
- makeRequest("https://vault.test/vault/vault-a/oauth/authorize", {
1495
- method: "POST",
1496
- body: new URLSearchParams({
1497
- action: "authorize",
1498
- client_id: clientId,
1499
- redirect_uri: redirectUri,
1500
- code_challenge: codeChallenge,
1501
- code_challenge_method: "S256",
1502
- scope: "full",
1503
- owner_token: ownerToken,
1504
- }),
1505
- }),
1506
- db,
1507
- { vaultName: "vault-a" },
1508
- );
1509
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1510
-
1511
- const tokenRes = await handleToken(
1512
- makeRequest("https://vault.test/vault/vault-a/oauth/token", {
1513
- method: "POST",
1514
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1515
- body: new URLSearchParams({
1516
- grant_type: "authorization_code",
1517
- code,
1518
- code_verifier: codeVerifier,
1519
- client_id: clientId,
1520
- redirect_uri: redirectUri,
1521
- }).toString(),
1522
- }),
1523
- db,
1524
- "vault-a",
1525
- );
1526
- expect(tokenRes.status).toBe(200);
1527
- const body = await tokenRes.json();
1528
- expect(body.vault).toBe("vault-a");
1529
- expect(body.access_token).toMatch(/^pvt_/);
1530
- });
1531
- });
1532
-
1533
- // ---------------------------------------------------------------------------
1534
- // Phase 0+1: PARACHUTE_HUB_ORIGIN + service catalog in token response
1535
- // ---------------------------------------------------------------------------
1536
-
1537
- describe("OAuth Phase 0: PARACHUTE_HUB_ORIGIN", () => {
1538
- const HUB = "https://hub.example";
1539
- let origHub: string | undefined;
1540
- let origHome: string | undefined;
1541
- let tmpHome: string;
1542
-
1543
- beforeEach(() => {
1544
- origHub = process.env.PARACHUTE_HUB_ORIGIN;
1545
- origHome = process.env.PARACHUTE_HOME;
1546
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-oauth-phase0-"));
1547
- process.env.PARACHUTE_HOME = tmpHome;
1548
- });
1549
-
1550
- afterEach(() => {
1551
- if (origHub === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
1552
- else process.env.PARACHUTE_HUB_ORIGIN = origHub;
1553
- if (origHome === undefined) delete process.env.PARACHUTE_HOME;
1554
- else process.env.PARACHUTE_HOME = origHome;
1555
- fs.rmSync(tmpHome, { recursive: true, force: true });
1556
- });
1557
-
1558
- test("discovery: returns hub issuer when request arrives via hub origin", () => {
1559
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1560
- const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-authorization-server`);
1561
- const res = handleAuthorizationServer(req, "default");
1562
- expect(res.status).toBe(200);
1563
- return res.json().then((body: any) => {
1564
- expect(body.issuer).toBe(HUB);
1565
- expect(body.authorization_endpoint).toBe(`${HUB}/oauth/authorize`);
1566
- expect(body.token_endpoint).toBe(`${HUB}/oauth/token`);
1567
- expect(body.registration_endpoint).toBe(`${HUB}/oauth/register`);
1568
- expect(body.scopes_supported).toContain("vault:read");
1569
- expect(body.scopes_supported).toContain("vault:write");
1570
- });
1571
- });
1572
-
1573
- test("discovery: trailing slash on hub origin is stripped", async () => {
1574
- process.env.PARACHUTE_HUB_ORIGIN = `${HUB}/`;
1575
- const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-authorization-server`);
1576
- const res = handleAuthorizationServer(req, "default");
1577
- const body = await res.json();
1578
- expect(body.issuer).toBe(HUB);
1579
- expect(body.token_endpoint).toBe(`${HUB}/oauth/token`);
1580
- });
1581
-
1582
- test("discovery: protected-resource metadata uses hub as authorization_server when request arrives via hub", async () => {
1583
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1584
- const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-protected-resource`);
1585
- const res = handleProtectedResource(req, "default");
1586
- const body = await res.json();
1587
- expect(body.authorization_servers).toEqual([HUB]);
1588
- expect(body.resource).toBe(`${HUB}/vault/default/mcp`);
1589
- expect(body.scopes_supported).toContain("vault:read");
1590
- });
1591
-
1592
- test("discovery: RFC 8414 — hub env set, request via loopback returns loopback issuer, not hub", async () => {
1593
- // Aaron's bug: mcp-install wrote a loopback URL while PARACHUTE_HUB_ORIGIN
1594
- // was set, so the client fetched discovery via http://127.0.0.1 but got
1595
- // back `issuer: https://hub.example` — origin mismatch, strict OAuth
1596
- // clients (Claude Code) reject. Each origin must advertise its own issuer.
1597
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1598
- const req = makeRequest("http://127.0.0.1:1940/vault/default/.well-known/oauth-authorization-server");
1599
- const res = handleAuthorizationServer(req, "default");
1600
- const body = await res.json();
1601
- expect(body.issuer).toBe("http://127.0.0.1:1940/vault/default");
1602
- expect(body.token_endpoint).toBe("http://127.0.0.1:1940/vault/default/oauth/token");
1603
- expect(body.registration_endpoint).toBe("http://127.0.0.1:1940/vault/default/oauth/register");
1604
- });
1605
-
1606
- test("discovery: protected-resource on loopback returns loopback AS even with hub env set", async () => {
1607
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1608
- const req = makeRequest("http://127.0.0.1:1940/vault/default/.well-known/oauth-protected-resource");
1609
- const res = handleProtectedResource(req, "default");
1610
- const body = await res.json();
1611
- expect(body.authorization_servers).toEqual(["http://127.0.0.1:1940/vault/default"]);
1612
- expect(body.resource).toBe("http://127.0.0.1:1940/vault/default/mcp");
1613
- });
1614
-
1615
- test("discovery: falls back to vault origin when env is unset", async () => {
1616
- delete process.env.PARACHUTE_HUB_ORIGIN;
1617
- const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
1618
- const res = handleAuthorizationServer(req, "default");
1619
- const body = await res.json();
1620
- expect(body.issuer).toBe("https://vault.test/vault/default");
1621
- expect(body.token_endpoint).toBe("https://vault.test/vault/default/oauth/token");
1622
- });
1623
-
1624
- test("scopes_supported publishes new shape alongside legacy names", async () => {
1625
- const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
1626
- const res = handleAuthorizationServer(req, "default");
1627
- const body = await res.json();
1628
- expect(body.scopes_supported).toEqual(expect.arrayContaining(["vault:read", "vault:write", "full", "read"]));
1629
- });
1630
-
1631
- test("token response includes iss = hub when issued on hub origin", async () => {
1632
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1633
- const token = await fullOAuthFlow();
1634
- // Re-issue a token to inspect the body (fullOAuthFlow returns only the string)
1635
- const ownerToken = createOwnerToken();
1636
- const clientId = await registerClient();
1637
- const { codeVerifier, codeChallenge } = generatePkce();
1638
- const redirectUri = "https://example.com/callback";
1639
- const authRes = await handleAuthorizePost(
1640
- makeRequest(`${HUB}/vault/default/oauth/authorize`, {
1641
- method: "POST",
1642
- body: new URLSearchParams({
1643
- action: "authorize",
1644
- client_id: clientId,
1645
- redirect_uri: redirectUri,
1646
- code_challenge: codeChallenge,
1647
- code_challenge_method: "S256",
1648
- scope: "full",
1649
- owner_token: ownerToken,
1650
- }),
1651
- }),
1652
- db,
1653
- { vaultName: "default" },
1654
- );
1655
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1656
- const tokenRes = await handleToken(
1657
- makeRequest(`${HUB}/vault/default/oauth/token`, {
1658
- method: "POST",
1659
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1660
- body: new URLSearchParams({
1661
- grant_type: "authorization_code",
1662
- code,
1663
- code_verifier: codeVerifier,
1664
- client_id: clientId,
1665
- redirect_uri: redirectUri,
1666
- }).toString(),
1667
- }),
1668
- db,
1669
- "default",
1670
- );
1671
- expect(tokenRes.status).toBe(200);
1672
- const body = await tokenRes.json();
1673
- expect(body.iss).toBe(HUB);
1674
- expect(body.services).toEqual({});
1675
- // Back-compat: access_token still present and unchanged shape-wise.
1676
- expect(body.access_token).toMatch(/^pvt_/);
1677
- expect(token).toMatch(/^pvt_/);
1678
- });
1679
-
1680
- test("token iss matches request origin when client came via loopback even with hub env set", async () => {
1681
- // Same-vault twin of the discovery-on-loopback test: a token minted over
1682
- // the loopback flow carries `iss` = the loopback issuer, not the hub.
1683
- // Tokens introspected against loopback discovery's issuer must validate.
1684
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1685
- const ownerToken = createOwnerToken();
1686
- const clientId = await registerClient();
1687
- const { codeVerifier, codeChallenge } = generatePkce();
1688
- const redirectUri = "https://example.com/callback";
1689
- const authRes = await handleAuthorizePost(
1690
- makeRequest("http://127.0.0.1:1940/vault/default/oauth/authorize", {
1691
- method: "POST",
1692
- body: new URLSearchParams({
1693
- action: "authorize",
1694
- client_id: clientId,
1695
- redirect_uri: redirectUri,
1696
- code_challenge: codeChallenge,
1697
- code_challenge_method: "S256",
1698
- scope: "full",
1699
- owner_token: ownerToken,
1700
- }),
1701
- }),
1702
- db,
1703
- { vaultName: "default" },
1704
- );
1705
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1706
- const tokenRes = await handleToken(
1707
- makeRequest("http://127.0.0.1:1940/vault/default/oauth/token", {
1708
- method: "POST",
1709
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1710
- body: new URLSearchParams({
1711
- grant_type: "authorization_code",
1712
- code,
1713
- code_verifier: codeVerifier,
1714
- client_id: clientId,
1715
- redirect_uri: redirectUri,
1716
- }).toString(),
1717
- }),
1718
- db,
1719
- "default",
1720
- );
1721
- const body = await tokenRes.json();
1722
- expect(body.iss).toBe("http://127.0.0.1:1940/vault/default");
1723
- });
1724
-
1725
- test("token response services catalog reflects services.json using hub origin when issued via hub", async () => {
1726
- process.env.PARACHUTE_HUB_ORIGIN = HUB;
1727
- fs.writeFileSync(
1728
- path.join(tmpHome, "services.json"),
1729
- JSON.stringify({
1730
- services: [
1731
- { name: "vault", port: 1940, paths: ["/vault/default"], health: "/health", version: "0.3.0" },
1732
- { name: "notes", port: 1941, paths: ["/notes"], health: "/health", version: "0.1.0" },
1733
- ],
1734
- }),
1735
- );
1736
-
1737
- const ownerToken = createOwnerToken();
1738
- const clientId = await registerClient();
1739
- const { codeVerifier, codeChallenge } = generatePkce();
1740
- const redirectUri = "https://example.com/callback";
1741
- const authRes = await handleAuthorizePost(
1742
- makeRequest(`${HUB}/vault/default/oauth/authorize`, {
1743
- method: "POST",
1744
- body: new URLSearchParams({
1745
- action: "authorize",
1746
- client_id: clientId,
1747
- redirect_uri: redirectUri,
1748
- code_challenge: codeChallenge,
1749
- code_challenge_method: "S256",
1750
- scope: "full",
1751
- owner_token: ownerToken,
1752
- }),
1753
- }),
1754
- db,
1755
- { vaultName: "default" },
1756
- );
1757
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1758
- const tokenRes = await handleToken(
1759
- makeRequest(`${HUB}/vault/default/oauth/token`, {
1760
- method: "POST",
1761
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1762
- body: new URLSearchParams({
1763
- grant_type: "authorization_code",
1764
- code,
1765
- code_verifier: codeVerifier,
1766
- client_id: clientId,
1767
- redirect_uri: redirectUri,
1768
- }).toString(),
1769
- }),
1770
- db,
1771
- "default",
1772
- );
1773
- const body = await tokenRes.json();
1774
- expect(body.services).toEqual({
1775
- vault: { url: `${HUB}/vault/default`, version: "0.3.0" },
1776
- notes: { url: `${HUB}/notes`, version: "0.1.0" },
1777
- });
1778
- });
1779
-
1780
- test("token response services catalog falls back to vault origin when hub env unset", async () => {
1781
- delete process.env.PARACHUTE_HUB_ORIGIN;
1782
- fs.writeFileSync(
1783
- path.join(tmpHome, "services.json"),
1784
- JSON.stringify({
1785
- services: [
1786
- { name: "vault", port: 1940, paths: ["/vault/default"], health: "/health", version: "0.3.0" },
1787
- ],
1788
- }),
1789
- );
1790
-
1791
- const ownerToken = createOwnerToken();
1792
- const clientId = await registerClient();
1793
- const { codeVerifier, codeChallenge } = generatePkce();
1794
- const redirectUri = "https://example.com/callback";
1795
- const authRes = await handleAuthorizePost(
1796
- makeRequest("https://vault.test/vault/default/oauth/authorize", {
1797
- method: "POST",
1798
- body: new URLSearchParams({
1799
- action: "authorize",
1800
- client_id: clientId,
1801
- redirect_uri: redirectUri,
1802
- code_challenge: codeChallenge,
1803
- code_challenge_method: "S256",
1804
- scope: "full",
1805
- owner_token: ownerToken,
1806
- }),
1807
- }),
1808
- db,
1809
- { vaultName: "default" },
1810
- );
1811
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1812
- const tokenRes = await handleToken(
1813
- makeRequest("https://vault.test/vault/default/oauth/token", {
1814
- method: "POST",
1815
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1816
- body: new URLSearchParams({
1817
- grant_type: "authorization_code",
1818
- code,
1819
- code_verifier: codeVerifier,
1820
- client_id: clientId,
1821
- redirect_uri: redirectUri,
1822
- }).toString(),
1823
- }),
1824
- db,
1825
- "default",
1826
- );
1827
- const body = await tokenRes.json();
1828
- expect(body.iss).toBe("https://vault.test/vault/default");
1829
- expect(body.services).toEqual({
1830
- vault: { url: "https://vault.test/vault/default", version: "0.3.0" },
1831
- });
1832
- });
1833
- });
1834
-
1835
- // ---------------------------------------------------------------------------
1836
- // Per-vault rate limiter + memory cap (#93)
1837
- // ---------------------------------------------------------------------------
1838
-
1839
- describe("OAuth consent — per-vault rate limiting (#93)", () => {
1840
- test("getAuthorizeRateLimiter returns the same instance for the same vault name", async () => {
1841
- const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1842
- await import("./owner-auth.ts");
1843
- resetVaultAuthorizeRateLimiters();
1844
- const a1 = getAuthorizeRateLimiter("alpha");
1845
- const a2 = getAuthorizeRateLimiter("alpha");
1846
- expect(a1).toBe(a2);
1847
- });
1848
-
1849
- test("getAuthorizeRateLimiter returns distinct instances per vault", async () => {
1850
- const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1851
- await import("./owner-auth.ts");
1852
- resetVaultAuthorizeRateLimiters();
1853
- const work = getAuthorizeRateLimiter("work");
1854
- const personal = getAuthorizeRateLimiter("personal");
1855
- expect(work).not.toBe(personal);
1856
- });
1857
-
1858
- test("a lockout on one vault's limiter does not lock the same IP on another vault's limiter", async () => {
1859
- const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1860
- await import("./owner-auth.ts");
1861
- resetVaultAuthorizeRateLimiters();
1862
- const ip = "192.0.2.55";
1863
- const work = getAuthorizeRateLimiter("work");
1864
- // Pump enough failures on `work` to trip the default 10-failure threshold.
1865
- for (let i = 0; i < 10; i++) work.recordFailure(ip);
1866
- expect(work.check(ip).allowed).toBe(false);
1867
- // The unrelated vault's limiter should still allow this IP.
1868
- const personal = getAuthorizeRateLimiter("personal");
1869
- expect(personal.check(ip).allowed).toBe(true);
1870
- });
1871
-
1872
- test("entry count is hard-capped — oldest IP is evicted FIFO when full", async () => {
1873
- const { RateLimiter } = await import("./owner-auth.ts");
1874
- // Tiny cap (3) so we don't have to hammer the limiter to prove eviction.
1875
- const limiter = new RateLimiter(10, 60_000, 60_000, 3);
1876
- limiter.recordFailure("10.0.0.1");
1877
- limiter.recordFailure("10.0.0.2");
1878
- limiter.recordFailure("10.0.0.3");
1879
- expect(limiter.size()).toBe(3);
1880
- // Adding a 4th IP must evict the oldest (10.0.0.1) to stay at the cap.
1881
- limiter.recordFailure("10.0.0.4");
1882
- expect(limiter.size()).toBe(3);
1883
- // The evicted IP is treated as untracked → fresh check is allowed.
1884
- expect(limiter.check("10.0.0.1").allowed).toBe(true);
1885
- // Newer entries remain locked into their failure state.
1886
- expect(limiter.check("10.0.0.4").allowed).toBe(true); // still under threshold
1887
- });
1888
- });
1889
-
1890
- // ---------------------------------------------------------------------------
1891
- // Server-bound scope at /authorize, subset enforcement at /token (#94)
1892
- // ---------------------------------------------------------------------------
1893
-
1894
- describe("OAuth scope binding (#94, RFC 6749 §3.3 / §6)", () => {
1895
- test("/authorize floors selected scope to requested — form cannot smuggle a broader scope", async () => {
1896
- const ownerToken = createOwnerToken();
1897
- const clientId = await registerClient();
1898
- const { codeChallenge } = generatePkce();
1899
- const redirectUri = "https://example.com/callback";
1900
-
1901
- const authRes = await handleAuthorizePost(
1902
- makeRequest("https://vault.test/oauth/authorize", {
1903
- method: "POST",
1904
- body: new URLSearchParams({
1905
- action: "authorize",
1906
- client_id: clientId,
1907
- redirect_uri: redirectUri,
1908
- code_challenge: codeChallenge,
1909
- code_challenge_method: "S256",
1910
- scope: "read", // requested = read
1911
- selected_scope: "full", // smuggled broader value
1912
- owner_token: ownerToken,
1913
- }),
1914
- }),
1915
- db,
1916
- { vaultName: "default" },
1917
- );
1918
- expect(authRes.status).toBe(302);
1919
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1920
-
1921
- // The bound scope on the issued auth code must be the narrower of the two.
1922
- const row = db
1923
- .prepare("SELECT scope FROM oauth_codes WHERE code = ?")
1924
- .get(code) as { scope: string };
1925
- expect(row.scope).toBe("read");
1926
- });
1927
-
1928
- test("/token rejects requested scope broader than bound (read → full)", async () => {
1929
- const ownerToken = createOwnerToken();
1930
- const clientId = await registerClient();
1931
- const { codeVerifier, codeChallenge } = generatePkce();
1932
- const redirectUri = "https://example.com/callback";
1933
-
1934
- const authRes = await handleAuthorizePost(
1935
- makeRequest("https://vault.test/oauth/authorize", {
1936
- method: "POST",
1937
- body: new URLSearchParams({
1938
- action: "authorize",
1939
- client_id: clientId,
1940
- redirect_uri: redirectUri,
1941
- code_challenge: codeChallenge,
1942
- code_challenge_method: "S256",
1943
- scope: "read",
1944
- owner_token: ownerToken,
1945
- }),
1946
- }),
1947
- db,
1948
- { vaultName: "default" },
1949
- );
1950
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1951
-
1952
- const tokenRes = await handleToken(
1953
- makeRequest("https://vault.test/oauth/token", {
1954
- method: "POST",
1955
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1956
- body: new URLSearchParams({
1957
- grant_type: "authorization_code",
1958
- code,
1959
- code_verifier: codeVerifier,
1960
- client_id: clientId,
1961
- redirect_uri: redirectUri,
1962
- scope: "full", // attempt to broaden
1963
- }).toString(),
1964
- }),
1965
- db,
1966
- "default",
1967
- );
1968
- expect(tokenRes.status).toBe(400);
1969
- const body = (await tokenRes.json()) as { error?: string };
1970
- expect(body.error).toBe("invalid_scope");
1971
- });
1972
-
1973
- test("/token accepts a narrower requested scope (full → read) and reflects it on the token", async () => {
1974
- const ownerToken = createOwnerToken();
1975
- const clientId = await registerClient();
1976
- const { codeVerifier, codeChallenge } = generatePkce();
1977
- const redirectUri = "https://example.com/callback";
1978
-
1979
- const authRes = await handleAuthorizePost(
1980
- makeRequest("https://vault.test/oauth/authorize", {
1981
- method: "POST",
1982
- body: new URLSearchParams({
1983
- action: "authorize",
1984
- client_id: clientId,
1985
- redirect_uri: redirectUri,
1986
- code_challenge: codeChallenge,
1987
- code_challenge_method: "S256",
1988
- scope: "full",
1989
- owner_token: ownerToken,
1990
- }),
1991
- }),
1992
- db,
1993
- { vaultName: "default" },
1994
- );
1995
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1996
-
1997
- const tokenRes = await handleToken(
1998
- makeRequest("https://vault.test/oauth/token", {
1999
- method: "POST",
2000
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2001
- body: new URLSearchParams({
2002
- grant_type: "authorization_code",
2003
- code,
2004
- code_verifier: codeVerifier,
2005
- client_id: clientId,
2006
- redirect_uri: redirectUri,
2007
- scope: "read", // narrower than bound
2008
- }).toString(),
2009
- }),
2010
- db,
2011
- "default",
2012
- );
2013
- expect(tokenRes.status).toBe(200);
2014
- const body = (await tokenRes.json()) as { scope?: string };
2015
- expect(body.scope).toBe("vault:read");
2016
- });
2017
-
2018
- test("/token treats whitespace-only scope as absent and falls through to bound scope (#196)", async () => {
2019
- // Guard at oauth.ts checks `scope !== null && scope.trim().length > 0`.
2020
- // A client sending `scope= ` is the same as omitting `scope` — we
2021
- // must not run subset enforcement against the whitespace string and
2022
- // reject it as invalid.
2023
- const ownerToken = createOwnerToken();
2024
- const clientId = await registerClient();
2025
- const { codeVerifier, codeChallenge } = generatePkce();
2026
- const redirectUri = "https://example.com/callback";
2027
-
2028
- const authRes = await handleAuthorizePost(
2029
- makeRequest("https://vault.test/oauth/authorize", {
2030
- method: "POST",
2031
- body: new URLSearchParams({
2032
- action: "authorize",
2033
- client_id: clientId,
2034
- redirect_uri: redirectUri,
2035
- code_challenge: codeChallenge,
2036
- code_challenge_method: "S256",
2037
- scope: "read",
2038
- owner_token: ownerToken,
2039
- }),
2040
- }),
2041
- db,
2042
- { vaultName: "default" },
2043
- );
2044
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2045
-
2046
- const tokenRes = await handleToken(
2047
- makeRequest("https://vault.test/oauth/token", {
2048
- method: "POST",
2049
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2050
- body: new URLSearchParams({
2051
- grant_type: "authorization_code",
2052
- code,
2053
- code_verifier: codeVerifier,
2054
- client_id: clientId,
2055
- redirect_uri: redirectUri,
2056
- scope: " ", // whitespace only — should fall through to bound
2057
- }).toString(),
2058
- }),
2059
- db,
2060
- "default",
2061
- );
2062
- expect(tokenRes.status).toBe(200);
2063
- const body = (await tokenRes.json()) as { scope?: string };
2064
- expect(body.scope).toBe("vault:read");
2065
- });
2066
-
2067
- test("/token rejects unknown scope strings even when the bound scope is broad", async () => {
2068
- const ownerToken = createOwnerToken();
2069
- const clientId = await registerClient();
2070
- const { codeVerifier, codeChallenge } = generatePkce();
2071
- const redirectUri = "https://example.com/callback";
2072
-
2073
- const authRes = await handleAuthorizePost(
2074
- makeRequest("https://vault.test/oauth/authorize", {
2075
- method: "POST",
2076
- body: new URLSearchParams({
2077
- action: "authorize",
2078
- client_id: clientId,
2079
- redirect_uri: redirectUri,
2080
- code_challenge: codeChallenge,
2081
- code_challenge_method: "S256",
2082
- scope: "full",
2083
- owner_token: ownerToken,
2084
- }),
2085
- }),
2086
- db,
2087
- { vaultName: "default" },
2088
- );
2089
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2090
-
2091
- const tokenRes = await handleToken(
2092
- makeRequest("https://vault.test/oauth/token", {
2093
- method: "POST",
2094
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2095
- body: new URLSearchParams({
2096
- grant_type: "authorization_code",
2097
- code,
2098
- code_verifier: codeVerifier,
2099
- client_id: clientId,
2100
- redirect_uri: redirectUri,
2101
- scope: "vault:admin", // not in the consent vocabulary
2102
- }).toString(),
2103
- }),
2104
- db,
2105
- "default",
2106
- );
2107
- expect(tokenRes.status).toBe(400);
2108
- const body = (await tokenRes.json()) as { error?: string };
2109
- expect(body.error).toBe("invalid_scope");
2110
- });
2111
-
2112
- test("/token uses the bound scope when no scope param is sent (regression)", async () => {
2113
- const ownerToken = createOwnerToken();
2114
- const clientId = await registerClient();
2115
- const { codeVerifier, codeChallenge } = generatePkce();
2116
- const redirectUri = "https://example.com/callback";
2117
-
2118
- const authRes = await handleAuthorizePost(
2119
- makeRequest("https://vault.test/oauth/authorize", {
2120
- method: "POST",
2121
- body: new URLSearchParams({
2122
- action: "authorize",
2123
- client_id: clientId,
2124
- redirect_uri: redirectUri,
2125
- code_challenge: codeChallenge,
2126
- code_challenge_method: "S256",
2127
- scope: "read",
2128
- owner_token: ownerToken,
2129
- }),
2130
- }),
2131
- db,
2132
- { vaultName: "default" },
2133
- );
2134
- const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2135
-
2136
- const tokenRes = await handleToken(
2137
- makeRequest("https://vault.test/oauth/token", {
2138
- method: "POST",
2139
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2140
- body: new URLSearchParams({
2141
- grant_type: "authorization_code",
2142
- code,
2143
- code_verifier: codeVerifier,
2144
- client_id: clientId,
2145
- redirect_uri: redirectUri,
2146
- // no scope param
2147
- }).toString(),
2148
- }),
2149
- db,
2150
- "default",
2151
- );
2152
- expect(tokenRes.status).toBe(200);
2153
- const body = (await tokenRes.json()) as { scope?: string };
2154
- expect(body.scope).toBe("vault:read");
2155
- });
2156
- });