@openparachute/hub 0.5.7 → 0.5.10-rc.10
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-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -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__/csrf.test.ts +40 -1
- 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 +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- 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 +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -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-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -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-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- 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 +262 -56
- 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/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { handleApiRevokeToken } from "../api-revoke-token.ts";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
8
|
+
import { mintOperatorToken } from "../operator-token.ts";
|
|
9
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
10
|
+
import { createUser } from "../users.ts";
|
|
11
|
+
|
|
12
|
+
interface Harness {
|
|
13
|
+
dir: string;
|
|
14
|
+
cleanup: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeHarness(): Harness {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-revoke-"));
|
|
19
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
23
|
+
|
|
24
|
+
async function bootstrap(
|
|
25
|
+
dir: string,
|
|
26
|
+
): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
|
|
27
|
+
const db = openHubDb(hubDbPath(dir));
|
|
28
|
+
rotateSigningKey(db);
|
|
29
|
+
const u = await createUser(db, "owner", "pw");
|
|
30
|
+
return { db, userId: u.id };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonRequest(body: unknown, headers: Record<string, string> = {}): Request {
|
|
34
|
+
return new Request("http://localhost/api/auth/revoke-token", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
headers: { "content-type": "application/json", ...headers },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Seed a tokens row by minting + recording, mirroring the CLI mint path. */
|
|
42
|
+
async function seedToken(
|
|
43
|
+
db: ReturnType<typeof openHubDb>,
|
|
44
|
+
userId: string,
|
|
45
|
+
scopes = ["scribe:transcribe"],
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const signed = await signAccessToken(db, {
|
|
48
|
+
sub: userId,
|
|
49
|
+
scopes,
|
|
50
|
+
audience: "scribe",
|
|
51
|
+
clientId: "parachute-hub",
|
|
52
|
+
issuer: ISSUER,
|
|
53
|
+
ttlSeconds: 3600,
|
|
54
|
+
});
|
|
55
|
+
recordTokenMint(db, {
|
|
56
|
+
jti: signed.jti,
|
|
57
|
+
createdVia: "cli_mint",
|
|
58
|
+
subject: userId,
|
|
59
|
+
clientId: "parachute-hub",
|
|
60
|
+
scopes,
|
|
61
|
+
expiresAt: signed.expiresAt,
|
|
62
|
+
});
|
|
63
|
+
return signed.jti;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("POST /api/auth/revoke-token (closes hub#220)", () => {
|
|
67
|
+
test("405 on non-POST", async () => {
|
|
68
|
+
const h = makeHarness();
|
|
69
|
+
try {
|
|
70
|
+
const { db } = await bootstrap(h.dir);
|
|
71
|
+
try {
|
|
72
|
+
const req = new Request("http://localhost/api/auth/revoke-token", { method: "GET" });
|
|
73
|
+
const resp = await handleApiRevokeToken(req, { db, issuer: ISSUER });
|
|
74
|
+
expect(resp.status).toBe(405);
|
|
75
|
+
expect(((await resp.json()) as { error: string }).error).toBe("method_not_allowed");
|
|
76
|
+
} finally {
|
|
77
|
+
db.close();
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
h.cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("401 when no Authorization header", async () => {
|
|
85
|
+
const h = makeHarness();
|
|
86
|
+
try {
|
|
87
|
+
const { db } = await bootstrap(h.dir);
|
|
88
|
+
try {
|
|
89
|
+
const resp = await handleApiRevokeToken(jsonRequest({ jti: "x" }), { db, issuer: ISSUER });
|
|
90
|
+
expect(resp.status).toBe(401);
|
|
91
|
+
expect(((await resp.json()) as { error: string }).error).toBe("unauthenticated");
|
|
92
|
+
} finally {
|
|
93
|
+
db.close();
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
h.cleanup();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("401 when bearer fails signature/issuer validation", async () => {
|
|
101
|
+
const h = makeHarness();
|
|
102
|
+
try {
|
|
103
|
+
const { db } = await bootstrap(h.dir);
|
|
104
|
+
try {
|
|
105
|
+
const resp = await handleApiRevokeToken(
|
|
106
|
+
jsonRequest({ jti: "x" }, { authorization: "Bearer not-a-real-jwt" }),
|
|
107
|
+
{ db, issuer: ISSUER },
|
|
108
|
+
);
|
|
109
|
+
expect(resp.status).toBe(401);
|
|
110
|
+
} finally {
|
|
111
|
+
db.close();
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
h.cleanup();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("403 when bearer scope lacks parachute:host:auth", async () => {
|
|
119
|
+
const h = makeHarness();
|
|
120
|
+
try {
|
|
121
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
122
|
+
try {
|
|
123
|
+
const narrow = await signAccessToken(db, {
|
|
124
|
+
sub: userId,
|
|
125
|
+
scopes: ["hub:admin"],
|
|
126
|
+
audience: "hub",
|
|
127
|
+
clientId: "parachute-hub",
|
|
128
|
+
issuer: ISSUER,
|
|
129
|
+
ttlSeconds: 3600,
|
|
130
|
+
});
|
|
131
|
+
const resp = await handleApiRevokeToken(
|
|
132
|
+
jsonRequest({ jti: "x" }, { authorization: `Bearer ${narrow.token}` }),
|
|
133
|
+
{ db, issuer: ISSUER },
|
|
134
|
+
);
|
|
135
|
+
expect(resp.status).toBe(403);
|
|
136
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
137
|
+
} finally {
|
|
138
|
+
db.close();
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
h.cleanup();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("400 when body missing jti", async () => {
|
|
146
|
+
const h = makeHarness();
|
|
147
|
+
try {
|
|
148
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
149
|
+
try {
|
|
150
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
151
|
+
const resp = await handleApiRevokeToken(
|
|
152
|
+
jsonRequest({}, { authorization: `Bearer ${op.token}` }),
|
|
153
|
+
{ db, issuer: ISSUER },
|
|
154
|
+
);
|
|
155
|
+
expect(resp.status).toBe(400);
|
|
156
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
157
|
+
expect(body.error).toBe("invalid_request");
|
|
158
|
+
expect(body.error_description).toContain("jti");
|
|
159
|
+
} finally {
|
|
160
|
+
db.close();
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
h.cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("400 when jti is not a non-empty string", async () => {
|
|
168
|
+
const h = makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
171
|
+
try {
|
|
172
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
173
|
+
for (const badJti of [null, 42, "", true, []]) {
|
|
174
|
+
const resp = await handleApiRevokeToken(
|
|
175
|
+
jsonRequest({ jti: badJti }, { authorization: `Bearer ${op.token}` }),
|
|
176
|
+
{ db, issuer: ISSUER },
|
|
177
|
+
);
|
|
178
|
+
expect(resp.status).toBe(400);
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
db.close();
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
h.cleanup();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("400 when body is not valid JSON", async () => {
|
|
189
|
+
const h = makeHarness();
|
|
190
|
+
try {
|
|
191
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
192
|
+
try {
|
|
193
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
194
|
+
const req = new Request("http://localhost/api/auth/revoke-token", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
body: "not json {[",
|
|
197
|
+
headers: {
|
|
198
|
+
"content-type": "application/json",
|
|
199
|
+
authorization: `Bearer ${op.token}`,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
const resp = await handleApiRevokeToken(req, { db, issuer: ISSUER });
|
|
203
|
+
expect(resp.status).toBe(400);
|
|
204
|
+
const body = (await resp.json()) as { error: string };
|
|
205
|
+
expect(body.error).toBe("invalid_request");
|
|
206
|
+
} finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
h.cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("404 when jti has no registry row", async () => {
|
|
215
|
+
const h = makeHarness();
|
|
216
|
+
try {
|
|
217
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
218
|
+
try {
|
|
219
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
220
|
+
const resp = await handleApiRevokeToken(
|
|
221
|
+
jsonRequest({ jti: "this-jti-does-not-exist" }, { authorization: `Bearer ${op.token}` }),
|
|
222
|
+
{ db, issuer: ISSUER },
|
|
223
|
+
);
|
|
224
|
+
expect(resp.status).toBe(404);
|
|
225
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
226
|
+
expect(body.error).toBe("not_found");
|
|
227
|
+
expect(body.error_description).toContain("this-jti-does-not-exist");
|
|
228
|
+
} finally {
|
|
229
|
+
db.close();
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
h.cleanup();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("happy path: revokes a fresh token; row's revoked_at is set", async () => {
|
|
237
|
+
const h = makeHarness();
|
|
238
|
+
try {
|
|
239
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
240
|
+
try {
|
|
241
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
242
|
+
const jti = await seedToken(db, userId);
|
|
243
|
+
const before = findTokenRowByJti(db, jti);
|
|
244
|
+
expect(before?.revokedAt).toBeNull();
|
|
245
|
+
|
|
246
|
+
const resp = await handleApiRevokeToken(
|
|
247
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
248
|
+
{ db, issuer: ISSUER },
|
|
249
|
+
);
|
|
250
|
+
expect(resp.status).toBe(200);
|
|
251
|
+
const body = (await resp.json()) as { jti: string; revoked_at: string };
|
|
252
|
+
expect(body.jti).toBe(jti);
|
|
253
|
+
expect(typeof body.revoked_at).toBe("string");
|
|
254
|
+
expect(body.revoked_at.length).toBeGreaterThan(0);
|
|
255
|
+
|
|
256
|
+
const after = findTokenRowByJti(db, jti);
|
|
257
|
+
expect(after?.revokedAt).toBe(body.revoked_at);
|
|
258
|
+
} finally {
|
|
259
|
+
db.close();
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
h.cleanup();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("idempotent: re-revoking returns 200 with the original revoked_at", async () => {
|
|
267
|
+
const h = makeHarness();
|
|
268
|
+
try {
|
|
269
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
270
|
+
try {
|
|
271
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
272
|
+
const jti = await seedToken(db, userId);
|
|
273
|
+
|
|
274
|
+
const first = await handleApiRevokeToken(
|
|
275
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
276
|
+
{ db, issuer: ISSUER },
|
|
277
|
+
);
|
|
278
|
+
expect(first.status).toBe(200);
|
|
279
|
+
const firstBody = (await first.json()) as { revoked_at: string };
|
|
280
|
+
const firstAt = firstBody.revoked_at;
|
|
281
|
+
|
|
282
|
+
// Sleep 1ms so a clock-skew bug would make `now` != `first.revoked_at`.
|
|
283
|
+
await Bun.sleep(2);
|
|
284
|
+
|
|
285
|
+
const second = await handleApiRevokeToken(
|
|
286
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
287
|
+
{ db, issuer: ISSUER },
|
|
288
|
+
);
|
|
289
|
+
expect(second.status).toBe(200);
|
|
290
|
+
const secondBody = (await second.json()) as { revoked_at: string };
|
|
291
|
+
// Idempotent: returns the original timestamp, not a fresh one.
|
|
292
|
+
expect(secondBody.revoked_at).toBe(firstAt);
|
|
293
|
+
} finally {
|
|
294
|
+
db.close();
|
|
295
|
+
}
|
|
296
|
+
} finally {
|
|
297
|
+
h.cleanup();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("happy path: --scope-set=auth narrow operator token passes the gate", async () => {
|
|
302
|
+
const h = makeHarness();
|
|
303
|
+
try {
|
|
304
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
305
|
+
try {
|
|
306
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
307
|
+
const jti = await seedToken(db, userId);
|
|
308
|
+
const resp = await handleApiRevokeToken(
|
|
309
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
310
|
+
{ db, issuer: ISSUER },
|
|
311
|
+
);
|
|
312
|
+
expect(resp.status).toBe(200);
|
|
313
|
+
} finally {
|
|
314
|
+
db.close();
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
h.cleanup();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|