@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
|
@@ -92,7 +92,7 @@ describe("vaultInstanceName", () => {
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
describe("buildWellKnown", () => {
|
|
95
|
-
test("
|
|
95
|
+
test("every kind is a plural array; services[] includes all (#92)", () => {
|
|
96
96
|
const doc = buildWellKnown({
|
|
97
97
|
services: [vault, notes, scribe],
|
|
98
98
|
canonicalOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
@@ -104,14 +104,18 @@ describe("buildWellKnown", () => {
|
|
|
104
104
|
version: "0.2.4",
|
|
105
105
|
},
|
|
106
106
|
]);
|
|
107
|
-
expect(doc.notes).toEqual(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
expect(doc.notes).toEqual([
|
|
108
|
+
{
|
|
109
|
+
url: "https://parachute.taildf9ce2.ts.net/notes",
|
|
110
|
+
version: "0.0.1",
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
expect(doc.scribe).toEqual([
|
|
114
|
+
{
|
|
115
|
+
url: "https://parachute.taildf9ce2.ts.net/scribe",
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
115
119
|
expect(doc.services.map((s) => s.name)).toEqual([
|
|
116
120
|
"parachute-vault",
|
|
117
121
|
"parachute-notes",
|
|
@@ -119,6 +123,18 @@ describe("buildWellKnown", () => {
|
|
|
119
123
|
]);
|
|
120
124
|
});
|
|
121
125
|
|
|
126
|
+
test("multiple installs of the same kind both land in the array (#92)", () => {
|
|
127
|
+
const work: ServiceEntry = { ...notes, paths: ["/notes-work"], port: 5174 };
|
|
128
|
+
const doc = buildWellKnown({
|
|
129
|
+
services: [notes, work],
|
|
130
|
+
canonicalOrigin: "https://x.example",
|
|
131
|
+
});
|
|
132
|
+
expect(doc.notes).toEqual([
|
|
133
|
+
{ url: "https://x.example/notes", version: "0.0.1" },
|
|
134
|
+
{ url: "https://x.example/notes-work", version: "0.0.1" },
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
122
138
|
test("services[] entries include infoUrl pointing at /.parachute/info", () => {
|
|
123
139
|
const doc = buildWellKnown({
|
|
124
140
|
services: [vault, notes],
|
|
@@ -158,7 +174,7 @@ describe("buildWellKnown", () => {
|
|
|
158
174
|
});
|
|
159
175
|
expect(doc.vaults).toEqual([]);
|
|
160
176
|
expect(doc.services).toHaveLength(1);
|
|
161
|
-
expect(doc.notes).toEqual({ url: "https://x.example/notes", version: "0.0.1" });
|
|
177
|
+
expect(doc.notes).toEqual([{ url: "https://x.example/notes", version: "0.0.1" }]);
|
|
162
178
|
});
|
|
163
179
|
|
|
164
180
|
test("multiple vault instances all land in the vaults array", () => {
|
|
@@ -177,6 +193,63 @@ describe("buildWellKnown", () => {
|
|
|
177
193
|
expect(doc.vaults.map((v) => v.name).sort()).toEqual(["default", "work"]);
|
|
178
194
|
});
|
|
179
195
|
|
|
196
|
+
test("single vault ServiceEntry with multiple paths emits one entry per path (closes #141)", () => {
|
|
197
|
+
// Reflects the post-#179/vault#208 manifest shape: one parachute-vault
|
|
198
|
+
// backend hosts every vault instance, expressed as one ServiceEntry with
|
|
199
|
+
// multiple paths.
|
|
200
|
+
const multi: ServiceEntry = {
|
|
201
|
+
...vault,
|
|
202
|
+
paths: ["/vault/default", "/vault/techne"],
|
|
203
|
+
};
|
|
204
|
+
const doc = buildWellKnown({
|
|
205
|
+
services: [multi],
|
|
206
|
+
canonicalOrigin: "https://x.example",
|
|
207
|
+
});
|
|
208
|
+
expect(doc.vaults).toEqual([
|
|
209
|
+
{ name: "default", url: "https://x.example/vault/default", version: "0.2.4" },
|
|
210
|
+
{ name: "techne", url: "https://x.example/vault/techne", version: "0.2.4" },
|
|
211
|
+
]);
|
|
212
|
+
// services[] mirrors the per-path expansion so the hub page and any
|
|
213
|
+
// generic consumer iterate every instance.
|
|
214
|
+
expect(doc.services).toEqual([
|
|
215
|
+
{
|
|
216
|
+
name: "parachute-vault",
|
|
217
|
+
url: "https://x.example/vault/default",
|
|
218
|
+
path: "/vault/default",
|
|
219
|
+
version: "0.2.4",
|
|
220
|
+
infoUrl: "https://x.example/vault/default/.parachute/info",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "parachute-vault",
|
|
224
|
+
url: "https://x.example/vault/techne",
|
|
225
|
+
path: "/vault/techne",
|
|
226
|
+
version: "0.2.4",
|
|
227
|
+
infoUrl: "https://x.example/vault/techne/.parachute/info",
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("multi-path vault entry is independent of multi-ServiceEntry shape (#141)", () => {
|
|
233
|
+
// A user could plausibly mix shapes: one multi-path bare `parachute-vault`
|
|
234
|
+
// plus a separately-installed `parachute-vault-archive`. All instances
|
|
235
|
+
// should surface.
|
|
236
|
+
const multi: ServiceEntry = {
|
|
237
|
+
...vault,
|
|
238
|
+
paths: ["/vault/default", "/vault/techne"],
|
|
239
|
+
};
|
|
240
|
+
const archive: ServiceEntry = {
|
|
241
|
+
...vault,
|
|
242
|
+
name: "parachute-vault-archive",
|
|
243
|
+
paths: ["/vault/archive"],
|
|
244
|
+
port: 1942,
|
|
245
|
+
};
|
|
246
|
+
const doc = buildWellKnown({
|
|
247
|
+
services: [multi, archive],
|
|
248
|
+
canonicalOrigin: "https://x.example",
|
|
249
|
+
});
|
|
250
|
+
expect(doc.vaults.map((v) => v.name).sort()).toEqual(["archive", "default", "techne"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
180
253
|
test("handles canonicalOrigin with trailing slash", () => {
|
|
181
254
|
const doc = buildWellKnown({
|
|
182
255
|
services: [vault],
|
|
@@ -185,6 +258,50 @@ describe("buildWellKnown", () => {
|
|
|
185
258
|
expect(doc.vaults[0]?.url).toBe("https://parachute.taildf9ce2.ts.net/vault/default");
|
|
186
259
|
});
|
|
187
260
|
|
|
261
|
+
test("managementUrl rides through when the resolver returns one", () => {
|
|
262
|
+
const doc = buildWellKnown({
|
|
263
|
+
services: [vault],
|
|
264
|
+
canonicalOrigin: "https://x.example",
|
|
265
|
+
managementUrlFor: () => "/admin",
|
|
266
|
+
});
|
|
267
|
+
expect(doc.vaults[0]?.managementUrl).toBe("/admin");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("managementUrl absent when the resolver returns undefined", () => {
|
|
271
|
+
const doc = buildWellKnown({
|
|
272
|
+
services: [vault],
|
|
273
|
+
canonicalOrigin: "https://x.example",
|
|
274
|
+
managementUrlFor: () => undefined,
|
|
275
|
+
});
|
|
276
|
+
expect(doc.vaults[0]).not.toHaveProperty("managementUrl");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("managementUrl is per-entry — multi-instance vaults can differ", () => {
|
|
280
|
+
const work: ServiceEntry = {
|
|
281
|
+
...vault,
|
|
282
|
+
name: "parachute-vault-work",
|
|
283
|
+
paths: ["/vault/work"],
|
|
284
|
+
port: 1941,
|
|
285
|
+
};
|
|
286
|
+
const doc = buildWellKnown({
|
|
287
|
+
services: [vault, work],
|
|
288
|
+
canonicalOrigin: "https://x.example",
|
|
289
|
+
managementUrlFor: (e) => (e.name === "parachute-vault-work" ? "/admin" : undefined),
|
|
290
|
+
});
|
|
291
|
+
const byName = new Map(doc.vaults.map((v) => [v.name, v.managementUrl]));
|
|
292
|
+
expect(byName.get("default")).toBeUndefined();
|
|
293
|
+
expect(byName.get("work")).toBe("/admin");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("managementUrl is not emitted on non-vault services", () => {
|
|
297
|
+
const doc = buildWellKnown({
|
|
298
|
+
services: [notes],
|
|
299
|
+
canonicalOrigin: "https://x.example",
|
|
300
|
+
managementUrlFor: () => "/admin",
|
|
301
|
+
});
|
|
302
|
+
expect(doc.notes).toEqual([{ url: "https://x.example/notes", version: "0.0.1" }]);
|
|
303
|
+
});
|
|
304
|
+
|
|
188
305
|
test("falls back to / for empty paths", () => {
|
|
189
306
|
const entry: ServiceEntry = { ...vault, paths: [] };
|
|
190
307
|
const doc = buildWellKnown({
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-token auth for hub-native admin endpoints (POST /vaults, future
|
|
3
|
+
* `/admin/*` routes). The hub validates its own JWTs against local signing
|
|
4
|
+
* keys — no JWKS round-trip — and asserts the presented token carries the
|
|
5
|
+
* required scope.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: until now the hub has been a *pure issuer* with no
|
|
8
|
+
* authenticated endpoints of its own. Phase 1 of the vault-config-and-scopes
|
|
9
|
+
* design adds POST /vaults, which mints+config-writes through privileged
|
|
10
|
+
* code paths. That call needs an admin scope, and its first reader is
|
|
11
|
+
* `parachute:host:admin` (the cross-vault provisioning capability).
|
|
12
|
+
*
|
|
13
|
+
* Errors are HTTP-shaped: `AdminAuthError(status, message)` so the route
|
|
14
|
+
* handler can `throw` and the boundary translates straight to a Response.
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from "bun:sqlite";
|
|
17
|
+
import { validateAccessToken } from "./jwt-sign.ts";
|
|
18
|
+
|
|
19
|
+
export interface AdminAuthContext {
|
|
20
|
+
/** JWT `sub` — the hub user id. */
|
|
21
|
+
sub: string;
|
|
22
|
+
/** Parsed `scope` claim. */
|
|
23
|
+
scopes: string[];
|
|
24
|
+
/** `client_id` claim, if present (operator token vs OAuth client). */
|
|
25
|
+
clientId: string | undefined;
|
|
26
|
+
/** `aud` claim, if present. Surfaced for logs / future cross-aud rules. */
|
|
27
|
+
audience: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class AdminAuthError extends Error {
|
|
31
|
+
override name = "AdminAuthError";
|
|
32
|
+
status: number;
|
|
33
|
+
constructor(status: number, message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.status = status;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pull a Bearer token from `Authorization: Bearer <token>`. Throws
|
|
41
|
+
* `AdminAuthError(401)` when missing / malformed. Match is case-insensitive
|
|
42
|
+
* on the scheme (some clients send "bearer" lowercase) but the token itself
|
|
43
|
+
* is the raw JWT string.
|
|
44
|
+
*/
|
|
45
|
+
export function extractBearerToken(req: Request): string {
|
|
46
|
+
const header = req.headers.get("authorization");
|
|
47
|
+
if (!header) {
|
|
48
|
+
throw new AdminAuthError(401, "missing Authorization header");
|
|
49
|
+
}
|
|
50
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
51
|
+
if (!match || !match[1]) {
|
|
52
|
+
throw new AdminAuthError(401, "Authorization header must be 'Bearer <token>'");
|
|
53
|
+
}
|
|
54
|
+
return match[1].trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate a presented bearer token against the hub's local signing keys
|
|
59
|
+
* and check it carries `requiredScope`. Returns surfaced claims on success;
|
|
60
|
+
* throws `AdminAuthError` (401 or 403) otherwise.
|
|
61
|
+
*
|
|
62
|
+
* `expectedIssuer` MUST be the hub's own origin — the same value baked into
|
|
63
|
+
* tokens we sign. Defense in depth: even though we can only verify our own
|
|
64
|
+
* keys, the `iss` mismatch reject keeps cross-issuer confusion impossible.
|
|
65
|
+
*/
|
|
66
|
+
export async function requireScope(
|
|
67
|
+
db: Database,
|
|
68
|
+
req: Request,
|
|
69
|
+
requiredScope: string,
|
|
70
|
+
expectedIssuer: string,
|
|
71
|
+
): Promise<AdminAuthContext> {
|
|
72
|
+
const token = extractBearerToken(req);
|
|
73
|
+
|
|
74
|
+
let validated: Awaited<ReturnType<typeof validateAccessToken>>;
|
|
75
|
+
try {
|
|
76
|
+
validated = await validateAccessToken(db, token, expectedIssuer);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
throw new AdminAuthError(401, `invalid token: ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sub = typeof validated.payload.sub === "string" ? validated.payload.sub : null;
|
|
83
|
+
if (!sub) throw new AdminAuthError(401, "token missing required `sub` claim");
|
|
84
|
+
|
|
85
|
+
const scopeClaim = (validated.payload as { scope?: unknown }).scope;
|
|
86
|
+
const scopes =
|
|
87
|
+
typeof scopeClaim === "string" ? scopeClaim.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
88
|
+
|
|
89
|
+
if (!scopes.includes(requiredScope)) {
|
|
90
|
+
throw new AdminAuthError(403, `token missing required scope: ${requiredScope}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const clientIdRaw = (validated.payload as { client_id?: unknown }).client_id;
|
|
94
|
+
const clientId = typeof clientIdRaw === "string" ? clientIdRaw : undefined;
|
|
95
|
+
const aud = typeof validated.payload.aud === "string" ? validated.payload.aud : undefined;
|
|
96
|
+
|
|
97
|
+
return { sub, scopes, clientId, audience: aud };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Translate an AdminAuthError to an RFC-6750-style JSON Response.
|
|
102
|
+
* Convenience for route handlers that want to do
|
|
103
|
+
* `try { ctx = await requireScope(...) } catch (err) { return adminAuthErrorResponse(err); }`.
|
|
104
|
+
*/
|
|
105
|
+
export function adminAuthErrorResponse(err: unknown): Response {
|
|
106
|
+
if (err instanceof AdminAuthError) {
|
|
107
|
+
return new Response(
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
error: err.status === 403 ? "insufficient_scope" : "invalid_token",
|
|
110
|
+
error_description: err.message,
|
|
111
|
+
}),
|
|
112
|
+
{
|
|
113
|
+
status: err.status,
|
|
114
|
+
headers: {
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
"www-authenticate": `Bearer error="${err.status === 403 ? "insufficient_scope" : "invalid_token"}", error_description="${err.message.replace(/"/g, "'")}"`,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
122
|
+
return new Response(JSON.stringify({ error: "server_error", error_description: msg }), {
|
|
123
|
+
status: 500,
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
125
|
+
});
|
|
126
|
+
}
|