@openparachute/vault 0.2.3 → 0.3.0-rc.1
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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
package/src/oauth.test.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
6
6
|
import { Database } from "bun:sqlite";
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
8
11
|
import { initSchema } from "../core/src/schema.ts";
|
|
9
12
|
import { generateToken, createToken, resolveToken } from "./token-store.ts";
|
|
10
13
|
import {
|
|
@@ -113,44 +116,45 @@ async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
|
|
|
113
116
|
|
|
114
117
|
describe("OAuth discovery", () => {
|
|
115
118
|
test("protected resource metadata", async () => {
|
|
116
|
-
const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
|
|
117
|
-
const res = handleProtectedResource(req);
|
|
119
|
+
const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-protected-resource");
|
|
120
|
+
const res = handleProtectedResource(req, "default");
|
|
118
121
|
expect(res.status).toBe(200);
|
|
119
122
|
const body = await res.json();
|
|
120
|
-
expect(body.resource).toBe("https://vault.test/mcp");
|
|
123
|
+
expect(body.resource).toBe("https://vault.test/vault/default/mcp");
|
|
124
|
+
expect(body.authorization_servers).toEqual(["https://vault.test/vault/default"]);
|
|
121
125
|
expect(body.scopes_supported).toContain("full");
|
|
122
126
|
expect(body.scopes_supported).toContain("read");
|
|
123
127
|
});
|
|
124
128
|
|
|
125
129
|
test("authorization server metadata", async () => {
|
|
126
|
-
const req = makeRequest("https://vault.test/.well-known/oauth-authorization-server");
|
|
127
|
-
const res = handleAuthorizationServer(req);
|
|
130
|
+
const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
|
|
131
|
+
const res = handleAuthorizationServer(req, "default");
|
|
128
132
|
expect(res.status).toBe(200);
|
|
129
133
|
const body = await res.json();
|
|
130
|
-
expect(body.issuer).toBe("https://vault.test");
|
|
131
|
-
expect(body.authorization_endpoint).toBe("https://vault.test/oauth/authorize");
|
|
132
|
-
expect(body.token_endpoint).toBe("https://vault.test/oauth/token");
|
|
133
|
-
expect(body.registration_endpoint).toBe("https://vault.test/oauth/register");
|
|
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");
|
|
134
138
|
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
|
135
139
|
});
|
|
136
140
|
|
|
137
|
-
test("resource URL
|
|
138
|
-
const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
|
|
139
|
-
const res = handleProtectedResource(req, "
|
|
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");
|
|
140
144
|
const body = await res.json();
|
|
141
|
-
expect(body.resource).toBe("https://vault.test/
|
|
145
|
+
expect(body.resource).toBe("https://vault.test/vault/work/mcp");
|
|
142
146
|
});
|
|
143
147
|
|
|
144
148
|
test("uses x-forwarded-proto and x-forwarded-host", async () => {
|
|
145
|
-
const req = makeRequest("http://localhost:1940/.well-known/oauth-protected-resource", {
|
|
149
|
+
const req = makeRequest("http://localhost:1940/vault/default/.well-known/oauth-protected-resource", {
|
|
146
150
|
headers: {
|
|
147
151
|
"x-forwarded-proto": "https",
|
|
148
152
|
"x-forwarded-host": "vault.example.com",
|
|
149
153
|
},
|
|
150
154
|
});
|
|
151
|
-
const res = handleProtectedResource(req);
|
|
155
|
+
const res = handleProtectedResource(req, "default");
|
|
152
156
|
const body = await res.json();
|
|
153
|
-
expect(body.resource).toBe("https://vault.example.com/mcp");
|
|
157
|
+
expect(body.resource).toBe("https://vault.example.com/vault/default/mcp");
|
|
154
158
|
});
|
|
155
159
|
});
|
|
156
160
|
|
|
@@ -989,7 +993,7 @@ describe("OAuth consent — scope selection", () => {
|
|
|
989
993
|
"default",
|
|
990
994
|
);
|
|
991
995
|
const body = await tokenRes.json();
|
|
992
|
-
expect(body.scope).toBe("read");
|
|
996
|
+
expect(body.scope).toBe("vault:read");
|
|
993
997
|
});
|
|
994
998
|
|
|
995
999
|
test("defaults selected_scope to requested scope when not provided", async () => {
|
|
@@ -1032,7 +1036,7 @@ describe("OAuth consent — scope selection", () => {
|
|
|
1032
1036
|
"default",
|
|
1033
1037
|
);
|
|
1034
1038
|
const body = await tokenRes.json();
|
|
1035
|
-
expect(body.scope).toBe("read");
|
|
1039
|
+
expect(body.scope).toBe("vault:read");
|
|
1036
1040
|
});
|
|
1037
1041
|
|
|
1038
1042
|
test("consent HTML includes both scope radio buttons", async () => {
|
|
@@ -1303,7 +1307,7 @@ describe("OAuth token response — vault name", () => {
|
|
|
1303
1307
|
expect(body.access_token).toMatch(/^pvt_/);
|
|
1304
1308
|
expect(body.vault).toBe("default");
|
|
1305
1309
|
expect(body.token_type).toBe("bearer");
|
|
1306
|
-
expect(body.scope).toBe("
|
|
1310
|
+
expect(body.scope).toBe("vault:read vault:write vault:admin");
|
|
1307
1311
|
});
|
|
1308
1312
|
|
|
1309
1313
|
test("includes vault name for a scoped (named-vault) flow", async () => {
|
|
@@ -1316,7 +1320,7 @@ describe("OAuth token response — vault name", () => {
|
|
|
1316
1320
|
const redirectUri = "https://example.com/callback";
|
|
1317
1321
|
|
|
1318
1322
|
const authRes = await handleAuthorizePost(
|
|
1319
|
-
makeRequest("https://vault.test/
|
|
1323
|
+
makeRequest("https://vault.test/vault/work/oauth/authorize", {
|
|
1320
1324
|
method: "POST",
|
|
1321
1325
|
body: new URLSearchParams({
|
|
1322
1326
|
action: "authorize",
|
|
@@ -1334,7 +1338,7 @@ describe("OAuth token response — vault name", () => {
|
|
|
1334
1338
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1335
1339
|
|
|
1336
1340
|
const tokenRes = await handleToken(
|
|
1337
|
-
makeRequest("https://vault.test/
|
|
1341
|
+
makeRequest("https://vault.test/vault/work/oauth/token", {
|
|
1338
1342
|
method: "POST",
|
|
1339
1343
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1340
1344
|
body: new URLSearchParams({
|
|
@@ -1359,49 +1363,31 @@ describe("OAuth token response — vault name", () => {
|
|
|
1359
1363
|
|
|
1360
1364
|
describe("OAuth discovery — vault-scoped", () => {
|
|
1361
1365
|
test("authorization-server metadata scopes all endpoints to the vault", async () => {
|
|
1362
|
-
const req = makeRequest("https://vault.test/
|
|
1366
|
+
const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-authorization-server");
|
|
1363
1367
|
const res = handleAuthorizationServer(req, "work");
|
|
1364
1368
|
const body = await res.json();
|
|
1365
|
-
// Issuer and endpoints all live under /
|
|
1366
|
-
//
|
|
1367
|
-
// discovery gets redirected to the scoped authorize/token endpoints,
|
|
1369
|
+
// Issuer and endpoints all live under /vault/work. A client following the
|
|
1370
|
+
// scoped discovery gets redirected to the scoped authorize/token endpoints,
|
|
1368
1371
|
// which in turn mint the token into the named vault's DB.
|
|
1369
|
-
expect(body.issuer).toBe("https://vault.test/
|
|
1370
|
-
expect(body.authorization_endpoint).toBe("https://vault.test/
|
|
1371
|
-
expect(body.token_endpoint).toBe("https://vault.test/
|
|
1372
|
-
expect(body.registration_endpoint).toBe("https://vault.test/
|
|
1372
|
+
expect(body.issuer).toBe("https://vault.test/vault/work");
|
|
1373
|
+
expect(body.authorization_endpoint).toBe("https://vault.test/vault/work/oauth/authorize");
|
|
1374
|
+
expect(body.token_endpoint).toBe("https://vault.test/vault/work/oauth/token");
|
|
1375
|
+
expect(body.registration_endpoint).toBe("https://vault.test/vault/work/oauth/register");
|
|
1373
1376
|
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
|
1374
1377
|
});
|
|
1375
1378
|
|
|
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
1379
|
test("protected-resource advertises a vault-scoped authorization server", async () => {
|
|
1387
|
-
const req = makeRequest("https://vault.test/
|
|
1388
|
-
const res = handleProtectedResource(req, "
|
|
1380
|
+
const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-protected-resource");
|
|
1381
|
+
const res = handleProtectedResource(req, "work");
|
|
1389
1382
|
const body = await res.json();
|
|
1390
|
-
expect(body.resource).toBe("https://vault.test/
|
|
1383
|
+
expect(body.resource).toBe("https://vault.test/vault/work/mcp");
|
|
1391
1384
|
// The authorization server the client should fetch next is the scoped one,
|
|
1392
1385
|
// so the client discovers the scoped authorize/token endpoints.
|
|
1393
|
-
expect(body.authorization_servers).toEqual(["https://vault.test/
|
|
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"]);
|
|
1386
|
+
expect(body.authorization_servers).toEqual(["https://vault.test/vault/work"]);
|
|
1401
1387
|
});
|
|
1402
1388
|
|
|
1403
1389
|
test("scoped discovery honors x-forwarded-host", async () => {
|
|
1404
|
-
const req = makeRequest("http://localhost:1940/
|
|
1390
|
+
const req = makeRequest("http://localhost:1940/vault/work/.well-known/oauth-authorization-server", {
|
|
1405
1391
|
headers: {
|
|
1406
1392
|
"x-forwarded-proto": "https",
|
|
1407
1393
|
"x-forwarded-host": "vault.example.com",
|
|
@@ -1409,8 +1395,8 @@ describe("OAuth discovery — vault-scoped", () => {
|
|
|
1409
1395
|
});
|
|
1410
1396
|
const res = handleAuthorizationServer(req, "work");
|
|
1411
1397
|
const body = await res.json();
|
|
1412
|
-
expect(body.issuer).toBe("https://vault.example.com/
|
|
1413
|
-
expect(body.authorization_endpoint).toBe("https://vault.example.com/
|
|
1398
|
+
expect(body.issuer).toBe("https://vault.example.com/vault/work");
|
|
1399
|
+
expect(body.authorization_endpoint).toBe("https://vault.example.com/vault/work/oauth/authorize");
|
|
1414
1400
|
});
|
|
1415
1401
|
});
|
|
1416
1402
|
|
|
@@ -1432,7 +1418,7 @@ describe("OAuth token — cross-vault code replay", () => {
|
|
|
1432
1418
|
|
|
1433
1419
|
// Issue a code under vault A's authorize endpoint
|
|
1434
1420
|
const authRes = await handleAuthorizePost(
|
|
1435
|
-
makeRequest("https://vault.test/
|
|
1421
|
+
makeRequest("https://vault.test/vault/vault-a/oauth/authorize", {
|
|
1436
1422
|
method: "POST",
|
|
1437
1423
|
body: new URLSearchParams({
|
|
1438
1424
|
action: "authorize",
|
|
@@ -1455,7 +1441,7 @@ describe("OAuth token — cross-vault code replay", () => {
|
|
|
1455
1441
|
// barrier: without the vault_name pinning, the code would mint a token
|
|
1456
1442
|
// into whichever vault's DB this handleToken was called against.
|
|
1457
1443
|
const tokenRes = await handleToken(
|
|
1458
|
-
makeRequest("https://vault.test/
|
|
1444
|
+
makeRequest("https://vault.test/vault/vault-b/oauth/token", {
|
|
1459
1445
|
method: "POST",
|
|
1460
1446
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1461
1447
|
body: new URLSearchParams({
|
|
@@ -1483,7 +1469,7 @@ describe("OAuth token — cross-vault code replay", () => {
|
|
|
1483
1469
|
const redirectUri = "https://example.com/callback";
|
|
1484
1470
|
|
|
1485
1471
|
const authRes = await handleAuthorizePost(
|
|
1486
|
-
makeRequest("https://vault.test/
|
|
1472
|
+
makeRequest("https://vault.test/vault/vault-a/oauth/authorize", {
|
|
1487
1473
|
method: "POST",
|
|
1488
1474
|
body: new URLSearchParams({
|
|
1489
1475
|
action: "authorize",
|
|
@@ -1501,7 +1487,7 @@ describe("OAuth token — cross-vault code replay", () => {
|
|
|
1501
1487
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1502
1488
|
|
|
1503
1489
|
const tokenRes = await handleToken(
|
|
1504
|
-
makeRequest("https://vault.test/
|
|
1490
|
+
makeRequest("https://vault.test/vault/vault-a/oauth/token", {
|
|
1505
1491
|
method: "POST",
|
|
1506
1492
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1507
1493
|
body: new URLSearchParams({
|
|
@@ -1521,3 +1507,305 @@ describe("OAuth token — cross-vault code replay", () => {
|
|
|
1521
1507
|
expect(body.access_token).toMatch(/^pvt_/);
|
|
1522
1508
|
});
|
|
1523
1509
|
});
|
|
1510
|
+
|
|
1511
|
+
// ---------------------------------------------------------------------------
|
|
1512
|
+
// Phase 0+1: PARACHUTE_HUB_ORIGIN + service catalog in token response
|
|
1513
|
+
// ---------------------------------------------------------------------------
|
|
1514
|
+
|
|
1515
|
+
describe("OAuth Phase 0: PARACHUTE_HUB_ORIGIN", () => {
|
|
1516
|
+
const HUB = "https://hub.example";
|
|
1517
|
+
let origHub: string | undefined;
|
|
1518
|
+
let origHome: string | undefined;
|
|
1519
|
+
let tmpHome: string;
|
|
1520
|
+
|
|
1521
|
+
beforeEach(() => {
|
|
1522
|
+
origHub = process.env.PARACHUTE_HUB_ORIGIN;
|
|
1523
|
+
origHome = process.env.PARACHUTE_HOME;
|
|
1524
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-oauth-phase0-"));
|
|
1525
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
afterEach(() => {
|
|
1529
|
+
if (origHub === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
1530
|
+
else process.env.PARACHUTE_HUB_ORIGIN = origHub;
|
|
1531
|
+
if (origHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
1532
|
+
else process.env.PARACHUTE_HOME = origHome;
|
|
1533
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
test("discovery: returns hub issuer when request arrives via hub origin", () => {
|
|
1537
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1538
|
+
const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-authorization-server`);
|
|
1539
|
+
const res = handleAuthorizationServer(req, "default");
|
|
1540
|
+
expect(res.status).toBe(200);
|
|
1541
|
+
return res.json().then((body: any) => {
|
|
1542
|
+
expect(body.issuer).toBe(HUB);
|
|
1543
|
+
expect(body.authorization_endpoint).toBe(`${HUB}/oauth/authorize`);
|
|
1544
|
+
expect(body.token_endpoint).toBe(`${HUB}/oauth/token`);
|
|
1545
|
+
expect(body.registration_endpoint).toBe(`${HUB}/oauth/register`);
|
|
1546
|
+
expect(body.scopes_supported).toContain("vault:read");
|
|
1547
|
+
expect(body.scopes_supported).toContain("vault:write");
|
|
1548
|
+
});
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test("discovery: trailing slash on hub origin is stripped", async () => {
|
|
1552
|
+
process.env.PARACHUTE_HUB_ORIGIN = `${HUB}/`;
|
|
1553
|
+
const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-authorization-server`);
|
|
1554
|
+
const res = handleAuthorizationServer(req, "default");
|
|
1555
|
+
const body = await res.json();
|
|
1556
|
+
expect(body.issuer).toBe(HUB);
|
|
1557
|
+
expect(body.token_endpoint).toBe(`${HUB}/oauth/token`);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("discovery: protected-resource metadata uses hub as authorization_server when request arrives via hub", async () => {
|
|
1561
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1562
|
+
const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-protected-resource`);
|
|
1563
|
+
const res = handleProtectedResource(req, "default");
|
|
1564
|
+
const body = await res.json();
|
|
1565
|
+
expect(body.authorization_servers).toEqual([HUB]);
|
|
1566
|
+
expect(body.resource).toBe(`${HUB}/vault/default/mcp`);
|
|
1567
|
+
expect(body.scopes_supported).toContain("vault:read");
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
test("discovery: RFC 8414 — hub env set, request via loopback returns loopback issuer, not hub", async () => {
|
|
1571
|
+
// Aaron's bug: mcp-install wrote a loopback URL while PARACHUTE_HUB_ORIGIN
|
|
1572
|
+
// was set, so the client fetched discovery via http://127.0.0.1 but got
|
|
1573
|
+
// back `issuer: https://hub.example` — origin mismatch, strict OAuth
|
|
1574
|
+
// clients (Claude Code) reject. Each origin must advertise its own issuer.
|
|
1575
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1576
|
+
const req = makeRequest("http://127.0.0.1:1940/vault/default/.well-known/oauth-authorization-server");
|
|
1577
|
+
const res = handleAuthorizationServer(req, "default");
|
|
1578
|
+
const body = await res.json();
|
|
1579
|
+
expect(body.issuer).toBe("http://127.0.0.1:1940/vault/default");
|
|
1580
|
+
expect(body.token_endpoint).toBe("http://127.0.0.1:1940/vault/default/oauth/token");
|
|
1581
|
+
expect(body.registration_endpoint).toBe("http://127.0.0.1:1940/vault/default/oauth/register");
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
test("discovery: protected-resource on loopback returns loopback AS even with hub env set", async () => {
|
|
1585
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1586
|
+
const req = makeRequest("http://127.0.0.1:1940/vault/default/.well-known/oauth-protected-resource");
|
|
1587
|
+
const res = handleProtectedResource(req, "default");
|
|
1588
|
+
const body = await res.json();
|
|
1589
|
+
expect(body.authorization_servers).toEqual(["http://127.0.0.1:1940/vault/default"]);
|
|
1590
|
+
expect(body.resource).toBe("http://127.0.0.1:1940/vault/default/mcp");
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
test("discovery: falls back to vault origin when env is unset", async () => {
|
|
1594
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
1595
|
+
const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
|
|
1596
|
+
const res = handleAuthorizationServer(req, "default");
|
|
1597
|
+
const body = await res.json();
|
|
1598
|
+
expect(body.issuer).toBe("https://vault.test/vault/default");
|
|
1599
|
+
expect(body.token_endpoint).toBe("https://vault.test/vault/default/oauth/token");
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test("scopes_supported publishes new shape alongside legacy names", async () => {
|
|
1603
|
+
const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
|
|
1604
|
+
const res = handleAuthorizationServer(req, "default");
|
|
1605
|
+
const body = await res.json();
|
|
1606
|
+
expect(body.scopes_supported).toEqual(expect.arrayContaining(["vault:read", "vault:write", "full", "read"]));
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
test("token response includes iss = hub when issued on hub origin", async () => {
|
|
1610
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1611
|
+
const token = await fullOAuthFlow();
|
|
1612
|
+
// Re-issue a token to inspect the body (fullOAuthFlow returns only the string)
|
|
1613
|
+
const ownerToken = createOwnerToken();
|
|
1614
|
+
const clientId = await registerClient();
|
|
1615
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1616
|
+
const redirectUri = "https://example.com/callback";
|
|
1617
|
+
const authRes = await handleAuthorizePost(
|
|
1618
|
+
makeRequest(`${HUB}/vault/default/oauth/authorize`, {
|
|
1619
|
+
method: "POST",
|
|
1620
|
+
body: new URLSearchParams({
|
|
1621
|
+
action: "authorize",
|
|
1622
|
+
client_id: clientId,
|
|
1623
|
+
redirect_uri: redirectUri,
|
|
1624
|
+
code_challenge: codeChallenge,
|
|
1625
|
+
code_challenge_method: "S256",
|
|
1626
|
+
scope: "full",
|
|
1627
|
+
owner_token: ownerToken,
|
|
1628
|
+
}),
|
|
1629
|
+
}),
|
|
1630
|
+
db,
|
|
1631
|
+
{ vaultName: "default" },
|
|
1632
|
+
);
|
|
1633
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1634
|
+
const tokenRes = await handleToken(
|
|
1635
|
+
makeRequest(`${HUB}/vault/default/oauth/token`, {
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1638
|
+
body: new URLSearchParams({
|
|
1639
|
+
grant_type: "authorization_code",
|
|
1640
|
+
code,
|
|
1641
|
+
code_verifier: codeVerifier,
|
|
1642
|
+
client_id: clientId,
|
|
1643
|
+
redirect_uri: redirectUri,
|
|
1644
|
+
}).toString(),
|
|
1645
|
+
}),
|
|
1646
|
+
db,
|
|
1647
|
+
"default",
|
|
1648
|
+
);
|
|
1649
|
+
expect(tokenRes.status).toBe(200);
|
|
1650
|
+
const body = await tokenRes.json();
|
|
1651
|
+
expect(body.iss).toBe(HUB);
|
|
1652
|
+
expect(body.services).toEqual({});
|
|
1653
|
+
// Back-compat: access_token still present and unchanged shape-wise.
|
|
1654
|
+
expect(body.access_token).toMatch(/^pvt_/);
|
|
1655
|
+
expect(token).toMatch(/^pvt_/);
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
test("token iss matches request origin when client came via loopback even with hub env set", async () => {
|
|
1659
|
+
// Same-vault twin of the discovery-on-loopback test: a token minted over
|
|
1660
|
+
// the loopback flow carries `iss` = the loopback issuer, not the hub.
|
|
1661
|
+
// Tokens introspected against loopback discovery's issuer must validate.
|
|
1662
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1663
|
+
const ownerToken = createOwnerToken();
|
|
1664
|
+
const clientId = await registerClient();
|
|
1665
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1666
|
+
const redirectUri = "https://example.com/callback";
|
|
1667
|
+
const authRes = await handleAuthorizePost(
|
|
1668
|
+
makeRequest("http://127.0.0.1:1940/vault/default/oauth/authorize", {
|
|
1669
|
+
method: "POST",
|
|
1670
|
+
body: new URLSearchParams({
|
|
1671
|
+
action: "authorize",
|
|
1672
|
+
client_id: clientId,
|
|
1673
|
+
redirect_uri: redirectUri,
|
|
1674
|
+
code_challenge: codeChallenge,
|
|
1675
|
+
code_challenge_method: "S256",
|
|
1676
|
+
scope: "full",
|
|
1677
|
+
owner_token: ownerToken,
|
|
1678
|
+
}),
|
|
1679
|
+
}),
|
|
1680
|
+
db,
|
|
1681
|
+
{ vaultName: "default" },
|
|
1682
|
+
);
|
|
1683
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1684
|
+
const tokenRes = await handleToken(
|
|
1685
|
+
makeRequest("http://127.0.0.1:1940/vault/default/oauth/token", {
|
|
1686
|
+
method: "POST",
|
|
1687
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1688
|
+
body: new URLSearchParams({
|
|
1689
|
+
grant_type: "authorization_code",
|
|
1690
|
+
code,
|
|
1691
|
+
code_verifier: codeVerifier,
|
|
1692
|
+
client_id: clientId,
|
|
1693
|
+
redirect_uri: redirectUri,
|
|
1694
|
+
}).toString(),
|
|
1695
|
+
}),
|
|
1696
|
+
db,
|
|
1697
|
+
"default",
|
|
1698
|
+
);
|
|
1699
|
+
const body = await tokenRes.json();
|
|
1700
|
+
expect(body.iss).toBe("http://127.0.0.1:1940/vault/default");
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
test("token response services catalog reflects services.json using hub origin when issued via hub", async () => {
|
|
1704
|
+
process.env.PARACHUTE_HUB_ORIGIN = HUB;
|
|
1705
|
+
fs.writeFileSync(
|
|
1706
|
+
path.join(tmpHome, "services.json"),
|
|
1707
|
+
JSON.stringify({
|
|
1708
|
+
services: [
|
|
1709
|
+
{ name: "vault", port: 1940, paths: ["/vault/default"], health: "/health", version: "0.3.0" },
|
|
1710
|
+
{ name: "notes", port: 1941, paths: ["/notes"], health: "/health", version: "0.1.0" },
|
|
1711
|
+
],
|
|
1712
|
+
}),
|
|
1713
|
+
);
|
|
1714
|
+
|
|
1715
|
+
const ownerToken = createOwnerToken();
|
|
1716
|
+
const clientId = await registerClient();
|
|
1717
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1718
|
+
const redirectUri = "https://example.com/callback";
|
|
1719
|
+
const authRes = await handleAuthorizePost(
|
|
1720
|
+
makeRequest(`${HUB}/vault/default/oauth/authorize`, {
|
|
1721
|
+
method: "POST",
|
|
1722
|
+
body: new URLSearchParams({
|
|
1723
|
+
action: "authorize",
|
|
1724
|
+
client_id: clientId,
|
|
1725
|
+
redirect_uri: redirectUri,
|
|
1726
|
+
code_challenge: codeChallenge,
|
|
1727
|
+
code_challenge_method: "S256",
|
|
1728
|
+
scope: "full",
|
|
1729
|
+
owner_token: ownerToken,
|
|
1730
|
+
}),
|
|
1731
|
+
}),
|
|
1732
|
+
db,
|
|
1733
|
+
{ vaultName: "default" },
|
|
1734
|
+
);
|
|
1735
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1736
|
+
const tokenRes = await handleToken(
|
|
1737
|
+
makeRequest(`${HUB}/vault/default/oauth/token`, {
|
|
1738
|
+
method: "POST",
|
|
1739
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1740
|
+
body: new URLSearchParams({
|
|
1741
|
+
grant_type: "authorization_code",
|
|
1742
|
+
code,
|
|
1743
|
+
code_verifier: codeVerifier,
|
|
1744
|
+
client_id: clientId,
|
|
1745
|
+
redirect_uri: redirectUri,
|
|
1746
|
+
}).toString(),
|
|
1747
|
+
}),
|
|
1748
|
+
db,
|
|
1749
|
+
"default",
|
|
1750
|
+
);
|
|
1751
|
+
const body = await tokenRes.json();
|
|
1752
|
+
expect(body.services).toEqual({
|
|
1753
|
+
vault: { url: `${HUB}/vault/default`, version: "0.3.0" },
|
|
1754
|
+
notes: { url: `${HUB}/notes`, version: "0.1.0" },
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test("token response services catalog falls back to vault origin when hub env unset", async () => {
|
|
1759
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
1760
|
+
fs.writeFileSync(
|
|
1761
|
+
path.join(tmpHome, "services.json"),
|
|
1762
|
+
JSON.stringify({
|
|
1763
|
+
services: [
|
|
1764
|
+
{ name: "vault", port: 1940, paths: ["/vault/default"], health: "/health", version: "0.3.0" },
|
|
1765
|
+
],
|
|
1766
|
+
}),
|
|
1767
|
+
);
|
|
1768
|
+
|
|
1769
|
+
const ownerToken = createOwnerToken();
|
|
1770
|
+
const clientId = await registerClient();
|
|
1771
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1772
|
+
const redirectUri = "https://example.com/callback";
|
|
1773
|
+
const authRes = await handleAuthorizePost(
|
|
1774
|
+
makeRequest("https://vault.test/vault/default/oauth/authorize", {
|
|
1775
|
+
method: "POST",
|
|
1776
|
+
body: new URLSearchParams({
|
|
1777
|
+
action: "authorize",
|
|
1778
|
+
client_id: clientId,
|
|
1779
|
+
redirect_uri: redirectUri,
|
|
1780
|
+
code_challenge: codeChallenge,
|
|
1781
|
+
code_challenge_method: "S256",
|
|
1782
|
+
scope: "full",
|
|
1783
|
+
owner_token: ownerToken,
|
|
1784
|
+
}),
|
|
1785
|
+
}),
|
|
1786
|
+
db,
|
|
1787
|
+
{ vaultName: "default" },
|
|
1788
|
+
);
|
|
1789
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1790
|
+
const tokenRes = await handleToken(
|
|
1791
|
+
makeRequest("https://vault.test/vault/default/oauth/token", {
|
|
1792
|
+
method: "POST",
|
|
1793
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1794
|
+
body: new URLSearchParams({
|
|
1795
|
+
grant_type: "authorization_code",
|
|
1796
|
+
code,
|
|
1797
|
+
code_verifier: codeVerifier,
|
|
1798
|
+
client_id: clientId,
|
|
1799
|
+
redirect_uri: redirectUri,
|
|
1800
|
+
}).toString(),
|
|
1801
|
+
}),
|
|
1802
|
+
db,
|
|
1803
|
+
"default",
|
|
1804
|
+
);
|
|
1805
|
+
const body = await tokenRes.json();
|
|
1806
|
+
expect(body.iss).toBe("https://vault.test/vault/default");
|
|
1807
|
+
expect(body.services).toEqual({
|
|
1808
|
+
vault: { url: "https://vault.test/vault/default", version: "0.3.0" },
|
|
1809
|
+
});
|
|
1810
|
+
});
|
|
1811
|
+
});
|