@openparachute/hub 0.3.0-rc.1 → 0.5.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.
Files changed (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,361 @@
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 { decodeJwt, decodeProtectedHeader } from "jose";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import {
8
+ ACCESS_TOKEN_TTL_SECONDS,
9
+ REFRESH_TOKEN_TTL_MS,
10
+ RefreshTokenInsertError,
11
+ findRefreshToken,
12
+ signAccessToken,
13
+ signRefreshToken,
14
+ validateAccessToken,
15
+ } from "../jwt-sign.ts";
16
+ import { getActiveSigningKey, rotateSigningKey } from "../signing-keys.ts";
17
+ import { createUser } from "../users.ts";
18
+
19
+ function makeDb() {
20
+ const configDir = mkdtempSync(join(tmpdir(), "phub-jwt-"));
21
+ const db = openHubDb(hubDbPath(configDir));
22
+ return {
23
+ db,
24
+ cleanup: () => {
25
+ db.close();
26
+ rmSync(configDir, { recursive: true, force: true });
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("signAccessToken", () => {
32
+ test("issues an RS256 JWT keyed by the active signing key", async () => {
33
+ const { db, cleanup } = makeDb();
34
+ try {
35
+ const active = getActiveSigningKey(db);
36
+ const { token, jti, expiresAt } = await signAccessToken(db, {
37
+ sub: "user-1",
38
+ scopes: ["vault.read", "vault.write"],
39
+ audience: "vault",
40
+ clientId: "notes-pwa",
41
+ issuer: "https://hub.example",
42
+ });
43
+ const header = decodeProtectedHeader(token);
44
+ expect(header.alg).toBe("RS256");
45
+ expect(header.kid).toBe(active.kid);
46
+ const payload = decodeJwt(token);
47
+ expect(payload.sub).toBe("user-1");
48
+ expect(payload.aud).toBe("vault");
49
+ expect(payload.scope).toBe("vault.read vault.write");
50
+ expect(payload.client_id).toBe("notes-pwa");
51
+ expect(payload.jti).toBe(jti);
52
+ expect(typeof payload.exp).toBe("number");
53
+ expect(typeof payload.iat).toBe("number");
54
+ expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(ACCESS_TOKEN_TTL_SECONDS);
55
+ // expiresAt round-trips to the JWT exp.
56
+ expect(new Date(expiresAt).getTime() / 1000).toBeCloseTo(payload.exp ?? 0, -1);
57
+ } finally {
58
+ cleanup();
59
+ }
60
+ });
61
+
62
+ test("does NOT write to the tokens table (pure)", async () => {
63
+ const { db, cleanup } = makeDb();
64
+ try {
65
+ await signAccessToken(db, {
66
+ sub: "user-1",
67
+ scopes: ["vault.read"],
68
+ audience: "vault",
69
+ clientId: "c",
70
+ issuer: "https://hub.example",
71
+ });
72
+ const count = (
73
+ db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tokens").get() ?? {
74
+ n: -1,
75
+ }
76
+ ).n;
77
+ expect(count).toBe(0);
78
+ } finally {
79
+ cleanup();
80
+ }
81
+ });
82
+
83
+ test("sets `iss` claim from opts.issuer (closes #77)", async () => {
84
+ const { db, cleanup } = makeDb();
85
+ try {
86
+ const issuer = "http://127.0.0.1:1939";
87
+ const { token } = await signAccessToken(db, {
88
+ sub: "user-1",
89
+ scopes: ["vault.read"],
90
+ audience: "vault",
91
+ clientId: "c",
92
+ issuer,
93
+ });
94
+ const payload = decodeJwt(token);
95
+ expect(payload.iss).toBe(issuer);
96
+ // Validation accepts the matching issuer.
97
+ const { payload: validated } = await validateAccessToken(db, token, issuer);
98
+ expect(validated.iss).toBe(issuer);
99
+ } finally {
100
+ cleanup();
101
+ }
102
+ });
103
+
104
+ test("validateAccessToken rejects a token with a mismatched iss (defense in depth)", async () => {
105
+ const { db, cleanup } = makeDb();
106
+ try {
107
+ const { token } = await signAccessToken(db, {
108
+ sub: "user-1",
109
+ scopes: [],
110
+ audience: "vault",
111
+ clientId: "c",
112
+ issuer: "http://127.0.0.1:1939",
113
+ });
114
+ await expect(validateAccessToken(db, token, "https://other.example")).rejects.toThrow();
115
+ } finally {
116
+ cleanup();
117
+ }
118
+ });
119
+ });
120
+
121
+ describe("signRefreshToken", () => {
122
+ test("inserts a tokens row with the hash, returns the plaintext", async () => {
123
+ const { db, cleanup } = makeDb();
124
+ try {
125
+ const u = await createUser(db, "owner", "pw");
126
+ const { token, refreshTokenHash, expiresAt } = signRefreshToken(db, {
127
+ jti: "jti-1",
128
+ userId: u.id,
129
+ clientId: "notes",
130
+ scopes: ["vault.read"],
131
+ });
132
+ expect(token.length).toBeGreaterThanOrEqual(32);
133
+ expect(refreshTokenHash).toMatch(/^[0-9a-f]{64}$/);
134
+ const row = db
135
+ .query<
136
+ {
137
+ jti: string;
138
+ user_id: string;
139
+ client_id: string;
140
+ scopes: string;
141
+ refresh_token_hash: string;
142
+ expires_at: string;
143
+ },
144
+ [string]
145
+ >("SELECT * FROM tokens WHERE jti = ?")
146
+ .get("jti-1");
147
+ expect(row).not.toBeNull();
148
+ expect(row?.user_id).toBe(u.id);
149
+ expect(row?.client_id).toBe("notes");
150
+ expect(row?.scopes).toBe("vault.read");
151
+ expect(row?.refresh_token_hash).toBe(refreshTokenHash);
152
+ expect(row?.expires_at).toBe(expiresAt);
153
+ } finally {
154
+ cleanup();
155
+ }
156
+ });
157
+
158
+ test("expiresAt is 30 days from now (sliding TTL initial value)", async () => {
159
+ const { db, cleanup } = makeDb();
160
+ try {
161
+ const u = await createUser(db, "owner", "pw");
162
+ const fixed = new Date("2026-04-26T00:00:00.000Z");
163
+ const { expiresAt } = signRefreshToken(db, {
164
+ jti: "j",
165
+ userId: u.id,
166
+ clientId: "c",
167
+ scopes: [],
168
+ now: () => fixed,
169
+ });
170
+ expect(new Date(expiresAt).getTime() - fixed.getTime()).toBe(REFRESH_TOKEN_TTL_MS);
171
+ } finally {
172
+ cleanup();
173
+ }
174
+ });
175
+
176
+ test("throws RefreshTokenInsertError on UNIQUE jti collision (#108)", async () => {
177
+ const { db, cleanup } = makeDb();
178
+ try {
179
+ const u = await createUser(db, "owner", "pw");
180
+ signRefreshToken(db, {
181
+ jti: "duplicate-jti",
182
+ userId: u.id,
183
+ clientId: "c",
184
+ scopes: [],
185
+ });
186
+ let caught: unknown;
187
+ try {
188
+ signRefreshToken(db, {
189
+ jti: "duplicate-jti",
190
+ userId: u.id,
191
+ clientId: "c",
192
+ scopes: [],
193
+ });
194
+ } catch (err) {
195
+ caught = err;
196
+ }
197
+ expect(caught).toBeInstanceOf(RefreshTokenInsertError);
198
+ expect((caught as RefreshTokenInsertError).cause).toBeDefined();
199
+ } finally {
200
+ cleanup();
201
+ }
202
+ });
203
+
204
+ test("when wrapped in db.transaction() the UPDATE rolls back on INSERT failure (#107)", async () => {
205
+ const { db, cleanup } = makeDb();
206
+ try {
207
+ const u = await createUser(db, "owner", "pw");
208
+ // Seed the row that simulates the pre-rotation refresh token.
209
+ signRefreshToken(db, {
210
+ jti: "old-jti",
211
+ userId: u.id,
212
+ clientId: "c",
213
+ scopes: [],
214
+ });
215
+ // Pre-insert a row at the new jti so the rotation INSERT will collide.
216
+ signRefreshToken(db, {
217
+ jti: "new-jti",
218
+ userId: u.id,
219
+ clientId: "c",
220
+ scopes: [],
221
+ });
222
+
223
+ // Mirror the rotation: revoke old + insert new, atomically.
224
+ let caught: unknown;
225
+ try {
226
+ db.transaction(() => {
227
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(
228
+ new Date().toISOString(),
229
+ "old-jti",
230
+ );
231
+ signRefreshToken(db, {
232
+ jti: "new-jti",
233
+ userId: u.id,
234
+ clientId: "c",
235
+ scopes: [],
236
+ });
237
+ })();
238
+ } catch (err) {
239
+ caught = err;
240
+ }
241
+ expect(caught).toBeInstanceOf(RefreshTokenInsertError);
242
+
243
+ // The UPDATE on "old-jti" must have been rolled back: the row
244
+ // is still active, so the legitimate client can retry the refresh.
245
+ const row = db
246
+ .query<{ revoked_at: string | null }, [string]>(
247
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
248
+ )
249
+ .get("old-jti");
250
+ expect(row?.revoked_at).toBeNull();
251
+ } finally {
252
+ cleanup();
253
+ }
254
+ });
255
+ });
256
+
257
+ describe("findRefreshToken", () => {
258
+ test("finds the row by hashing the plaintext", async () => {
259
+ const { db, cleanup } = makeDb();
260
+ try {
261
+ const u = await createUser(db, "owner", "pw");
262
+ const { token } = signRefreshToken(db, {
263
+ jti: "jti-1",
264
+ userId: u.id,
265
+ clientId: "c",
266
+ scopes: ["a", "b"],
267
+ });
268
+ const row = findRefreshToken(db, token);
269
+ expect(row?.jti).toBe("jti-1");
270
+ expect(row?.userId).toBe(u.id);
271
+ expect(row?.scopes).toEqual(["a", "b"]);
272
+ expect(row?.revokedAt).toBeNull();
273
+ } finally {
274
+ cleanup();
275
+ }
276
+ });
277
+
278
+ test("returns null for an unknown token", async () => {
279
+ const { db, cleanup } = makeDb();
280
+ try {
281
+ expect(findRefreshToken(db, "not-a-real-token")).toBeNull();
282
+ } finally {
283
+ cleanup();
284
+ }
285
+ });
286
+ });
287
+
288
+ describe("validateAccessToken", () => {
289
+ test("verifies a freshly-signed token", async () => {
290
+ const { db, cleanup } = makeDb();
291
+ try {
292
+ const { token } = await signAccessToken(db, {
293
+ sub: "u",
294
+ scopes: ["s"],
295
+ audience: "vault",
296
+ clientId: "c",
297
+ issuer: "https://hub.example",
298
+ });
299
+ const { payload, kid } = await validateAccessToken(db, token);
300
+ expect(payload.sub).toBe("u");
301
+ expect(kid.length).toBeGreaterThan(0);
302
+ } finally {
303
+ cleanup();
304
+ }
305
+ });
306
+
307
+ test("verifies a token signed by a recently-retired key (rotation tolerance)", async () => {
308
+ const { db, cleanup } = makeDb();
309
+ try {
310
+ const { token } = await signAccessToken(db, {
311
+ sub: "u",
312
+ scopes: [],
313
+ audience: "vault",
314
+ clientId: "c",
315
+ issuer: "https://hub.example",
316
+ });
317
+ // Rotate — old key becomes retired but stays in JWKS for 24h.
318
+ rotateSigningKey(db);
319
+ const { payload } = await validateAccessToken(db, token);
320
+ expect(payload.sub).toBe("u");
321
+ } finally {
322
+ cleanup();
323
+ }
324
+ });
325
+
326
+ test("rejects a token whose kid no longer appears in JWKS", async () => {
327
+ const { db, cleanup } = makeDb();
328
+ try {
329
+ const { token } = await signAccessToken(db, {
330
+ sub: "u",
331
+ scopes: [],
332
+ audience: "vault",
333
+ clientId: "c",
334
+ issuer: "https://hub.example",
335
+ });
336
+ // Force the prior active key past 24h retention.
337
+ const past = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
338
+ db.exec(`UPDATE signing_keys SET retired_at = '${past}' WHERE retired_at IS NULL`);
339
+ // And rotate so there's a fresh active key, leaving the original
340
+ // beyond JWKS retention.
341
+ rotateSigningKey(db);
342
+ await expect(validateAccessToken(db, token)).rejects.toThrow(/unknown or expired kid/);
343
+ } finally {
344
+ cleanup();
345
+ }
346
+ });
347
+
348
+ test("rejects a token with no kid header", async () => {
349
+ const { db, cleanup } = makeDb();
350
+ try {
351
+ // Hand-rolled JWT with no kid.
352
+ const header = { alg: "RS256" };
353
+ const payload = { sub: "u", iat: 1, exp: 9_999_999_999 };
354
+ const enc = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
355
+ const fake = `${enc(header)}.${enc(payload)}.sig`;
356
+ await expect(validateAccessToken(db, fake)).rejects.toThrow(/missing kid/);
357
+ } finally {
358
+ cleanup();
359
+ }
360
+ });
361
+ });