@openparachute/vault 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +57 -12
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +347 -0
- package/src/routing.ts +365 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -15
package/src/oauth.test.ts
CHANGED
|
@@ -84,7 +84,7 @@ async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
|
|
|
84
84
|
owner_token: ownerToken,
|
|
85
85
|
}),
|
|
86
86
|
});
|
|
87
|
-
const authRes = await handleAuthorizePost(authReq, db);
|
|
87
|
+
const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
|
|
88
88
|
expect(authRes.status).toBe(302);
|
|
89
89
|
const location = new URL(authRes.headers.get("location")!);
|
|
90
90
|
const code = location.searchParams.get("code")!;
|
|
@@ -102,7 +102,7 @@ async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
|
|
|
102
102
|
redirect_uri: redirectUri,
|
|
103
103
|
}).toString(),
|
|
104
104
|
});
|
|
105
|
-
const tokenRes = await handleToken(tokenReq, db);
|
|
105
|
+
const tokenRes = await handleToken(tokenReq, db, "default");
|
|
106
106
|
const tokenBody = await tokenRes.json();
|
|
107
107
|
return tokenBody.access_token;
|
|
108
108
|
}
|
|
@@ -407,7 +407,7 @@ describe("OAuth token exchange", () => {
|
|
|
407
407
|
redirect_uri: "https://example.com/callback",
|
|
408
408
|
}).toString(),
|
|
409
409
|
});
|
|
410
|
-
const res = await handleToken(req, db);
|
|
410
|
+
const res = await handleToken(req, db, "default");
|
|
411
411
|
expect(res.status).toBe(400);
|
|
412
412
|
const body = await res.json();
|
|
413
413
|
expect(body.error).toBe("invalid_grant");
|
|
@@ -432,7 +432,7 @@ describe("OAuth token exchange", () => {
|
|
|
432
432
|
owner_token: ownerToken,
|
|
433
433
|
}),
|
|
434
434
|
});
|
|
435
|
-
const authRes = await handleAuthorizePost(authReq, db);
|
|
435
|
+
const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
|
|
436
436
|
const location = new URL(authRes.headers.get("location")!);
|
|
437
437
|
const code = location.searchParams.get("code")!;
|
|
438
438
|
|
|
@@ -448,7 +448,7 @@ describe("OAuth token exchange", () => {
|
|
|
448
448
|
redirect_uri: redirectUri,
|
|
449
449
|
}).toString(),
|
|
450
450
|
});
|
|
451
|
-
const res = await handleToken(tokenReq, db);
|
|
451
|
+
const res = await handleToken(tokenReq, db, "default");
|
|
452
452
|
expect(res.status).toBe(400);
|
|
453
453
|
const body = await res.json();
|
|
454
454
|
expect(body.error_description).toContain("PKCE");
|
|
@@ -473,7 +473,7 @@ describe("OAuth token exchange", () => {
|
|
|
473
473
|
owner_token: ownerToken,
|
|
474
474
|
}),
|
|
475
475
|
});
|
|
476
|
-
const authRes = await handleAuthorizePost(authReq, db);
|
|
476
|
+
const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
|
|
477
477
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
478
478
|
|
|
479
479
|
const tokenParams = new URLSearchParams({
|
|
@@ -492,6 +492,7 @@ describe("OAuth token exchange", () => {
|
|
|
492
492
|
body: tokenParams,
|
|
493
493
|
}),
|
|
494
494
|
db,
|
|
495
|
+
"default",
|
|
495
496
|
);
|
|
496
497
|
expect(res1.status).toBe(200);
|
|
497
498
|
|
|
@@ -503,6 +504,7 @@ describe("OAuth token exchange", () => {
|
|
|
503
504
|
body: tokenParams,
|
|
504
505
|
}),
|
|
505
506
|
db,
|
|
507
|
+
"default",
|
|
506
508
|
);
|
|
507
509
|
expect(res2.status).toBe(400);
|
|
508
510
|
const body = await res2.json();
|
|
@@ -534,6 +536,7 @@ describe("OAuth token exchange", () => {
|
|
|
534
536
|
}).toString(),
|
|
535
537
|
}),
|
|
536
538
|
db,
|
|
539
|
+
"default",
|
|
537
540
|
);
|
|
538
541
|
expect(res.status).toBe(400);
|
|
539
542
|
const body = await res.json();
|
|
@@ -562,6 +565,7 @@ describe("OAuth token exchange", () => {
|
|
|
562
565
|
}),
|
|
563
566
|
}),
|
|
564
567
|
db,
|
|
568
|
+
{ vaultName: "default" },
|
|
565
569
|
);
|
|
566
570
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
567
571
|
|
|
@@ -579,6 +583,7 @@ describe("OAuth token exchange", () => {
|
|
|
579
583
|
}).toString(),
|
|
580
584
|
}),
|
|
581
585
|
db,
|
|
586
|
+
"default",
|
|
582
587
|
);
|
|
583
588
|
expect(res.status).toBe(400);
|
|
584
589
|
const body = await res.json();
|
|
@@ -593,6 +598,7 @@ describe("OAuth token exchange", () => {
|
|
|
593
598
|
body: "grant_type=client_credentials",
|
|
594
599
|
}),
|
|
595
600
|
db,
|
|
601
|
+
"default",
|
|
596
602
|
);
|
|
597
603
|
expect(res.status).toBe(400);
|
|
598
604
|
const body = await res.json();
|
|
@@ -619,6 +625,7 @@ describe("OAuth token exchange", () => {
|
|
|
619
625
|
}).toString(),
|
|
620
626
|
}),
|
|
621
627
|
db,
|
|
628
|
+
"default",
|
|
622
629
|
);
|
|
623
630
|
expect(res.status).toBe(400);
|
|
624
631
|
const body = await res.json();
|
|
@@ -629,6 +636,7 @@ describe("OAuth token exchange", () => {
|
|
|
629
636
|
const res = await handleToken(
|
|
630
637
|
makeRequest("https://vault.test/oauth/token"),
|
|
631
638
|
db,
|
|
639
|
+
"default",
|
|
632
640
|
);
|
|
633
641
|
expect(res.status).toBe(405);
|
|
634
642
|
});
|
|
@@ -697,7 +705,7 @@ describe("OAuth consent — password mode", () => {
|
|
|
697
705
|
}),
|
|
698
706
|
}),
|
|
699
707
|
db,
|
|
700
|
-
{ ownerPasswordHash: passwordHash },
|
|
708
|
+
{ ownerPasswordHash: passwordHash, vaultName: "default" },
|
|
701
709
|
);
|
|
702
710
|
|
|
703
711
|
expect(authRes.status).toBe(302);
|
|
@@ -717,6 +725,7 @@ describe("OAuth consent — password mode", () => {
|
|
|
717
725
|
}).toString(),
|
|
718
726
|
}),
|
|
719
727
|
db,
|
|
728
|
+
"default",
|
|
720
729
|
);
|
|
721
730
|
const body = await tokenRes.json();
|
|
722
731
|
expect(body.access_token.startsWith("pvt_")).toBe(true);
|
|
@@ -959,6 +968,7 @@ describe("OAuth consent — scope selection", () => {
|
|
|
959
968
|
}),
|
|
960
969
|
}),
|
|
961
970
|
db,
|
|
971
|
+
{ vaultName: "default" },
|
|
962
972
|
);
|
|
963
973
|
expect(authRes.status).toBe(302);
|
|
964
974
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
@@ -976,6 +986,7 @@ describe("OAuth consent — scope selection", () => {
|
|
|
976
986
|
}).toString(),
|
|
977
987
|
}),
|
|
978
988
|
db,
|
|
989
|
+
"default",
|
|
979
990
|
);
|
|
980
991
|
const body = await tokenRes.json();
|
|
981
992
|
expect(body.scope).toBe("read");
|
|
@@ -1001,6 +1012,7 @@ describe("OAuth consent — scope selection", () => {
|
|
|
1001
1012
|
}),
|
|
1002
1013
|
}),
|
|
1003
1014
|
db,
|
|
1015
|
+
{ vaultName: "default" },
|
|
1004
1016
|
);
|
|
1005
1017
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1006
1018
|
|
|
@@ -1017,6 +1029,7 @@ describe("OAuth consent — scope selection", () => {
|
|
|
1017
1029
|
}).toString(),
|
|
1018
1030
|
}),
|
|
1019
1031
|
db,
|
|
1032
|
+
"default",
|
|
1020
1033
|
);
|
|
1021
1034
|
const body = await tokenRes.json();
|
|
1022
1035
|
expect(body.scope).toBe("read");
|
|
@@ -1118,7 +1131,7 @@ describe("OAuth consent — 2FA (TOTP)", () => {
|
|
|
1118
1131
|
}),
|
|
1119
1132
|
}),
|
|
1120
1133
|
db,
|
|
1121
|
-
{ ownerPasswordHash: passwordHash, totpSecret: secret },
|
|
1134
|
+
{ ownerPasswordHash: passwordHash, totpSecret: secret, vaultName: "default" },
|
|
1122
1135
|
);
|
|
1123
1136
|
expect(res.status).toBe(302);
|
|
1124
1137
|
const authCode = new URL(res.headers.get("location")!).searchParams.get("code")!;
|
|
@@ -1138,6 +1151,7 @@ describe("OAuth consent — 2FA (TOTP)", () => {
|
|
|
1138
1151
|
}).toString(),
|
|
1139
1152
|
}),
|
|
1140
1153
|
db,
|
|
1154
|
+
"default",
|
|
1141
1155
|
);
|
|
1142
1156
|
expect(tokenRes.status).toBe(200);
|
|
1143
1157
|
const body = await tokenRes.json();
|
|
@@ -1240,3 +1254,270 @@ describe("OAuth consent — 2FA (TOTP)", () => {
|
|
|
1240
1254
|
expect(html).toContain("Invalid credentials");
|
|
1241
1255
|
});
|
|
1242
1256
|
});
|
|
1257
|
+
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
// Token response — honest vault name (Fix 1)
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
|
|
1262
|
+
describe("OAuth token response — vault name", () => {
|
|
1263
|
+
test("includes vault name for the default (unscoped) flow", async () => {
|
|
1264
|
+
const ownerToken = createOwnerToken();
|
|
1265
|
+
const clientId = await registerClient();
|
|
1266
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1267
|
+
const redirectUri = "https://example.com/callback";
|
|
1268
|
+
|
|
1269
|
+
const authRes = await handleAuthorizePost(
|
|
1270
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
1271
|
+
method: "POST",
|
|
1272
|
+
body: new URLSearchParams({
|
|
1273
|
+
action: "authorize",
|
|
1274
|
+
client_id: clientId,
|
|
1275
|
+
redirect_uri: redirectUri,
|
|
1276
|
+
code_challenge: codeChallenge,
|
|
1277
|
+
code_challenge_method: "S256",
|
|
1278
|
+
scope: "full",
|
|
1279
|
+
owner_token: ownerToken,
|
|
1280
|
+
}),
|
|
1281
|
+
}),
|
|
1282
|
+
db,
|
|
1283
|
+
{ vaultName: "default" },
|
|
1284
|
+
);
|
|
1285
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1286
|
+
|
|
1287
|
+
const tokenRes = await handleToken(
|
|
1288
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
1289
|
+
method: "POST",
|
|
1290
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1291
|
+
body: new URLSearchParams({
|
|
1292
|
+
grant_type: "authorization_code",
|
|
1293
|
+
code,
|
|
1294
|
+
code_verifier: codeVerifier,
|
|
1295
|
+
client_id: clientId,
|
|
1296
|
+
redirect_uri: redirectUri,
|
|
1297
|
+
}).toString(),
|
|
1298
|
+
}),
|
|
1299
|
+
db,
|
|
1300
|
+
"default",
|
|
1301
|
+
);
|
|
1302
|
+
const body = await tokenRes.json();
|
|
1303
|
+
expect(body.access_token).toMatch(/^pvt_/);
|
|
1304
|
+
expect(body.vault).toBe("default");
|
|
1305
|
+
expect(body.token_type).toBe("bearer");
|
|
1306
|
+
expect(body.scope).toBe("full");
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("includes vault name for a scoped (named-vault) flow", async () => {
|
|
1310
|
+
// The vaultName is purely a response-shape concern; the DB is the same
|
|
1311
|
+
// in-memory DB here. The point is that handleToken echoes the name it
|
|
1312
|
+
// was called with, so the client can trust which vault it just connected to.
|
|
1313
|
+
const ownerToken = createOwnerToken();
|
|
1314
|
+
const clientId = await registerClient();
|
|
1315
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1316
|
+
const redirectUri = "https://example.com/callback";
|
|
1317
|
+
|
|
1318
|
+
const authRes = await handleAuthorizePost(
|
|
1319
|
+
makeRequest("https://vault.test/vaults/work/oauth/authorize", {
|
|
1320
|
+
method: "POST",
|
|
1321
|
+
body: new URLSearchParams({
|
|
1322
|
+
action: "authorize",
|
|
1323
|
+
client_id: clientId,
|
|
1324
|
+
redirect_uri: redirectUri,
|
|
1325
|
+
code_challenge: codeChallenge,
|
|
1326
|
+
code_challenge_method: "S256",
|
|
1327
|
+
scope: "full",
|
|
1328
|
+
owner_token: ownerToken,
|
|
1329
|
+
}),
|
|
1330
|
+
}),
|
|
1331
|
+
db,
|
|
1332
|
+
{ vaultName: "work" },
|
|
1333
|
+
);
|
|
1334
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1335
|
+
|
|
1336
|
+
const tokenRes = await handleToken(
|
|
1337
|
+
makeRequest("https://vault.test/vaults/work/oauth/token", {
|
|
1338
|
+
method: "POST",
|
|
1339
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1340
|
+
body: new URLSearchParams({
|
|
1341
|
+
grant_type: "authorization_code",
|
|
1342
|
+
code,
|
|
1343
|
+
code_verifier: codeVerifier,
|
|
1344
|
+
client_id: clientId,
|
|
1345
|
+
redirect_uri: redirectUri,
|
|
1346
|
+
}).toString(),
|
|
1347
|
+
}),
|
|
1348
|
+
db,
|
|
1349
|
+
"work",
|
|
1350
|
+
);
|
|
1351
|
+
const body = await tokenRes.json();
|
|
1352
|
+
expect(body.vault).toBe("work");
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// ---------------------------------------------------------------------------
|
|
1357
|
+
// Vault-scoped discovery (Fix 3 — routing coherence)
|
|
1358
|
+
// ---------------------------------------------------------------------------
|
|
1359
|
+
|
|
1360
|
+
describe("OAuth discovery — vault-scoped", () => {
|
|
1361
|
+
test("authorization-server metadata scopes all endpoints to the vault", async () => {
|
|
1362
|
+
const req = makeRequest("https://vault.test/vaults/work/.well-known/oauth-authorization-server");
|
|
1363
|
+
const res = handleAuthorizationServer(req, "work");
|
|
1364
|
+
const body = await res.json();
|
|
1365
|
+
// Issuer and endpoints all live under /vaults/work. This is what makes
|
|
1366
|
+
// vault-scoped OAuth work end-to-end: a client following the scoped
|
|
1367
|
+
// discovery gets redirected to the scoped authorize/token endpoints,
|
|
1368
|
+
// which in turn mint the token into the named vault's DB.
|
|
1369
|
+
expect(body.issuer).toBe("https://vault.test/vaults/work");
|
|
1370
|
+
expect(body.authorization_endpoint).toBe("https://vault.test/vaults/work/oauth/authorize");
|
|
1371
|
+
expect(body.token_endpoint).toBe("https://vault.test/vaults/work/oauth/token");
|
|
1372
|
+
expect(body.registration_endpoint).toBe("https://vault.test/vaults/work/oauth/register");
|
|
1373
|
+
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
test("authorization-server metadata defaults to unscoped endpoints when no vaultName", async () => {
|
|
1377
|
+
// Regression check — the unscoped form still returns unscoped endpoints.
|
|
1378
|
+
const req = makeRequest("https://vault.test/.well-known/oauth-authorization-server");
|
|
1379
|
+
const res = handleAuthorizationServer(req);
|
|
1380
|
+
const body = await res.json();
|
|
1381
|
+
expect(body.issuer).toBe("https://vault.test");
|
|
1382
|
+
expect(body.authorization_endpoint).toBe("https://vault.test/oauth/authorize");
|
|
1383
|
+
expect(body.token_endpoint).toBe("https://vault.test/oauth/token");
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test("protected-resource advertises a vault-scoped authorization server", async () => {
|
|
1387
|
+
const req = makeRequest("https://vault.test/vaults/work/.well-known/oauth-protected-resource");
|
|
1388
|
+
const res = handleProtectedResource(req, "/vaults/work/mcp", "/vaults/work");
|
|
1389
|
+
const body = await res.json();
|
|
1390
|
+
expect(body.resource).toBe("https://vault.test/vaults/work/mcp");
|
|
1391
|
+
// The authorization server the client should fetch next is the scoped one,
|
|
1392
|
+
// so the client discovers the scoped authorize/token endpoints.
|
|
1393
|
+
expect(body.authorization_servers).toEqual(["https://vault.test/vaults/work"]);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("protected-resource defaults to root authorization server when no prefix", async () => {
|
|
1397
|
+
const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
|
|
1398
|
+
const res = handleProtectedResource(req);
|
|
1399
|
+
const body = await res.json();
|
|
1400
|
+
expect(body.authorization_servers).toEqual(["https://vault.test"]);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
test("scoped discovery honors x-forwarded-host", async () => {
|
|
1404
|
+
const req = makeRequest("http://localhost:1940/vaults/work/.well-known/oauth-authorization-server", {
|
|
1405
|
+
headers: {
|
|
1406
|
+
"x-forwarded-proto": "https",
|
|
1407
|
+
"x-forwarded-host": "vault.example.com",
|
|
1408
|
+
},
|
|
1409
|
+
});
|
|
1410
|
+
const res = handleAuthorizationServer(req, "work");
|
|
1411
|
+
const body = await res.json();
|
|
1412
|
+
expect(body.issuer).toBe("https://vault.example.com/vaults/work");
|
|
1413
|
+
expect(body.authorization_endpoint).toBe("https://vault.example.com/vaults/work/oauth/authorize");
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// ---------------------------------------------------------------------------
|
|
1418
|
+
// Cross-vault code replay defense
|
|
1419
|
+
// ---------------------------------------------------------------------------
|
|
1420
|
+
|
|
1421
|
+
describe("OAuth token — cross-vault code replay", () => {
|
|
1422
|
+
// The in-memory DB in this suite is shared, but handleToken is passed the
|
|
1423
|
+
// vaultName it was invoked under. That's the check: a code issued for
|
|
1424
|
+
// vault A must not mint a token when presented to vault B's token endpoint,
|
|
1425
|
+
// even if both endpoints share storage.
|
|
1426
|
+
|
|
1427
|
+
test("code issued for vault A rejected at vault B's token endpoint", async () => {
|
|
1428
|
+
const ownerToken = createOwnerToken();
|
|
1429
|
+
const clientId = await registerClient();
|
|
1430
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1431
|
+
const redirectUri = "https://example.com/callback";
|
|
1432
|
+
|
|
1433
|
+
// Issue a code under vault A's authorize endpoint
|
|
1434
|
+
const authRes = await handleAuthorizePost(
|
|
1435
|
+
makeRequest("https://vault.test/vaults/vault-a/oauth/authorize", {
|
|
1436
|
+
method: "POST",
|
|
1437
|
+
body: new URLSearchParams({
|
|
1438
|
+
action: "authorize",
|
|
1439
|
+
client_id: clientId,
|
|
1440
|
+
redirect_uri: redirectUri,
|
|
1441
|
+
code_challenge: codeChallenge,
|
|
1442
|
+
code_challenge_method: "S256",
|
|
1443
|
+
scope: "full",
|
|
1444
|
+
owner_token: ownerToken,
|
|
1445
|
+
}),
|
|
1446
|
+
}),
|
|
1447
|
+
db,
|
|
1448
|
+
{ vaultName: "vault-a" },
|
|
1449
|
+
);
|
|
1450
|
+
expect(authRes.status).toBe(302);
|
|
1451
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1452
|
+
|
|
1453
|
+
// Try to redeem it at vault B's token endpoint — must reject with
|
|
1454
|
+
// invalid_grant per RFC 6749 §5.2. This is the privilege-escalation
|
|
1455
|
+
// barrier: without the vault_name pinning, the code would mint a token
|
|
1456
|
+
// into whichever vault's DB this handleToken was called against.
|
|
1457
|
+
const tokenRes = await handleToken(
|
|
1458
|
+
makeRequest("https://vault.test/vaults/vault-b/oauth/token", {
|
|
1459
|
+
method: "POST",
|
|
1460
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1461
|
+
body: new URLSearchParams({
|
|
1462
|
+
grant_type: "authorization_code",
|
|
1463
|
+
code,
|
|
1464
|
+
code_verifier: codeVerifier,
|
|
1465
|
+
client_id: clientId,
|
|
1466
|
+
redirect_uri: redirectUri,
|
|
1467
|
+
}).toString(),
|
|
1468
|
+
}),
|
|
1469
|
+
db,
|
|
1470
|
+
"vault-b",
|
|
1471
|
+
);
|
|
1472
|
+
expect(tokenRes.status).toBe(400);
|
|
1473
|
+
const body = await tokenRes.json();
|
|
1474
|
+
expect(body.error).toBe("invalid_grant");
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test("code issued for vault A still redeems successfully at vault A's token endpoint", async () => {
|
|
1478
|
+
// Control case — same setup as the rejection test, but the token
|
|
1479
|
+
// endpoint matches the authorize endpoint. Must succeed.
|
|
1480
|
+
const ownerToken = createOwnerToken();
|
|
1481
|
+
const clientId = await registerClient();
|
|
1482
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1483
|
+
const redirectUri = "https://example.com/callback";
|
|
1484
|
+
|
|
1485
|
+
const authRes = await handleAuthorizePost(
|
|
1486
|
+
makeRequest("https://vault.test/vaults/vault-a/oauth/authorize", {
|
|
1487
|
+
method: "POST",
|
|
1488
|
+
body: new URLSearchParams({
|
|
1489
|
+
action: "authorize",
|
|
1490
|
+
client_id: clientId,
|
|
1491
|
+
redirect_uri: redirectUri,
|
|
1492
|
+
code_challenge: codeChallenge,
|
|
1493
|
+
code_challenge_method: "S256",
|
|
1494
|
+
scope: "full",
|
|
1495
|
+
owner_token: ownerToken,
|
|
1496
|
+
}),
|
|
1497
|
+
}),
|
|
1498
|
+
db,
|
|
1499
|
+
{ vaultName: "vault-a" },
|
|
1500
|
+
);
|
|
1501
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1502
|
+
|
|
1503
|
+
const tokenRes = await handleToken(
|
|
1504
|
+
makeRequest("https://vault.test/vaults/vault-a/oauth/token", {
|
|
1505
|
+
method: "POST",
|
|
1506
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1507
|
+
body: new URLSearchParams({
|
|
1508
|
+
grant_type: "authorization_code",
|
|
1509
|
+
code,
|
|
1510
|
+
code_verifier: codeVerifier,
|
|
1511
|
+
client_id: clientId,
|
|
1512
|
+
redirect_uri: redirectUri,
|
|
1513
|
+
}).toString(),
|
|
1514
|
+
}),
|
|
1515
|
+
db,
|
|
1516
|
+
"vault-a",
|
|
1517
|
+
);
|
|
1518
|
+
expect(tokenRes.status).toBe(200);
|
|
1519
|
+
const body = await tokenRes.json();
|
|
1520
|
+
expect(body.vault).toBe("vault-a");
|
|
1521
|
+
expect(body.access_token).toMatch(/^pvt_/);
|
|
1522
|
+
});
|
|
1523
|
+
});
|
package/src/oauth.ts
CHANGED
|
@@ -63,23 +63,44 @@ function escapeHtml(s: string): string {
|
|
|
63
63
|
// Discovery endpoints
|
|
64
64
|
// ---------------------------------------------------------------------------
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
|
68
|
+
*
|
|
69
|
+
* @param mcpPath — the resource URL (e.g. `/mcp` or `/vaults/X/mcp`).
|
|
70
|
+
* @param authServerPrefix — path prefix for the authorization server issuer
|
|
71
|
+
* (e.g. `""` for global, `/vaults/X` for scoped).
|
|
72
|
+
* The client discovers the AS metadata at
|
|
73
|
+
* `{base}{prefix}/.well-known/oauth-authorization-server`.
|
|
74
|
+
*/
|
|
75
|
+
export function handleProtectedResource(
|
|
76
|
+
req: Request,
|
|
77
|
+
mcpPath = "/mcp",
|
|
78
|
+
authServerPrefix = "",
|
|
79
|
+
): Response {
|
|
67
80
|
const base = getBaseUrl(req);
|
|
68
81
|
return Response.json({
|
|
69
82
|
resource: `${base}${mcpPath}`,
|
|
70
|
-
authorization_servers: [base],
|
|
83
|
+
authorization_servers: [`${base}${authServerPrefix}`],
|
|
71
84
|
scopes_supported: ["full", "read"],
|
|
72
85
|
bearer_methods_supported: ["header"],
|
|
73
86
|
});
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
|
|
89
|
+
/**
|
|
90
|
+
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
91
|
+
*
|
|
92
|
+
* @param vaultName — when provided, returns vault-scoped endpoints
|
|
93
|
+
* (`/vaults/<name>/oauth/*`) and issuer. Tokens minted
|
|
94
|
+
* via these endpoints are scoped to the named vault's DB.
|
|
95
|
+
*/
|
|
96
|
+
export function handleAuthorizationServer(req: Request, vaultName?: string): Response {
|
|
77
97
|
const base = getBaseUrl(req);
|
|
98
|
+
const prefix = vaultName ? `/vaults/${vaultName}` : "";
|
|
78
99
|
return Response.json({
|
|
79
|
-
issuer: base
|
|
80
|
-
authorization_endpoint: `${base}/oauth/authorize`,
|
|
81
|
-
token_endpoint: `${base}/oauth/token`,
|
|
82
|
-
registration_endpoint: `${base}/oauth/register`,
|
|
100
|
+
issuer: `${base}${prefix}`,
|
|
101
|
+
authorization_endpoint: `${base}${prefix}/oauth/authorize`,
|
|
102
|
+
token_endpoint: `${base}${prefix}/oauth/token`,
|
|
103
|
+
registration_endpoint: `${base}${prefix}/oauth/register`,
|
|
83
104
|
response_types_supported: ["code"],
|
|
84
105
|
code_challenge_methods_supported: ["S256"],
|
|
85
106
|
grant_types_supported: ["authorization_code"],
|
|
@@ -354,10 +375,12 @@ export async function handleAuthorizePost(
|
|
|
354
375
|
const code = crypto.randomBytes(32).toString("base64url");
|
|
355
376
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
|
|
356
377
|
|
|
378
|
+
// vault_name pins the code to the issuing vault. handleToken rejects
|
|
379
|
+
// any code whose vault_name doesn't match the token-endpoint's vault.
|
|
357
380
|
db.prepare(`
|
|
358
|
-
INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at)
|
|
359
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
360
|
-
`).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString());
|
|
381
|
+
INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at, vault_name)
|
|
382
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
383
|
+
`).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
|
|
361
384
|
|
|
362
385
|
redirect.searchParams.set("code", code);
|
|
363
386
|
return Response.redirect(redirect.toString(), 302);
|
|
@@ -367,7 +390,19 @@ export async function handleAuthorizePost(
|
|
|
367
390
|
// Token endpoint
|
|
368
391
|
// ---------------------------------------------------------------------------
|
|
369
392
|
|
|
370
|
-
|
|
393
|
+
/**
|
|
394
|
+
* OAuth 2.1 token endpoint — exchanges an auth code for a vault token.
|
|
395
|
+
*
|
|
396
|
+
* @param vaultName — the name of the vault this token is scoped to. Included
|
|
397
|
+
* in the response as `vault: <name>` so the client knows
|
|
398
|
+
* which vault was just connected. The token itself lives
|
|
399
|
+
* in that vault's tokens table.
|
|
400
|
+
*/
|
|
401
|
+
export async function handleToken(
|
|
402
|
+
req: Request,
|
|
403
|
+
db: Database,
|
|
404
|
+
vaultName: string,
|
|
405
|
+
): Promise<Response> {
|
|
371
406
|
if (req.method !== "POST") {
|
|
372
407
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
373
408
|
}
|
|
@@ -404,7 +439,7 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
|
|
|
404
439
|
|
|
405
440
|
// Look up the auth code
|
|
406
441
|
const authCode = db.prepare(`
|
|
407
|
-
SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used
|
|
442
|
+
SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used, vault_name
|
|
408
443
|
FROM oauth_codes WHERE code = ?
|
|
409
444
|
`).get(code) as {
|
|
410
445
|
code: string;
|
|
@@ -415,6 +450,7 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
|
|
|
415
450
|
redirect_uri: string;
|
|
416
451
|
expires_at: string;
|
|
417
452
|
used: number;
|
|
453
|
+
vault_name: string | null;
|
|
418
454
|
} | null;
|
|
419
455
|
|
|
420
456
|
if (!authCode) {
|
|
@@ -441,6 +477,14 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
|
|
|
441
477
|
return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
|
|
442
478
|
}
|
|
443
479
|
|
|
480
|
+
// Validate the code was issued for the same vault this token endpoint
|
|
481
|
+
// serves. Without this, a code issued under /vaults/A/oauth/authorize
|
|
482
|
+
// could be presented to /vaults/B/oauth/token and the token would be
|
|
483
|
+
// minted into B's DB — privilege escalation across vault boundaries.
|
|
484
|
+
if (authCode.vault_name !== vaultName) {
|
|
485
|
+
return Response.json({ error: "invalid_grant", error_description: "vault mismatch" }, { status: 400 });
|
|
486
|
+
}
|
|
487
|
+
|
|
444
488
|
// PKCE verification: SHA256(code_verifier) must match stored code_challenge
|
|
445
489
|
const expectedChallenge = crypto
|
|
446
490
|
.createHash("sha256")
|
|
@@ -466,6 +510,7 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
|
|
|
466
510
|
access_token: fullToken,
|
|
467
511
|
token_type: "bearer",
|
|
468
512
|
scope: permission,
|
|
513
|
+
vault: vaultName,
|
|
469
514
|
});
|
|
470
515
|
}
|
|
471
516
|
|