@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/src/admin-handlers.ts
CHANGED
|
@@ -1,67 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP handlers for the hub admin surface — login
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* HTTP handlers for the hub admin surface — login + logout. Sessions ride
|
|
3
|
+
* the same `parachute_hub_session` cookie that the OAuth login mints, since
|
|
4
|
+
* PR #112 widened the cookie path from `/oauth/` to `/`.
|
|
5
|
+
*
|
|
6
|
+
* `/login` (was `/admin/login` pre-#231-followup) is the canonical entry
|
|
7
|
+
* for ALL parachute auth — admin operators, OAuth user flows, etc. The
|
|
8
|
+
* `/admin/login` and `/admin/logout` paths 301-redirect for back-compat.
|
|
6
9
|
*
|
|
7
10
|
* Every state-changing POST is double-submit-CSRF protected
|
|
8
|
-
* (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare)
|
|
9
|
-
* and every authenticated GET issues a 302 to `/admin/login?next=<path>`
|
|
10
|
-
* when no session is found rather than rendering an inline login form —
|
|
11
|
-
* keeps each route's intent clean and lets the operator bookmark
|
|
12
|
-
* `/admin/config` without thinking about state.
|
|
11
|
+
* (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
|
|
13
12
|
*/
|
|
14
13
|
import type { Database } from "bun:sqlite";
|
|
15
|
-
import {
|
|
16
|
-
type AdminConfigModuleView,
|
|
17
|
-
type ModuleStatus,
|
|
18
|
-
renderAdminConfigPage,
|
|
19
|
-
renderAdminError,
|
|
20
|
-
renderAdminLogin,
|
|
21
|
-
} from "./admin-config-ui.ts";
|
|
22
|
-
import {
|
|
23
|
-
type ConfigurableModule,
|
|
24
|
-
configPathFor,
|
|
25
|
-
discoverConfigurableModules,
|
|
26
|
-
readModuleConfig,
|
|
27
|
-
validateAndCoerce,
|
|
28
|
-
writeModuleConfig,
|
|
29
|
-
} from "./admin-config.ts";
|
|
30
|
-
import { restart as lifecycleRestart } from "./commands/lifecycle.ts";
|
|
31
|
-
import { CONFIG_DIR } from "./config.ts";
|
|
14
|
+
import { renderAdminError, renderAdminLogin } from "./admin-login-ui.ts";
|
|
32
15
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
33
|
-
import type { ModuleManifest } from "./module-manifest.ts";
|
|
34
16
|
import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
|
|
35
|
-
import {
|
|
36
|
-
type ServicesManifest,
|
|
37
|
-
readManifest as readServicesManifest,
|
|
38
|
-
} from "./services-manifest.ts";
|
|
39
17
|
import {
|
|
40
18
|
SESSION_TTL_MS,
|
|
41
19
|
buildSessionClearCookie,
|
|
42
20
|
buildSessionCookie,
|
|
43
21
|
createSession,
|
|
44
22
|
deleteSession,
|
|
45
|
-
findActiveSession,
|
|
46
23
|
parseSessionCookie,
|
|
47
24
|
} from "./sessions.ts";
|
|
48
25
|
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
49
26
|
|
|
50
|
-
export interface AdminDeps {
|
|
51
|
-
/** Resolves the installed-services manifest (production: `services-manifest.readManifest`). */
|
|
52
|
-
loadServicesManifest?: () => ServicesManifest;
|
|
53
|
-
/** Per-module `.parachute/module.json` reader (production: `module-manifest.readModuleManifest`). */
|
|
54
|
-
readManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
55
|
-
/** `~/.parachute` (defaults to `CONFIG_DIR`). Module configs land at `<configDir>/<name>/config.json`. */
|
|
56
|
-
configDir?: string;
|
|
57
|
-
/** Test seam — defaults to `commands/lifecycle.restart`. */
|
|
58
|
-
restartService?: (name: string) => Promise<number>;
|
|
59
|
-
/** Test seam — defaults to logging to stderr. */
|
|
60
|
-
log?: (line: string) => void;
|
|
61
|
-
/** Test seam — defaults to real clock. */
|
|
62
|
-
now?: () => Date;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
27
|
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
66
28
|
return new Response(body, {
|
|
67
29
|
status,
|
|
@@ -73,22 +35,27 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
|
|
|
73
35
|
return new Response(null, { status: 302, headers: { location, ...extra } });
|
|
74
36
|
}
|
|
75
37
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Post-login default landing. The admin SPA mounts at `/admin/*` and treats
|
|
40
|
+
* `/admin/vaults` as its home (vault list, the default tab). Anywhere else
|
|
41
|
+
* would either bounce the operator out of the SPA shell or land on a
|
|
42
|
+
* legacy server-rendered page — `/admin/vaults` is the canonical entry.
|
|
43
|
+
*/
|
|
44
|
+
const POST_LOGIN_DEFAULT = "/admin/vaults";
|
|
83
45
|
|
|
84
46
|
function safeNext(raw: string | null): string {
|
|
85
|
-
if (!raw) return
|
|
47
|
+
if (!raw) return POST_LOGIN_DEFAULT;
|
|
86
48
|
// Only allow same-origin paths — never honor an absolute URL or scheme.
|
|
87
|
-
if (!raw.startsWith("/") || raw.startsWith("//")) return
|
|
49
|
+
if (!raw.startsWith("/") || raw.startsWith("//")) return POST_LOGIN_DEFAULT;
|
|
88
50
|
return raw;
|
|
89
51
|
}
|
|
90
52
|
|
|
91
|
-
// --- /
|
|
53
|
+
// --- /login ---------------------------------------------------------------
|
|
54
|
+
//
|
|
55
|
+
// Renamed from `/admin/login` so the surface name reflects what it is — the
|
|
56
|
+
// canonical entry for ALL parachute auth (operators, OAuth user flows,
|
|
57
|
+
// etc.), not an admin-only door. `/admin/login` and `/admin/logout` 301
|
|
58
|
+
// to here from `hub-server.ts` for back-compat.
|
|
92
59
|
|
|
93
60
|
export function handleAdminLoginGet(_db: Database, req: Request): Response {
|
|
94
61
|
const url = new URL(req.url);
|
|
@@ -164,25 +131,24 @@ export async function handleAdminLoginPost(
|
|
|
164
131
|
/**
|
|
165
132
|
* Test-injection seam for `handleAdminLoginPost`. Production callers omit
|
|
166
133
|
* `deps`; tests pass a deterministic clock so the rate-limit assertions
|
|
167
|
-
* don't race wall-clock time.
|
|
168
|
-
* `AdminDeps` because it doesn't load services / module manifests.
|
|
134
|
+
* don't race wall-clock time.
|
|
169
135
|
*/
|
|
170
136
|
export interface AdminLoginDeps {
|
|
171
137
|
/** Test seam — defaults to real clock. */
|
|
172
138
|
now?: () => Date;
|
|
173
139
|
}
|
|
174
140
|
|
|
175
|
-
// --- /
|
|
141
|
+
// --- /logout --------------------------------------------------------------
|
|
176
142
|
|
|
177
143
|
/**
|
|
178
144
|
* POST-only — logout is state-changing, so it rides the same double-submit
|
|
179
|
-
* CSRF discipline as login
|
|
180
|
-
*
|
|
181
|
-
*
|
|
145
|
+
* CSRF discipline as login. Without CSRF, a malicious cross-origin form
|
|
146
|
+
* could log the operator out (annoyance, not catastrophe, but the safety
|
|
147
|
+
* belt is already on the bus).
|
|
182
148
|
*
|
|
183
149
|
* Always idempotent: clearing the cookie succeeds even if there's no
|
|
184
|
-
* matching session row. Returns 302 → /
|
|
185
|
-
*
|
|
150
|
+
* matching session row. Returns 302 → /login so the operator lands back
|
|
151
|
+
* on the form ready to re-authenticate.
|
|
186
152
|
*/
|
|
187
153
|
export async function handleAdminLogoutPost(db: Database, req: Request): Promise<Response> {
|
|
188
154
|
const form = await req.formData();
|
|
@@ -198,193 +164,5 @@ export async function handleAdminLogoutPost(db: Database, req: Request): Promise
|
|
|
198
164
|
}
|
|
199
165
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
200
166
|
if (sid) deleteSession(db, sid);
|
|
201
|
-
return redirect("/
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// --- /admin/config ---------------------------------------------------------
|
|
205
|
-
|
|
206
|
-
export async function handleAdminConfigGet(
|
|
207
|
-
db: Database,
|
|
208
|
-
req: Request,
|
|
209
|
-
deps: AdminDeps = {},
|
|
210
|
-
): Promise<Response> {
|
|
211
|
-
const session = findActiveSession(db, req);
|
|
212
|
-
if (!session) return loginRedirect(req);
|
|
213
|
-
|
|
214
|
-
const csrf = ensureCsrfToken(req);
|
|
215
|
-
const setCookieExtra: Record<string, string> = csrf.setCookie
|
|
216
|
-
? { "set-cookie": csrf.setCookie }
|
|
217
|
-
: {};
|
|
218
|
-
|
|
219
|
-
const modules = await loadModuleViews(deps);
|
|
220
|
-
const flash = parseFlash(req);
|
|
221
|
-
if (flash) applyFlashTo(modules, flash);
|
|
222
|
-
return htmlResponse(
|
|
223
|
-
renderAdminConfigPage({ modules, csrfToken: csrf.token }),
|
|
224
|
-
200,
|
|
225
|
-
setCookieExtra,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export async function handleAdminConfigPost(
|
|
230
|
-
db: Database,
|
|
231
|
-
req: Request,
|
|
232
|
-
moduleName: string,
|
|
233
|
-
deps: AdminDeps = {},
|
|
234
|
-
): Promise<Response> {
|
|
235
|
-
const session = findActiveSession(db, req);
|
|
236
|
-
if (!session) return loginRedirect(req);
|
|
237
|
-
|
|
238
|
-
const form = await req.formData();
|
|
239
|
-
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
240
|
-
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
241
|
-
return htmlResponse(
|
|
242
|
-
renderAdminError({
|
|
243
|
-
title: "Invalid form submission",
|
|
244
|
-
message: "The form's CSRF token did not match. Reload the page and try again.",
|
|
245
|
-
}),
|
|
246
|
-
400,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const modules = await discoverConfigurableModules(discoverDeps(deps));
|
|
251
|
-
const target = modules.find((m) => m.name === moduleName);
|
|
252
|
-
if (!target) {
|
|
253
|
-
return htmlResponse(
|
|
254
|
-
renderAdminError({
|
|
255
|
-
title: "Unknown module",
|
|
256
|
-
message: `No installed module named "${moduleName}" declares a config schema on this hub.`,
|
|
257
|
-
}),
|
|
258
|
-
404,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
263
|
-
const submitted = collectFormValues(form, target);
|
|
264
|
-
const result = validateAndCoerce(submitted, target.schema);
|
|
265
|
-
if (!result.ok) {
|
|
266
|
-
return rerenderWithStatus(deps, target, csrfToken, {
|
|
267
|
-
fieldErrors: result.errors,
|
|
268
|
-
pending: submitted,
|
|
269
|
-
errorMessage: "Some fields need attention before this config can be saved.",
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
try {
|
|
274
|
-
writeModuleConfig(target.configPath, result.data ?? {});
|
|
275
|
-
} catch (err) {
|
|
276
|
-
return rerenderWithStatus(deps, target, csrfToken, {
|
|
277
|
-
pending: submitted,
|
|
278
|
-
errorMessage: `Failed to write ${target.configPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const restartFn = deps.restartService ?? defaultRestart(deps);
|
|
283
|
-
let restartCode = 0;
|
|
284
|
-
try {
|
|
285
|
-
restartCode = await restartFn(target.name);
|
|
286
|
-
} catch (err) {
|
|
287
|
-
return successRedirect(target.name, "saved-restart-failed", err);
|
|
288
|
-
}
|
|
289
|
-
if (restartCode !== 0) return successRedirect(target.name, "saved-restart-failed");
|
|
290
|
-
return successRedirect(target.name, "saved");
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function defaultRestart(deps: AdminDeps): (name: string) => Promise<number> {
|
|
294
|
-
return (name) =>
|
|
295
|
-
lifecycleRestart(name, {
|
|
296
|
-
configDir: deps.configDir ?? CONFIG_DIR,
|
|
297
|
-
log: deps.log,
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function discoverDeps(deps: AdminDeps) {
|
|
302
|
-
const out: Parameters<typeof discoverConfigurableModules>[0] = {
|
|
303
|
-
loadServicesManifest: deps.loadServicesManifest ?? readServicesManifest,
|
|
304
|
-
configDir: deps.configDir ?? CONFIG_DIR,
|
|
305
|
-
};
|
|
306
|
-
if (deps.readManifest) out.readManifest = deps.readManifest;
|
|
307
|
-
return out;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async function loadModuleViews(deps: AdminDeps): Promise<AdminConfigModuleView[]> {
|
|
311
|
-
const modules = await discoverConfigurableModules(discoverDeps(deps));
|
|
312
|
-
return modules.map((module) => {
|
|
313
|
-
const { data, parseError } = readModuleConfig(module.configPath);
|
|
314
|
-
const view: AdminConfigModuleView = { module, current: data };
|
|
315
|
-
if (parseError) view.parseError = parseError;
|
|
316
|
-
return view;
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function collectFormValues(
|
|
321
|
-
form: Awaited<ReturnType<Request["formData"]>>,
|
|
322
|
-
module: ConfigurableModule,
|
|
323
|
-
): Record<string, string | boolean | undefined> {
|
|
324
|
-
const out: Record<string, string | boolean | undefined> = {};
|
|
325
|
-
for (const [key, prop] of Object.entries(module.schema.properties)) {
|
|
326
|
-
if (prop.type === "boolean") {
|
|
327
|
-
// Unchecked checkboxes don't appear in form data — absence = false.
|
|
328
|
-
out[key] = form.has(key);
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
const v = form.get(key);
|
|
332
|
-
out[key] = typeof v === "string" ? v : undefined;
|
|
333
|
-
}
|
|
334
|
-
return out;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// --- flash + redirect helpers ---------------------------------------------
|
|
338
|
-
|
|
339
|
-
const FLASH_PARAM = "_status";
|
|
340
|
-
const FLASH_MODULE_PARAM = "_module";
|
|
341
|
-
|
|
342
|
-
function successRedirect(moduleName: string, status: string, err?: unknown): Response {
|
|
343
|
-
const target = new URL("/admin/config", "http://placeholder");
|
|
344
|
-
target.searchParams.set(FLASH_PARAM, status);
|
|
345
|
-
target.searchParams.set(FLASH_MODULE_PARAM, moduleName);
|
|
346
|
-
if (err) target.searchParams.set("_err", err instanceof Error ? err.message : String(err));
|
|
347
|
-
target.hash = `module-${moduleName}`;
|
|
348
|
-
return redirect(`${target.pathname}${target.search}${target.hash}`);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function parseFlash(req: Request): { module: string; status: string; errMessage?: string } | null {
|
|
352
|
-
const url = new URL(req.url);
|
|
353
|
-
const status = url.searchParams.get(FLASH_PARAM);
|
|
354
|
-
const mod = url.searchParams.get(FLASH_MODULE_PARAM);
|
|
355
|
-
if (!status || !mod) return null;
|
|
356
|
-
const errMessage = url.searchParams.get("_err") ?? undefined;
|
|
357
|
-
const out: { module: string; status: string; errMessage?: string } = { module: mod, status };
|
|
358
|
-
if (errMessage) out.errMessage = errMessage;
|
|
359
|
-
return out;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function applyFlashTo(
|
|
363
|
-
views: AdminConfigModuleView[],
|
|
364
|
-
flash: { module: string; status: string; errMessage?: string },
|
|
365
|
-
): void {
|
|
366
|
-
const view = views.find((v) => v.module.name === flash.module);
|
|
367
|
-
if (!view) return;
|
|
368
|
-
if (flash.status === "saved") {
|
|
369
|
-
view.status = { successMessage: `Saved and restarted ${view.module.displayName}.` };
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
if (flash.status === "saved-restart-failed") {
|
|
373
|
-
const tail = flash.errMessage ? ` (${flash.errMessage})` : "";
|
|
374
|
-
view.status = {
|
|
375
|
-
errorMessage: `Saved ${view.module.displayName} config but the restart did not succeed${tail}. Run \`parachute restart ${view.module.name}\` and check logs.`,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
async function rerenderWithStatus(
|
|
381
|
-
deps: AdminDeps,
|
|
382
|
-
target: ConfigurableModule,
|
|
383
|
-
csrfToken: string,
|
|
384
|
-
status: ModuleStatus,
|
|
385
|
-
): Promise<Response> {
|
|
386
|
-
const views = await loadModuleViews(deps);
|
|
387
|
-
const view = views.find((v) => v.module.name === target.name);
|
|
388
|
-
if (view) view.status = status;
|
|
389
|
-
return htmlResponse(renderAdminConfigPage({ modules: views, csrfToken }), 422);
|
|
167
|
+
return redirect("/login", { "set-cookie": buildSessionClearCookie() });
|
|
390
168
|
}
|
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `GET /admin/host-admin-token` — exchange a valid admin session cookie for a
|
|
3
|
-
* short-lived JWT carrying `parachute:host:admin
|
|
3
|
+
* short-lived JWT carrying both `parachute:host:admin` and
|
|
4
|
+
* `parachute:host:auth` (the same superset an `--scope-set admin` operator
|
|
5
|
+
* token holds).
|
|
4
6
|
*
|
|
5
|
-
* Why this exists: the hub's
|
|
6
|
-
*
|
|
7
|
-
* `parachute:host:admin`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Why this exists: the hub's admin SPA (served under `/hub/`) needs a Bearer
|
|
8
|
+
* to call:
|
|
9
|
+
* - `parachute:host:admin` surfaces — `POST /vaults` (vault provisioning),
|
|
10
|
+
* `/api/grants` (OAuth consent grant management).
|
|
11
|
+
* - `parachute:host:auth` surfaces — `GET /api/auth/tokens`,
|
|
12
|
+
* `POST /api/auth/mint-token`, `POST /api/auth/revoke-token` (the token
|
|
13
|
+
* registry endpoints from hub#212 Phase 2).
|
|
14
|
+
*
|
|
15
|
+
* Both scopes are in `NON_REQUESTABLE_SCOPES` — the public `/oauth/authorize`
|
|
16
|
+
* endpoint refuses to mint either, so third-party apps can't acquire these
|
|
17
|
+
* capabilities via consent. This local mint path is the SPA's only way in.
|
|
10
18
|
*
|
|
11
19
|
* The local mint path now has two surfaces:
|
|
12
|
-
* 1. `parachute auth ...` — operator-token file (
|
|
20
|
+
* 1. `parachute auth ...` — operator-token file (90d, on-disk, mode 0600).
|
|
13
21
|
* 2. THIS endpoint — short-lived JWT (10 min) handed to the SPA in memory.
|
|
14
22
|
*
|
|
15
23
|
* Both paths require already-proved local-operator identity:
|
|
16
24
|
* - operator-token mint runs as the operator's unix user.
|
|
17
25
|
* - this endpoint requires a valid `parachute_hub_session` cookie, which
|
|
18
|
-
* was set by `/
|
|
26
|
+
* was set by `/login` after a password check.
|
|
19
27
|
*
|
|
20
28
|
* Tokens minted here are deliberately NOT persisted in the `tokens` table
|
|
21
29
|
* (no refresh, no revocation tracking). They expire on their own; the SPA
|
|
22
30
|
* re-fetches when the JWT is about to lapse.
|
|
31
|
+
*
|
|
32
|
+
* Background on the dual-scope shape: prior to hub#212 Phase 2 this endpoint
|
|
33
|
+
* minted `parachute:host:admin` only. The Phase 2 admin endpoints
|
|
34
|
+
* (`/api/auth/*`) gate on `parachute:host:auth`, which the SPA's bearer
|
|
35
|
+
* lacked — `/hub/tokens` failed with `bearer token lacks parachute:host:auth`
|
|
36
|
+
* on first end-to-end load. Adding `:host:auth` here brings the SPA bearer
|
|
37
|
+
* in line with the admin scope-set semantics from hub#214 / #222.
|
|
23
38
|
*/
|
|
24
39
|
import type { Database } from "bun:sqlite";
|
|
25
40
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
@@ -29,7 +44,7 @@ import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
|
29
44
|
export const HOST_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
|
|
30
45
|
const HOST_ADMIN_AUDIENCE = "hub";
|
|
31
46
|
const HOST_ADMIN_CLIENT_ID = "parachute-hub-spa";
|
|
32
|
-
export const HOST_ADMIN_SCOPES = ["parachute:host:admin"] as const;
|
|
47
|
+
export const HOST_ADMIN_SCOPES = ["parachute:host:admin", "parachute:host:auth"] as const;
|
|
33
48
|
|
|
34
49
|
export interface MintHostAdminTokenDeps {
|
|
35
50
|
db: Database;
|
|
@@ -47,7 +62,7 @@ export async function handleHostAdminToken(
|
|
|
47
62
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
48
63
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
49
64
|
if (!session) {
|
|
50
|
-
return jsonError(401, "unauthenticated", "no admin session — sign in at /
|
|
65
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
51
66
|
}
|
|
52
67
|
const minted = await signAccessToken(deps.db, {
|
|
53
68
|
sub: session.userId,
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded HTML for the hub's pre-auth surfaces: the `/login` form and the
|
|
3
|
+
* generic admin error page surfaced when CSRF or rate-limit gates fire on
|
|
4
|
+
* `/login` and `/logout`. Same privacy posture as `oauth-ui.ts` (no third-
|
|
5
|
+
* party fonts, inline CSS, no JS) — these pages are pre-auth and have to
|
|
6
|
+
* stand alone without the SPA shell.
|
|
7
|
+
*
|
|
8
|
+
* History: this file was `admin-config-ui.ts` and held the server-rendered
|
|
9
|
+
* `/admin/config` module-config portal (hub#46). #240 retired the portal
|
|
10
|
+
* post-SPA-rework; the file shed everything except the two renderers below.
|
|
11
|
+
* Renamed to `admin-login-ui.ts` in #241 so the filename matches the content.
|
|
12
|
+
*
|
|
13
|
+
* Pure functions — DB, sessions live in `admin-handlers.ts`.
|
|
14
|
+
*/
|
|
15
|
+
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
16
|
+
import { escapeHtml } from "./oauth-ui.ts";
|
|
17
|
+
|
|
18
|
+
// --- shared chrome ---------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const PALETTE = {
|
|
21
|
+
bg: "#faf8f4",
|
|
22
|
+
bgSoft: "#f3f0ea",
|
|
23
|
+
fg: "#2c2a26",
|
|
24
|
+
fgMuted: "#6b6860",
|
|
25
|
+
fgDim: "#9a9690",
|
|
26
|
+
accent: "#4a7c59",
|
|
27
|
+
accentHover: "#3d6849",
|
|
28
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
29
|
+
border: "#e4e0d8",
|
|
30
|
+
borderLight: "#ece9e2",
|
|
31
|
+
cardBg: "#ffffff",
|
|
32
|
+
danger: "#a3392b",
|
|
33
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
34
|
+
success: "#3d6849",
|
|
35
|
+
successSoft: "rgba(61, 104, 73, 0.08)",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
39
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
40
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
41
|
+
|
|
42
|
+
function escapeAttr(s: string): string {
|
|
43
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function baseDocument(title: string, body: string): string {
|
|
47
|
+
return `<!doctype html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8" />
|
|
51
|
+
<title>${escapeHtml(title)}</title>
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
53
|
+
<meta name="referrer" content="no-referrer" />
|
|
54
|
+
<style>${STYLES}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<main>
|
|
58
|
+
${body}
|
|
59
|
+
</main>
|
|
60
|
+
</body>
|
|
61
|
+
</html>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function header(): string {
|
|
65
|
+
return `
|
|
66
|
+
<div class="brand">
|
|
67
|
+
<span class="brand-mark">⌬</span>
|
|
68
|
+
<span class="brand-name">Parachute</span>
|
|
69
|
+
<span class="brand-tag">admin</span>
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- /login ---------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export interface AdminLoginProps {
|
|
76
|
+
/** Continuation path after successful login — submitted as a hidden field. */
|
|
77
|
+
next: string;
|
|
78
|
+
csrfToken: string;
|
|
79
|
+
errorMessage?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderAdminLogin(props: AdminLoginProps): string {
|
|
83
|
+
const { next, csrfToken, errorMessage } = props;
|
|
84
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
85
|
+
const body = `
|
|
86
|
+
<div class="card">
|
|
87
|
+
<div class="card-header">
|
|
88
|
+
${header()}
|
|
89
|
+
<h1>Sign in</h1>
|
|
90
|
+
<p class="subtitle">to administer this hub</p>
|
|
91
|
+
</div>
|
|
92
|
+
${error}
|
|
93
|
+
<form method="POST" action="/login" class="auth-form">
|
|
94
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
95
|
+
<input type="hidden" name="next" value="${escapeAttr(next)}" />
|
|
96
|
+
<label class="field">
|
|
97
|
+
<span class="field-label">Username</span>
|
|
98
|
+
<input type="text" name="username" autocomplete="username" autofocus required />
|
|
99
|
+
</label>
|
|
100
|
+
<label class="field">
|
|
101
|
+
<span class="field-label">Password</span>
|
|
102
|
+
<input type="password" name="password" autocomplete="current-password" required />
|
|
103
|
+
</label>
|
|
104
|
+
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
105
|
+
</form>
|
|
106
|
+
</div>`;
|
|
107
|
+
return baseDocument("Sign in to Parachute Hub admin", body);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- error page ------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export function renderAdminError(props: { title: string; message: string }): string {
|
|
113
|
+
const body = `
|
|
114
|
+
<div class="card">
|
|
115
|
+
${header()}
|
|
116
|
+
<h1 class="error-title">${escapeHtml(props.title)}</h1>
|
|
117
|
+
<p class="subtitle">${escapeHtml(props.message)}</p>
|
|
118
|
+
</div>`;
|
|
119
|
+
return baseDocument(props.title, body);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- styles ----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const STYLES = `
|
|
125
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
126
|
+
html, body { margin: 0; padding: 0; }
|
|
127
|
+
body {
|
|
128
|
+
font-family: ${FONT_SANS};
|
|
129
|
+
background: ${PALETTE.bg};
|
|
130
|
+
color: ${PALETTE.fg};
|
|
131
|
+
line-height: 1.55;
|
|
132
|
+
min-height: 100vh;
|
|
133
|
+
-webkit-font-smoothing: antialiased;
|
|
134
|
+
-moz-osx-font-smoothing: grayscale;
|
|
135
|
+
}
|
|
136
|
+
main {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
min-height: 100vh;
|
|
141
|
+
padding: 1.5rem;
|
|
142
|
+
}
|
|
143
|
+
.card {
|
|
144
|
+
width: 100%;
|
|
145
|
+
max-width: 30rem;
|
|
146
|
+
background: ${PALETTE.cardBg};
|
|
147
|
+
border: 1px solid ${PALETTE.border};
|
|
148
|
+
border-radius: 12px;
|
|
149
|
+
padding: 2rem 1.75rem;
|
|
150
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
151
|
+
}
|
|
152
|
+
.card-header { margin-bottom: 1.5rem; }
|
|
153
|
+
.brand {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 0.5rem;
|
|
157
|
+
color: ${PALETTE.accent};
|
|
158
|
+
font-weight: 500;
|
|
159
|
+
font-size: 0.95rem;
|
|
160
|
+
margin-bottom: 1.25rem;
|
|
161
|
+
}
|
|
162
|
+
.brand-mark { font-size: 1.1rem; line-height: 1; }
|
|
163
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
164
|
+
.brand-tag {
|
|
165
|
+
text-transform: uppercase;
|
|
166
|
+
letter-spacing: 0.06em;
|
|
167
|
+
font-size: 0.7rem;
|
|
168
|
+
color: ${PALETTE.fgMuted};
|
|
169
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
170
|
+
padding: 0.05rem 0.4rem;
|
|
171
|
+
border-radius: 999px;
|
|
172
|
+
}
|
|
173
|
+
h1 {
|
|
174
|
+
font-family: ${FONT_SERIF};
|
|
175
|
+
font-weight: 400;
|
|
176
|
+
font-size: 1.75rem;
|
|
177
|
+
line-height: 1.2;
|
|
178
|
+
margin: 0 0 0.4rem;
|
|
179
|
+
color: ${PALETTE.fg};
|
|
180
|
+
}
|
|
181
|
+
.subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
|
|
182
|
+
|
|
183
|
+
.auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
|
184
|
+
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
185
|
+
.field-label {
|
|
186
|
+
font-size: 0.85rem;
|
|
187
|
+
font-weight: 500;
|
|
188
|
+
color: ${PALETTE.fgMuted};
|
|
189
|
+
letter-spacing: 0.01em;
|
|
190
|
+
font-family: ${FONT_MONO};
|
|
191
|
+
}
|
|
192
|
+
input[type=text], input[type=password] {
|
|
193
|
+
font: inherit;
|
|
194
|
+
width: 100%;
|
|
195
|
+
padding: 0.6rem 0.75rem;
|
|
196
|
+
border: 1px solid ${PALETTE.border};
|
|
197
|
+
border-radius: 6px;
|
|
198
|
+
background: ${PALETTE.bg};
|
|
199
|
+
color: ${PALETTE.fg};
|
|
200
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
201
|
+
}
|
|
202
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
203
|
+
outline: none;
|
|
204
|
+
border-color: ${PALETTE.accent};
|
|
205
|
+
background: ${PALETTE.cardBg};
|
|
206
|
+
box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.btn {
|
|
210
|
+
font: inherit;
|
|
211
|
+
font-weight: 500;
|
|
212
|
+
padding: 0.65rem 1.25rem;
|
|
213
|
+
border-radius: 6px;
|
|
214
|
+
border: 1px solid transparent;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
217
|
+
min-height: 2.5rem;
|
|
218
|
+
}
|
|
219
|
+
.btn-primary {
|
|
220
|
+
background: ${PALETTE.accent};
|
|
221
|
+
color: ${PALETTE.cardBg};
|
|
222
|
+
margin-top: 0.4rem;
|
|
223
|
+
}
|
|
224
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
225
|
+
|
|
226
|
+
.error-banner {
|
|
227
|
+
background: ${PALETTE.dangerSoft};
|
|
228
|
+
border: 1px solid ${PALETTE.danger};
|
|
229
|
+
border-radius: 6px;
|
|
230
|
+
color: ${PALETTE.danger};
|
|
231
|
+
padding: 0.6rem 0.8rem;
|
|
232
|
+
margin: 0 0 1rem;
|
|
233
|
+
font-size: 0.9rem;
|
|
234
|
+
}
|
|
235
|
+
.error-title { color: ${PALETTE.danger}; }
|
|
236
|
+
|
|
237
|
+
@media (max-width: 480px) {
|
|
238
|
+
main { padding: 0.75rem; }
|
|
239
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
240
|
+
h1 { font-size: 1.5rem; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@media (prefers-color-scheme: dark) {
|
|
244
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
245
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
246
|
+
h1 { color: #f0ece4; }
|
|
247
|
+
.subtitle, .field-label { color: #a8a29a; }
|
|
248
|
+
input[type=text], input[type=password] {
|
|
249
|
+
background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
|
|
250
|
+
}
|
|
251
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
252
|
+
background: #25221d;
|
|
253
|
+
}
|
|
254
|
+
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
255
|
+
}
|
|
256
|
+
`;
|
|
@@ -55,7 +55,7 @@ export async function handleVaultAdminToken(
|
|
|
55
55
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
56
56
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
57
57
|
if (!session) {
|
|
58
|
-
return jsonError(401, "unauthenticated", "no admin session — sign in at /
|
|
58
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
59
59
|
}
|
|
60
60
|
const scope = `vault:${vaultName}:admin`;
|
|
61
61
|
// Per-vault audience: vault validates the JWT's `aud` claim against
|