@openparachute/hub 0.3.0-rc.1 → 0.5.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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /api/grants and /api/grants/:client_id.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - GET: 401 without Bearer, 403 with the wrong scope, 200 with the right
|
|
6
|
+
* scope; vault filter; client_name surfaced; multi-user isolation.
|
|
7
|
+
* - DELETE: 401/403 mirror the GET surface; 404 when no grant exists; 204
|
|
8
|
+
* on success; audit log emitted.
|
|
9
|
+
* - 405 on wrong methods.
|
|
10
|
+
*/
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { handleListGrants, handleRevokeGrant } from "../admin-grants.ts";
|
|
17
|
+
import { registerClient } from "../clients.ts";
|
|
18
|
+
import { recordGrant } from "../grants.ts";
|
|
19
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
21
|
+
import { createUser } from "../users.ts";
|
|
22
|
+
|
|
23
|
+
const ISSUER = "https://hub.test";
|
|
24
|
+
|
|
25
|
+
interface Harness {
|
|
26
|
+
db: Database;
|
|
27
|
+
cleanup: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeHarness(): Harness {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-admin-grants-"));
|
|
32
|
+
const db = openHubDb(hubDbPath(dir));
|
|
33
|
+
return {
|
|
34
|
+
db,
|
|
35
|
+
cleanup: () => {
|
|
36
|
+
db.close();
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let harness: Harness;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
harness = makeHarness();
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
harness.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function makeOperatorBearer(scopes = ["parachute:host:admin"]): Promise<{
|
|
51
|
+
bearer: string;
|
|
52
|
+
userId: string;
|
|
53
|
+
}> {
|
|
54
|
+
const user = await createUser(harness.db, "operator", "pw");
|
|
55
|
+
const minted = await signAccessToken(harness.db, {
|
|
56
|
+
sub: user.id,
|
|
57
|
+
scopes,
|
|
58
|
+
audience: "hub",
|
|
59
|
+
clientId: "parachute-hub-spa",
|
|
60
|
+
issuer: ISSUER,
|
|
61
|
+
ttlSeconds: 600,
|
|
62
|
+
});
|
|
63
|
+
return { bearer: minted.token, userId: user.id };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function reg(name?: string): string {
|
|
67
|
+
const r = registerClient(harness.db, {
|
|
68
|
+
redirectUris: ["https://app.example/cb"],
|
|
69
|
+
...(name !== undefined ? { clientName: name } : {}),
|
|
70
|
+
});
|
|
71
|
+
return r.client.clientId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function listReq(query = ""): Request {
|
|
75
|
+
return new Request(`${ISSUER}/api/grants${query}`);
|
|
76
|
+
}
|
|
77
|
+
function listReqWithBearer(bearer: string, query = ""): Request {
|
|
78
|
+
return new Request(`${ISSUER}/api/grants${query}`, {
|
|
79
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function deleteReq(clientId: string, bearer?: string): Request {
|
|
83
|
+
const headers: Record<string, string> = {};
|
|
84
|
+
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
|
85
|
+
return new Request(`${ISSUER}/api/grants/${clientId}`, {
|
|
86
|
+
method: "DELETE",
|
|
87
|
+
headers,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("handleListGrants", () => {
|
|
92
|
+
test("401 with no Authorization header", async () => {
|
|
93
|
+
const res = await handleListGrants(listReq(), { db: harness.db, issuer: ISSUER });
|
|
94
|
+
expect(res.status).toBe(401);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("403 when token lacks parachute:host:admin", async () => {
|
|
98
|
+
const { bearer } = await makeOperatorBearer(["other:scope"]);
|
|
99
|
+
const res = await handleListGrants(listReqWithBearer(bearer), {
|
|
100
|
+
db: harness.db,
|
|
101
|
+
issuer: ISSUER,
|
|
102
|
+
});
|
|
103
|
+
expect(res.status).toBe(403);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("405 on POST", async () => {
|
|
107
|
+
const { bearer } = await makeOperatorBearer();
|
|
108
|
+
const req = new Request(`${ISSUER}/api/grants`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
111
|
+
});
|
|
112
|
+
const res = await handleListGrants(req, { db: harness.db, issuer: ISSUER });
|
|
113
|
+
expect(res.status).toBe(405);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("200 returns operator's grants enriched with client_name", async () => {
|
|
117
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
118
|
+
const cidA = reg("App A");
|
|
119
|
+
const cidB = reg(); // no display name
|
|
120
|
+
recordGrant(harness.db, userId, cidA, ["vault:work:read", "vault:work:write"]);
|
|
121
|
+
recordGrant(harness.db, userId, cidB, ["notes:read"]);
|
|
122
|
+
|
|
123
|
+
const res = await handleListGrants(listReqWithBearer(bearer), {
|
|
124
|
+
db: harness.db,
|
|
125
|
+
issuer: ISSUER,
|
|
126
|
+
});
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
129
|
+
const body = (await res.json()) as { grants: Array<Record<string, unknown>> };
|
|
130
|
+
expect(body.grants).toHaveLength(2);
|
|
131
|
+
|
|
132
|
+
const a = body.grants.find((g) => g.client_id === cidA);
|
|
133
|
+
expect(a).toBeDefined();
|
|
134
|
+
expect(a?.client_name).toBe("App A");
|
|
135
|
+
expect(a?.scopes).toEqual(["vault:work:read", "vault:work:write"]);
|
|
136
|
+
|
|
137
|
+
const b = body.grants.find((g) => g.client_id === cidB);
|
|
138
|
+
expect(b?.client_name).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("?vault=<name> filters to grants whose scopes touch that vault", async () => {
|
|
142
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
143
|
+
const work = reg("Work app");
|
|
144
|
+
const scratch = reg("Scratch app");
|
|
145
|
+
recordGrant(harness.db, userId, work, ["vault:work:read"]);
|
|
146
|
+
recordGrant(harness.db, userId, scratch, ["vault:scratch:read", "vault:scratch:write"]);
|
|
147
|
+
|
|
148
|
+
const res = await handleListGrants(listReqWithBearer(bearer, "?vault=work"), {
|
|
149
|
+
db: harness.db,
|
|
150
|
+
issuer: ISSUER,
|
|
151
|
+
});
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
const body = (await res.json()) as { grants: Array<{ client_id: string }> };
|
|
154
|
+
expect(body.grants).toHaveLength(1);
|
|
155
|
+
expect(body.grants[0]?.client_id).toBe(work);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("?vault filter rejects garbage names with 400", async () => {
|
|
159
|
+
const { bearer } = await makeOperatorBearer();
|
|
160
|
+
const res = await handleListGrants(listReqWithBearer(bearer, "?vault=hi%2Fthere"), {
|
|
161
|
+
db: harness.db,
|
|
162
|
+
issuer: ISSUER,
|
|
163
|
+
});
|
|
164
|
+
expect(res.status).toBe(400);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("does not leak grants belonging to a different user", async () => {
|
|
168
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
169
|
+
// A second user with their own grant — must not appear in operator A's list.
|
|
170
|
+
const otherUser = await createUser(harness.db, "other", "pw", { allowMulti: true });
|
|
171
|
+
const cid = reg("Some app");
|
|
172
|
+
recordGrant(harness.db, otherUser.id, cid, ["vault:work:read"]);
|
|
173
|
+
recordGrant(harness.db, userId, cid, ["notes:read"]);
|
|
174
|
+
|
|
175
|
+
const res = await handleListGrants(listReqWithBearer(bearer), {
|
|
176
|
+
db: harness.db,
|
|
177
|
+
issuer: ISSUER,
|
|
178
|
+
});
|
|
179
|
+
const body = (await res.json()) as { grants: Array<{ user_id: string; scopes: string[] }> };
|
|
180
|
+
expect(body.grants).toHaveLength(1);
|
|
181
|
+
expect(body.grants[0]?.user_id).toBe(userId);
|
|
182
|
+
expect(body.grants[0]?.scopes).toEqual(["notes:read"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("handleRevokeGrant", () => {
|
|
187
|
+
test("401 with no Authorization header", async () => {
|
|
188
|
+
const res = await handleRevokeGrant(deleteReq("nope"), "nope", {
|
|
189
|
+
db: harness.db,
|
|
190
|
+
issuer: ISSUER,
|
|
191
|
+
});
|
|
192
|
+
expect(res.status).toBe(401);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("403 when token lacks parachute:host:admin", async () => {
|
|
196
|
+
const { bearer } = await makeOperatorBearer(["other:scope"]);
|
|
197
|
+
const res = await handleRevokeGrant(deleteReq("nope", bearer), "nope", {
|
|
198
|
+
db: harness.db,
|
|
199
|
+
issuer: ISSUER,
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(403);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("405 on GET", async () => {
|
|
205
|
+
const { bearer } = await makeOperatorBearer();
|
|
206
|
+
const cid = reg();
|
|
207
|
+
const req = new Request(`${ISSUER}/api/grants/${cid}`, {
|
|
208
|
+
method: "GET",
|
|
209
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
210
|
+
});
|
|
211
|
+
const res = await handleRevokeGrant(req, cid, { db: harness.db, issuer: ISSUER });
|
|
212
|
+
expect(res.status).toBe(405);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("404 when no grant exists for (user, client)", async () => {
|
|
216
|
+
const { bearer } = await makeOperatorBearer();
|
|
217
|
+
const cid = reg();
|
|
218
|
+
const res = await handleRevokeGrant(deleteReq(cid, bearer), cid, {
|
|
219
|
+
db: harness.db,
|
|
220
|
+
issuer: ISSUER,
|
|
221
|
+
});
|
|
222
|
+
expect(res.status).toBe(404);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("204 deletes the operator's grant and emits an audit log line", async () => {
|
|
226
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
227
|
+
const cid = reg("App A");
|
|
228
|
+
recordGrant(harness.db, userId, cid, ["vault:work:read"]);
|
|
229
|
+
|
|
230
|
+
const logs: string[] = [];
|
|
231
|
+
const originalLog = console.log;
|
|
232
|
+
console.log = (...args: unknown[]) => {
|
|
233
|
+
logs.push(args.map(String).join(" "));
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
const res = await handleRevokeGrant(deleteReq(cid, bearer), cid, {
|
|
237
|
+
db: harness.db,
|
|
238
|
+
issuer: ISSUER,
|
|
239
|
+
});
|
|
240
|
+
expect(res.status).toBe(204);
|
|
241
|
+
} finally {
|
|
242
|
+
console.log = originalLog;
|
|
243
|
+
}
|
|
244
|
+
// Grant is gone.
|
|
245
|
+
const followup = await handleRevokeGrant(deleteReq(cid, bearer), cid, {
|
|
246
|
+
db: harness.db,
|
|
247
|
+
issuer: ISSUER,
|
|
248
|
+
});
|
|
249
|
+
expect(followup.status).toBe(404);
|
|
250
|
+
|
|
251
|
+
// Audit line carries client_id, user_id, and the scopes that were revoked.
|
|
252
|
+
const line = logs.find((l) => l.startsWith("grant revoked:"));
|
|
253
|
+
expect(line).toBeDefined();
|
|
254
|
+
expect(line).toContain(`client_id=${cid}`);
|
|
255
|
+
expect(line).toContain(`user_id=${userId}`);
|
|
256
|
+
expect(line).toContain("scopes=vault:work:read");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("404 when the operator tries to revoke another user's grant", async () => {
|
|
260
|
+
const { bearer } = await makeOperatorBearer();
|
|
261
|
+
const otherUser = await createUser(harness.db, "other", "pw", { allowMulti: true });
|
|
262
|
+
const cid = reg();
|
|
263
|
+
recordGrant(harness.db, otherUser.id, cid, ["vault:work:read"]);
|
|
264
|
+
|
|
265
|
+
const res = await handleRevokeGrant(deleteReq(cid, bearer), cid, {
|
|
266
|
+
db: harness.db,
|
|
267
|
+
issuer: ISSUER,
|
|
268
|
+
});
|
|
269
|
+
expect(res.status).toBe(404);
|
|
270
|
+
});
|
|
271
|
+
});
|