@openparachute/hub 0.5.7 → 0.5.10-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/package.json
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /api/oauth/clients/:id and /api/oauth/clients/:id/approve.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - GET: 401 without Bearer, 403 with the wrong scope, 200 with the right
|
|
6
|
+
* scope, 404 for unknown client_id, 405 on POST.
|
|
7
|
+
* - POST approve: same auth surface, 200 + audit log on a pending row,
|
|
8
|
+
* 200 + `already_approved` on a re-approve, 404 unknown id, 405 on GET.
|
|
9
|
+
*/
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { handleApproveClient, handleGetClient } from "../admin-clients.ts";
|
|
16
|
+
import { approveClient, getClient, registerClient } from "../clients.ts";
|
|
17
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
18
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
19
|
+
import { createUser } from "../users.ts";
|
|
20
|
+
|
|
21
|
+
const ISSUER = "https://hub.test";
|
|
22
|
+
|
|
23
|
+
interface Harness {
|
|
24
|
+
db: Database;
|
|
25
|
+
cleanup: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeHarness(): Harness {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-admin-clients-"));
|
|
30
|
+
const db = openHubDb(hubDbPath(dir));
|
|
31
|
+
return {
|
|
32
|
+
db,
|
|
33
|
+
cleanup: () => {
|
|
34
|
+
db.close();
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let harness: Harness;
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
harness = makeHarness();
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
harness.cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function makeOperatorBearer(scopes = ["parachute:host:admin"]): Promise<{
|
|
49
|
+
bearer: string;
|
|
50
|
+
userId: string;
|
|
51
|
+
}> {
|
|
52
|
+
const user = await createUser(harness.db, "operator", "pw");
|
|
53
|
+
const minted = await signAccessToken(harness.db, {
|
|
54
|
+
sub: user.id,
|
|
55
|
+
scopes,
|
|
56
|
+
audience: "hub",
|
|
57
|
+
clientId: "parachute-hub-spa",
|
|
58
|
+
issuer: ISSUER,
|
|
59
|
+
ttlSeconds: 600,
|
|
60
|
+
});
|
|
61
|
+
return { bearer: minted.token, userId: user.id };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function regPending(name?: string): string {
|
|
65
|
+
const r = registerClient(harness.db, {
|
|
66
|
+
redirectUris: ["https://app.example/cb"],
|
|
67
|
+
scopes: ["vault:work:read"],
|
|
68
|
+
status: "pending",
|
|
69
|
+
...(name !== undefined ? { clientName: name } : {}),
|
|
70
|
+
});
|
|
71
|
+
return r.client.clientId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getReq(clientId: string, bearer?: string): Request {
|
|
75
|
+
const init: RequestInit = {};
|
|
76
|
+
if (bearer) init.headers = { authorization: `Bearer ${bearer}` };
|
|
77
|
+
return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}`, init);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function approveReq(clientId: string, bearer?: string, method = "POST"): Request {
|
|
81
|
+
const init: RequestInit = { method };
|
|
82
|
+
if (bearer) init.headers = { authorization: `Bearer ${bearer}` };
|
|
83
|
+
return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, init);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("handleGetClient", () => {
|
|
87
|
+
test("401 without Bearer", async () => {
|
|
88
|
+
const id = regPending("App");
|
|
89
|
+
const res = await handleGetClient(getReq(id), id, { db: harness.db, issuer: ISSUER });
|
|
90
|
+
expect(res.status).toBe(401);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("403 with the wrong scope", async () => {
|
|
94
|
+
const { bearer } = await makeOperatorBearer(["parachute:host:auth"]);
|
|
95
|
+
const id = regPending("App");
|
|
96
|
+
const res = await handleGetClient(getReq(id, bearer), id, {
|
|
97
|
+
db: harness.db,
|
|
98
|
+
issuer: ISSUER,
|
|
99
|
+
});
|
|
100
|
+
expect(res.status).toBe(403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("200 returns client details", async () => {
|
|
104
|
+
const { bearer } = await makeOperatorBearer();
|
|
105
|
+
const id = regPending("Notes");
|
|
106
|
+
const res = await handleGetClient(getReq(id, bearer), id, {
|
|
107
|
+
db: harness.db,
|
|
108
|
+
issuer: ISSUER,
|
|
109
|
+
});
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
112
|
+
expect(body.client_id).toBe(id);
|
|
113
|
+
expect(body.client_name).toBe("Notes");
|
|
114
|
+
expect(body.status).toBe("pending");
|
|
115
|
+
expect(body.redirect_uris).toEqual(["https://app.example/cb"]);
|
|
116
|
+
expect(body.scopes).toEqual(["vault:work:read"]);
|
|
117
|
+
expect(typeof body.registered_at).toBe("string");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns the row's status after approval (so the SPA can short-circuit re-approve)", async () => {
|
|
121
|
+
const { bearer } = await makeOperatorBearer();
|
|
122
|
+
const id = regPending("Notes");
|
|
123
|
+
approveClient(harness.db, id);
|
|
124
|
+
const res = await handleGetClient(getReq(id, bearer), id, {
|
|
125
|
+
db: harness.db,
|
|
126
|
+
issuer: ISSUER,
|
|
127
|
+
});
|
|
128
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
129
|
+
expect(body.status).toBe("approved");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("404 for unknown client_id", async () => {
|
|
133
|
+
const { bearer } = await makeOperatorBearer();
|
|
134
|
+
const res = await handleGetClient(getReq("nope", bearer), "nope", {
|
|
135
|
+
db: harness.db,
|
|
136
|
+
issuer: ISSUER,
|
|
137
|
+
});
|
|
138
|
+
expect(res.status).toBe(404);
|
|
139
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
140
|
+
expect(body.error).toBe("not_found");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("405 on POST", async () => {
|
|
144
|
+
const { bearer } = await makeOperatorBearer();
|
|
145
|
+
const id = regPending();
|
|
146
|
+
const req = new Request(`${ISSUER}/api/oauth/clients/${id}`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
149
|
+
});
|
|
150
|
+
const res = await handleGetClient(req, id, { db: harness.db, issuer: ISSUER });
|
|
151
|
+
expect(res.status).toBe(405);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("client_name is null when never set", async () => {
|
|
155
|
+
const { bearer } = await makeOperatorBearer();
|
|
156
|
+
const id = regPending(); // no client_name
|
|
157
|
+
const res = await handleGetClient(getReq(id, bearer), id, {
|
|
158
|
+
db: harness.db,
|
|
159
|
+
issuer: ISSUER,
|
|
160
|
+
});
|
|
161
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
162
|
+
expect(body.client_name).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("handleApproveClient", () => {
|
|
167
|
+
test("401 without Bearer", async () => {
|
|
168
|
+
const id = regPending();
|
|
169
|
+
const res = await handleApproveClient(approveReq(id), id, {
|
|
170
|
+
db: harness.db,
|
|
171
|
+
issuer: ISSUER,
|
|
172
|
+
});
|
|
173
|
+
expect(res.status).toBe(401);
|
|
174
|
+
// Row still pending.
|
|
175
|
+
expect(getClient(harness.db, id)?.status).toBe("pending");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("403 with the wrong scope", async () => {
|
|
179
|
+
const { bearer } = await makeOperatorBearer(["parachute:host:auth"]);
|
|
180
|
+
const id = regPending();
|
|
181
|
+
const res = await handleApproveClient(approveReq(id, bearer), id, {
|
|
182
|
+
db: harness.db,
|
|
183
|
+
issuer: ISSUER,
|
|
184
|
+
});
|
|
185
|
+
expect(res.status).toBe(403);
|
|
186
|
+
expect(getClient(harness.db, id)?.status).toBe("pending");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("200 flips a pending row to approved + emits an audit log line", async () => {
|
|
190
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
191
|
+
const id = regPending("Notes");
|
|
192
|
+
|
|
193
|
+
const logs: string[] = [];
|
|
194
|
+
const originalLog = console.log;
|
|
195
|
+
console.log = (...args: unknown[]) => {
|
|
196
|
+
logs.push(args.map(String).join(" "));
|
|
197
|
+
};
|
|
198
|
+
let res: Response;
|
|
199
|
+
try {
|
|
200
|
+
res = await handleApproveClient(approveReq(id, bearer), id, {
|
|
201
|
+
db: harness.db,
|
|
202
|
+
issuer: ISSUER,
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
console.log = originalLog;
|
|
206
|
+
}
|
|
207
|
+
expect(res.status).toBe(200);
|
|
208
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
209
|
+
expect(body.client_id).toBe(id);
|
|
210
|
+
expect(body.status).toBe("approved");
|
|
211
|
+
expect(body.already_approved).toBe(false);
|
|
212
|
+
expect(getClient(harness.db, id)?.status).toBe("approved");
|
|
213
|
+
|
|
214
|
+
const line = logs.find((l) => l.startsWith("client approved:"));
|
|
215
|
+
expect(line).toBeDefined();
|
|
216
|
+
expect(line).toContain(`client_id=${id}`);
|
|
217
|
+
expect(line).toContain("client_name=Notes");
|
|
218
|
+
expect(line).toContain(`approver_sub=${userId}`);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("idempotent: re-approving returns already_approved: true + NO audit line", async () => {
|
|
222
|
+
// Pin the audit-log idempotency contract explicitly: a no-op approve
|
|
223
|
+
// (the row was already approved) must NOT emit the "client approved:"
|
|
224
|
+
// line. Without this gate a UI tab re-submitting Approve, or a deep-
|
|
225
|
+
// linked operator approving an already-approved client, would
|
|
226
|
+
// pollute the log with confusing "approved a thing that was already
|
|
227
|
+
// approved" noise. The handler captures `wasPending` BEFORE calling
|
|
228
|
+
// approveClient so this property holds even if a future refactor
|
|
229
|
+
// splits the read / write across statements.
|
|
230
|
+
const { bearer } = await makeOperatorBearer();
|
|
231
|
+
const id = regPending();
|
|
232
|
+
approveClient(harness.db, id);
|
|
233
|
+
|
|
234
|
+
const logs: string[] = [];
|
|
235
|
+
const originalLog = console.log;
|
|
236
|
+
console.log = (...args: unknown[]) => {
|
|
237
|
+
logs.push(args.map(String).join(" "));
|
|
238
|
+
};
|
|
239
|
+
let res: Response;
|
|
240
|
+
try {
|
|
241
|
+
res = await handleApproveClient(approveReq(id, bearer), id, {
|
|
242
|
+
db: harness.db,
|
|
243
|
+
issuer: ISSUER,
|
|
244
|
+
});
|
|
245
|
+
} finally {
|
|
246
|
+
console.log = originalLog;
|
|
247
|
+
}
|
|
248
|
+
expect(res.status).toBe(200);
|
|
249
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
250
|
+
expect(body.already_approved).toBe(true);
|
|
251
|
+
expect(body.status).toBe("approved");
|
|
252
|
+
|
|
253
|
+
const approvedLine = logs.find((l) => l.startsWith("client approved:"));
|
|
254
|
+
expect(approvedLine).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("404 for unknown client_id", async () => {
|
|
258
|
+
const { bearer } = await makeOperatorBearer();
|
|
259
|
+
const res = await handleApproveClient(approveReq("nope", bearer), "nope", {
|
|
260
|
+
db: harness.db,
|
|
261
|
+
issuer: ISSUER,
|
|
262
|
+
});
|
|
263
|
+
expect(res.status).toBe(404);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("405 on GET", async () => {
|
|
267
|
+
const { bearer } = await makeOperatorBearer();
|
|
268
|
+
const id = regPending();
|
|
269
|
+
const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
|
|
270
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
271
|
+
});
|
|
272
|
+
const res = await handleApproveClient(req, id, { db: harness.db, issuer: ISSUER });
|
|
273
|
+
expect(res.status).toBe(405);
|
|
274
|
+
});
|
|
275
|
+
});
|