@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
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { exposePublic, exposeTailnet } from "./commands/expose.ts";
|
|
|
13
13
|
import { install } from "./commands/install.ts";
|
|
14
14
|
import { logs, restart, start, stop } from "./commands/lifecycle.ts";
|
|
15
15
|
import { migrate } from "./commands/migrate.ts";
|
|
16
|
+
import { setup } from "./commands/setup.ts";
|
|
16
17
|
import { status } from "./commands/status.ts";
|
|
18
|
+
import { upgrade } from "./commands/upgrade.ts";
|
|
17
19
|
import { dispatchVault } from "./commands/vault.ts";
|
|
18
20
|
import { ExposeStateError } from "./expose-state.ts";
|
|
19
21
|
import {
|
|
@@ -22,11 +24,14 @@ import {
|
|
|
22
24
|
logsHelp,
|
|
23
25
|
migrateHelp,
|
|
24
26
|
restartHelp,
|
|
27
|
+
setupHelp,
|
|
25
28
|
startHelp,
|
|
26
29
|
statusHelp,
|
|
27
30
|
stopHelp,
|
|
28
31
|
topLevelHelp,
|
|
32
|
+
upgradeHelp,
|
|
29
33
|
} from "./help.ts";
|
|
34
|
+
import { HUB_SVC } from "./hub-control.ts";
|
|
30
35
|
import { knownServices } from "./service-spec.ts";
|
|
31
36
|
import { ServicesManifestError } from "./services-manifest.ts";
|
|
32
37
|
import { TailscaleError } from "./tailscale/run.ts";
|
|
@@ -139,41 +144,124 @@ function extractNamedFlag(
|
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
/**
|
|
142
|
-
* Extract
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
147
|
+
* Extract every `parachute expose …` provider flag in one pass:
|
|
148
|
+
*
|
|
149
|
+
* --cloudflare boolean — pin to Cloudflare Tunnel
|
|
150
|
+
* --tailnet boolean — pin to Tailscale Funnel (#29)
|
|
151
|
+
* --skip-provider-check boolean — bypass auto-detection in non-TTY,
|
|
152
|
+
* fall through to today's Tailscale
|
|
153
|
+
* default (CI escape hatch, #29)
|
|
154
|
+
* --domain=<host> hostname for the Cloudflare path
|
|
155
|
+
* --tunnel-name=<name> named tunnel override (#32)
|
|
156
|
+
*
|
|
157
|
+
* Returns the stripped argv so the layer/action parser sees `[layer, action?]`
|
|
158
|
+
* regardless of flag placement. `--tailnet` + `--cloudflare` together is
|
|
159
|
+
* caller-rejected; this extractor doesn't enforce mutual exclusion so help-
|
|
160
|
+
* driven error messages can stay close to the dispatch site.
|
|
146
161
|
*/
|
|
147
|
-
function
|
|
162
|
+
function extractExposeProviderFlags(args: string[]): {
|
|
148
163
|
cloudflare: boolean;
|
|
164
|
+
tailnet: boolean;
|
|
165
|
+
skipProviderCheck: boolean;
|
|
149
166
|
domain?: string;
|
|
167
|
+
tunnelName?: string;
|
|
150
168
|
rest: string[];
|
|
151
169
|
error?: string;
|
|
152
170
|
} {
|
|
153
171
|
const rest: string[] = [];
|
|
154
172
|
let cloudflare = false;
|
|
173
|
+
let tailnet = false;
|
|
174
|
+
let skipProviderCheck = false;
|
|
155
175
|
let domain: string | undefined;
|
|
176
|
+
let tunnelName: string | undefined;
|
|
156
177
|
for (let i = 0; i < args.length; i++) {
|
|
157
178
|
const a = args[i];
|
|
158
179
|
if (a === "--cloudflare") {
|
|
159
180
|
cloudflare = true;
|
|
160
181
|
continue;
|
|
161
182
|
}
|
|
183
|
+
if (a === "--tailnet" || a === "--tailscale") {
|
|
184
|
+
tailnet = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (a === "--skip-provider-check") {
|
|
188
|
+
skipProviderCheck = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
162
191
|
if (a === "--domain") {
|
|
163
192
|
const v = args[i + 1];
|
|
164
|
-
if (!v)
|
|
193
|
+
if (!v) {
|
|
194
|
+
return {
|
|
195
|
+
cloudflare,
|
|
196
|
+
tailnet,
|
|
197
|
+
skipProviderCheck,
|
|
198
|
+
rest,
|
|
199
|
+
error: "--domain requires a hostname argument",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
165
202
|
domain = v;
|
|
166
203
|
i++;
|
|
167
204
|
continue;
|
|
168
205
|
}
|
|
169
206
|
if (a?.startsWith("--domain=")) {
|
|
170
207
|
domain = a.slice("--domain=".length);
|
|
171
|
-
if (!domain)
|
|
208
|
+
if (!domain) {
|
|
209
|
+
return {
|
|
210
|
+
cloudflare,
|
|
211
|
+
tailnet,
|
|
212
|
+
skipProviderCheck,
|
|
213
|
+
rest,
|
|
214
|
+
error: "--domain requires a hostname argument",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (a === "--tunnel-name") {
|
|
220
|
+
const v = args[i + 1];
|
|
221
|
+
if (!v) {
|
|
222
|
+
return {
|
|
223
|
+
cloudflare,
|
|
224
|
+
tailnet,
|
|
225
|
+
skipProviderCheck,
|
|
226
|
+
rest,
|
|
227
|
+
error: "--tunnel-name requires a name argument",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
tunnelName = v;
|
|
231
|
+
i++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (a?.startsWith("--tunnel-name=")) {
|
|
235
|
+
tunnelName = a.slice("--tunnel-name=".length);
|
|
236
|
+
if (!tunnelName) {
|
|
237
|
+
return {
|
|
238
|
+
cloudflare,
|
|
239
|
+
tailnet,
|
|
240
|
+
skipProviderCheck,
|
|
241
|
+
rest,
|
|
242
|
+
error: "--tunnel-name requires a name argument",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
172
245
|
continue;
|
|
173
246
|
}
|
|
174
247
|
if (a !== undefined) rest.push(a);
|
|
175
248
|
}
|
|
176
|
-
|
|
249
|
+
const out: {
|
|
250
|
+
cloudflare: boolean;
|
|
251
|
+
tailnet: boolean;
|
|
252
|
+
skipProviderCheck: boolean;
|
|
253
|
+
domain?: string;
|
|
254
|
+
tunnelName?: string;
|
|
255
|
+
rest: string[];
|
|
256
|
+
} = {
|
|
257
|
+
cloudflare,
|
|
258
|
+
tailnet,
|
|
259
|
+
skipProviderCheck,
|
|
260
|
+
rest,
|
|
261
|
+
};
|
|
262
|
+
if (domain !== undefined) out.domain = domain;
|
|
263
|
+
if (tunnelName !== undefined) out.tunnelName = tunnelName;
|
|
264
|
+
return out;
|
|
177
265
|
}
|
|
178
266
|
|
|
179
267
|
async function main(argv: string[]): Promise<number> {
|
|
@@ -192,6 +280,29 @@ async function main(argv: string[]): Promise<number> {
|
|
|
192
280
|
console.log(pkg.version);
|
|
193
281
|
return 0;
|
|
194
282
|
|
|
283
|
+
case "setup": {
|
|
284
|
+
if (isHelpFlag(rest[0])) {
|
|
285
|
+
console.log(setupHelp());
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
const tagExtract = extractTag(rest);
|
|
289
|
+
if (tagExtract.error) {
|
|
290
|
+
console.error(`parachute setup: ${tagExtract.error}`);
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
const noStart = tagExtract.rest.includes("--no-start");
|
|
294
|
+
const remaining = tagExtract.rest.filter((a) => a !== "--no-start");
|
|
295
|
+
if (remaining.length > 0) {
|
|
296
|
+
console.error(`parachute setup: unknown argument "${remaining[0]}"`);
|
|
297
|
+
console.error("usage: parachute setup [--tag <name>] [--no-start]");
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
const setupOpts: Parameters<typeof setup>[0] = {};
|
|
301
|
+
if (tagExtract.tag) setupOpts.tag = tagExtract.tag;
|
|
302
|
+
if (noStart) setupOpts.noStart = true;
|
|
303
|
+
return await setup(setupOpts);
|
|
304
|
+
}
|
|
305
|
+
|
|
195
306
|
case "install": {
|
|
196
307
|
if (isHelpFlag(rest[0])) {
|
|
197
308
|
console.log(installHelp());
|
|
@@ -253,12 +364,18 @@ async function main(argv: string[]): Promise<number> {
|
|
|
253
364
|
console.error(`parachute expose: ${hubExtract.error}`);
|
|
254
365
|
return 1;
|
|
255
366
|
}
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
258
|
-
console.error(`parachute expose: ${
|
|
367
|
+
const flagExtract = extractExposeProviderFlags(hubExtract.rest);
|
|
368
|
+
if (flagExtract.error) {
|
|
369
|
+
console.error(`parachute expose: ${flagExtract.error}`);
|
|
259
370
|
return 1;
|
|
260
371
|
}
|
|
261
|
-
|
|
372
|
+
if (flagExtract.cloudflare && flagExtract.tailnet) {
|
|
373
|
+
console.error(
|
|
374
|
+
"parachute expose: --tailnet and --cloudflare are mutually exclusive. Pick one.",
|
|
375
|
+
);
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
const exposeArgs = flagExtract.rest;
|
|
262
379
|
const layer = exposeArgs[0];
|
|
263
380
|
const mode = exposeArgs[1];
|
|
264
381
|
if (isHelpFlag(layer)) {
|
|
@@ -284,10 +401,17 @@ async function main(argv: string[]): Promise<number> {
|
|
|
284
401
|
}
|
|
285
402
|
const action = mode === "off" ? "off" : "up";
|
|
286
403
|
|
|
404
|
+
if (flagExtract.tailnet && layer !== "public") {
|
|
405
|
+
console.error(
|
|
406
|
+
"parachute expose: --tailnet pins the public layer to Tailscale Funnel; it doesn't apply to `expose tailnet`.",
|
|
407
|
+
);
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
|
410
|
+
|
|
287
411
|
// Cloudflare mode is a separate execution path — different detector,
|
|
288
412
|
// different state file, different process model (it spawns cloudflared
|
|
289
413
|
// rather than driving tailscale serve/funnel). Route to it early.
|
|
290
|
-
if (
|
|
414
|
+
if (flagExtract.cloudflare) {
|
|
291
415
|
if (layer !== "public") {
|
|
292
416
|
console.error(
|
|
293
417
|
"parachute expose: --cloudflare only applies to `public` (it's a public-internet path).",
|
|
@@ -297,10 +421,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
297
421
|
const { exposeCloudflareUp, exposeCloudflareOff } = await import(
|
|
298
422
|
"./commands/expose-cloudflare.ts"
|
|
299
423
|
);
|
|
424
|
+
const cfOpts = flagExtract.tunnelName ? { tunnelName: flagExtract.tunnelName } : {};
|
|
300
425
|
if (action === "off") {
|
|
301
|
-
return await exposeCloudflareOff();
|
|
426
|
+
return await exposeCloudflareOff(cfOpts);
|
|
302
427
|
}
|
|
303
|
-
if (!
|
|
428
|
+
if (!flagExtract.domain) {
|
|
304
429
|
// Partial flag promotion: the user told us they want Cloudflare but
|
|
305
430
|
// didn't supply a hostname. In a TTY, prompt only for what's
|
|
306
431
|
// missing instead of forcing them to retype the whole command. In a
|
|
@@ -322,21 +447,46 @@ async function main(argv: string[]): Promise<number> {
|
|
|
322
447
|
console.error(" parachute expose public");
|
|
323
448
|
return 1;
|
|
324
449
|
}
|
|
325
|
-
return await exposeCloudflareUp(
|
|
450
|
+
return await exposeCloudflareUp(flagExtract.domain, cfOpts);
|
|
326
451
|
}
|
|
327
452
|
|
|
328
453
|
const exposeOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
|
|
329
454
|
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
455
|
+
// `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
|
|
456
|
+
// interactive picker and the non-TTY auto-pick. Goes straight to
|
|
457
|
+
// exposePublic so today's Funnel flow keeps working unchanged.
|
|
458
|
+
if (layer === "public" && action === "up" && flagExtract.tailnet) {
|
|
459
|
+
return await exposePublic("up", exposeOpts);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Interactive picker: `parachute expose public` with no provider flags,
|
|
463
|
+
// running under a TTY on both stdin and stdout, routes through a guided
|
|
464
|
+
// flow that offers Tailscale vs. Cloudflare, walks provider setup, and
|
|
465
|
+
// hands back to the flag-driven entry points.
|
|
335
466
|
if (layer === "public" && action === "up" && isTtyInteractive()) {
|
|
336
467
|
const { exposePublicInteractive } = await import("./commands/expose-interactive.ts");
|
|
337
468
|
return await exposePublicInteractive({ exposeOpts });
|
|
338
469
|
}
|
|
339
470
|
|
|
471
|
+
// Non-TTY auto-pick: detect which provider is configured and run it.
|
|
472
|
+
// `--skip-provider-check` (CI escape hatch) skips detection and falls
|
|
473
|
+
// through to today's Tailscale-Funnel default — useful when the
|
|
474
|
+
// environment is already pre-flighted and the auto-pick would just
|
|
475
|
+
// print noise. Both paths run only on `expose public up`; tailnet
|
|
476
|
+
// exposure has only one provider so nothing to pick.
|
|
477
|
+
//
|
|
478
|
+
// `domain` and `tunnelName` are deliberately *not* threaded into
|
|
479
|
+
// auto-pick. Both are Cloudflare-only flags; if a user passes them
|
|
480
|
+
// without `--cloudflare`, threading would silently route them to
|
|
481
|
+
// Cloudflare. Better to drop the flags here and let auto-pick decide
|
|
482
|
+
// purely from what's installed — if it lands on cloudflare-only-ready,
|
|
483
|
+
// it prints the explicit `--cloudflare --domain` hint instead of
|
|
484
|
+
// guessing intent.
|
|
485
|
+
if (layer === "public" && action === "up" && !flagExtract.skipProviderCheck) {
|
|
486
|
+
const { exposePublicAutoPick } = await import("./commands/expose-public-auto.ts");
|
|
487
|
+
return await exposePublicAutoPick({ tailscaleOpts: exposeOpts });
|
|
488
|
+
}
|
|
489
|
+
|
|
340
490
|
// `expose public off` (no `--cloudflare`) auto-detects which provider is
|
|
341
491
|
// live. The explicit `--cloudflare` off branch above still wins — this
|
|
342
492
|
// path is only for users who typed plain `off` and don't want to
|
|
@@ -346,9 +496,15 @@ async function main(argv: string[]): Promise<number> {
|
|
|
346
496
|
return await runExposePublicOffAutoDetect({ tailscaleOffOpts: exposeOpts });
|
|
347
497
|
}
|
|
348
498
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
499
|
+
// `--skip-provider-check` fallthrough: pin to today's Tailscale-Funnel
|
|
500
|
+
// default for `expose public up`. Made explicit (rather than letting
|
|
501
|
+
// it tumble through the layer ternary) so the escape-hatch branch is
|
|
502
|
+
// visible at a glance.
|
|
503
|
+
if (layer === "public" && action === "up" && flagExtract.skipProviderCheck) {
|
|
504
|
+
return await exposePublic("up", exposeOpts);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return await exposeTailnet(action, exposeOpts);
|
|
352
508
|
}
|
|
353
509
|
|
|
354
510
|
case "start": {
|
|
@@ -381,6 +537,27 @@ async function main(argv: string[]): Promise<number> {
|
|
|
381
537
|
return await restart(rest[0]);
|
|
382
538
|
}
|
|
383
539
|
|
|
540
|
+
case "upgrade": {
|
|
541
|
+
if (isHelpFlag(rest[0])) {
|
|
542
|
+
console.log(upgradeHelp());
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
const tagExtract = extractTag(rest);
|
|
546
|
+
if (tagExtract.error) {
|
|
547
|
+
console.error(`parachute upgrade: ${tagExtract.error}`);
|
|
548
|
+
return 1;
|
|
549
|
+
}
|
|
550
|
+
const remaining = tagExtract.rest;
|
|
551
|
+
if (remaining.length > 1) {
|
|
552
|
+
console.error(`parachute upgrade: unexpected argument "${remaining[1]}"`);
|
|
553
|
+
console.error("usage: parachute upgrade [<service>] [--tag <name>]");
|
|
554
|
+
return 1;
|
|
555
|
+
}
|
|
556
|
+
const upgradeOpts: Parameters<typeof upgrade>[1] = {};
|
|
557
|
+
if (tagExtract.tag) upgradeOpts.tag = tagExtract.tag;
|
|
558
|
+
return await upgrade(remaining[0], upgradeOpts);
|
|
559
|
+
}
|
|
560
|
+
|
|
384
561
|
case "logs": {
|
|
385
562
|
if (isHelpFlag(rest[0])) {
|
|
386
563
|
console.log(logsHelp());
|
|
@@ -389,7 +566,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
389
566
|
const svc = rest[0];
|
|
390
567
|
if (!svc) {
|
|
391
568
|
console.error("usage: parachute logs <service> [-f]");
|
|
392
|
-
console.error(`services: ${knownServices().join(", ")}`);
|
|
569
|
+
console.error(`services: ${[HUB_SVC, ...knownServices()].join(", ")}`);
|
|
393
570
|
return 1;
|
|
394
571
|
}
|
|
395
572
|
const follow = rest.includes("-f") || rest.includes("--follow");
|
package/src/clients.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth client registry. Backs the `/oauth/register` endpoint (RFC 7591
|
|
3
|
+
* Dynamic Client Registration) and the client-lookup side of
|
|
4
|
+
* `/oauth/authorize` and `/oauth/token`.
|
|
5
|
+
*
|
|
6
|
+
* Two flavors:
|
|
7
|
+
* - **Public clients** (PKCE-only): no `client_secret`. Browser-side apps
|
|
8
|
+
* register themselves with one or more `redirect_uris` and rely on PKCE
|
|
9
|
+
* for the auth-code exchange. `client_secret_hash` is NULL for these.
|
|
10
|
+
* - **Confidential clients**: server-side apps. We mint a random
|
|
11
|
+
* `client_secret` on registration, store its sha256 hash, return the
|
|
12
|
+
* plaintext exactly once. The token endpoint enforces client_secret per
|
|
13
|
+
* RFC 6749 §3.2.1 (closes #72).
|
|
14
|
+
*
|
|
15
|
+
* Approval gate (closes #74): every row carries a `status` of `pending` or
|
|
16
|
+
* `approved`. New self-registrations default to `pending`; only registrations
|
|
17
|
+
* that authenticate with an operator token bearing `hub:admin` (the install-
|
|
18
|
+
* time path for first-party modules) land as `approved`. The OAuth flow
|
|
19
|
+
* rejects `pending` clients at `/oauth/authorize` and `/oauth/token`. An
|
|
20
|
+
* operator promotes a pending client via `parachute auth approve-client`.
|
|
21
|
+
*/
|
|
22
|
+
import type { Database } from "bun:sqlite";
|
|
23
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
24
|
+
|
|
25
|
+
export type ClientStatus = "pending" | "approved";
|
|
26
|
+
|
|
27
|
+
export interface OAuthClient {
|
|
28
|
+
clientId: string;
|
|
29
|
+
/** SHA-256 hex digest of the client secret. Null for public clients. */
|
|
30
|
+
clientSecretHash: string | null;
|
|
31
|
+
redirectUris: string[];
|
|
32
|
+
scopes: string[];
|
|
33
|
+
clientName: string | null;
|
|
34
|
+
registeredAt: string;
|
|
35
|
+
/** Whether the client may participate in OAuth flows. See file header. */
|
|
36
|
+
status: ClientStatus;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ClientNotFoundError extends Error {
|
|
40
|
+
constructor(clientId: string) {
|
|
41
|
+
super(`oauth client "${clientId}" is not registered`);
|
|
42
|
+
this.name = "ClientNotFoundError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class InvalidRedirectUriError extends Error {
|
|
47
|
+
constructor(uri: string) {
|
|
48
|
+
super(`redirect_uri "${uri}" is not registered for this client`);
|
|
49
|
+
this.name = "InvalidRedirectUriError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Row {
|
|
54
|
+
client_id: string;
|
|
55
|
+
client_secret_hash: string | null;
|
|
56
|
+
redirect_uris: string;
|
|
57
|
+
scopes: string;
|
|
58
|
+
client_name: string | null;
|
|
59
|
+
registered_at: string;
|
|
60
|
+
status: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rowToClient(r: Row): OAuthClient {
|
|
64
|
+
return {
|
|
65
|
+
clientId: r.client_id,
|
|
66
|
+
clientSecretHash: r.client_secret_hash,
|
|
67
|
+
redirectUris: JSON.parse(r.redirect_uris) as string[],
|
|
68
|
+
scopes: r.scopes.split(" ").filter((s) => s.length > 0),
|
|
69
|
+
clientName: r.client_name,
|
|
70
|
+
registeredAt: r.registered_at,
|
|
71
|
+
status: r.status === "approved" ? "approved" : "pending",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RegisterClientOpts {
|
|
76
|
+
redirectUris: string[];
|
|
77
|
+
scopes?: string[];
|
|
78
|
+
clientName?: string;
|
|
79
|
+
/** Defaults to public (PKCE-only). Set to true for a server-side client. */
|
|
80
|
+
confidential?: boolean;
|
|
81
|
+
/** Override the generated client_id. Mostly for tests + first-party seeds. */
|
|
82
|
+
clientId?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Approval status to write. Defaults to `approved` — direct callers
|
|
85
|
+
* (tests, install-time first-party seeds) want a row that can OAuth.
|
|
86
|
+
* The public DCR endpoint (`POST /oauth/register`) passes `pending`
|
|
87
|
+
* explicitly so self-served registrations require operator approval
|
|
88
|
+
* before they can run an OAuth flow (closes #74).
|
|
89
|
+
*/
|
|
90
|
+
status?: ClientStatus;
|
|
91
|
+
now?: () => Date;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RegisteredClient {
|
|
95
|
+
client: OAuthClient;
|
|
96
|
+
/** Plaintext secret for confidential clients. NOT recoverable from the DB. */
|
|
97
|
+
clientSecret: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function registerClient(db: Database, opts: RegisterClientOpts): RegisteredClient {
|
|
101
|
+
if (opts.redirectUris.length === 0) {
|
|
102
|
+
throw new Error("registerClient: at least one redirect_uri is required");
|
|
103
|
+
}
|
|
104
|
+
for (const uri of opts.redirectUris) {
|
|
105
|
+
if (!isValidRedirectUri(uri)) {
|
|
106
|
+
throw new Error(`registerClient: invalid redirect_uri "${uri}"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const clientId = opts.clientId ?? randomUUID();
|
|
110
|
+
const clientSecret = opts.confidential ? randomBytes(32).toString("base64url") : null;
|
|
111
|
+
const clientSecretHash = clientSecret
|
|
112
|
+
? createHash("sha256").update(clientSecret).digest("hex")
|
|
113
|
+
: null;
|
|
114
|
+
const registeredAt = (opts.now?.() ?? new Date()).toISOString();
|
|
115
|
+
const scopes = (opts.scopes ?? []).join(" ");
|
|
116
|
+
const status: ClientStatus = opts.status ?? "approved";
|
|
117
|
+
db.prepare(
|
|
118
|
+
`INSERT INTO clients
|
|
119
|
+
(client_id, client_secret_hash, redirect_uris, scopes, client_name, registered_at, status)
|
|
120
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
121
|
+
).run(
|
|
122
|
+
clientId,
|
|
123
|
+
clientSecretHash,
|
|
124
|
+
JSON.stringify(opts.redirectUris),
|
|
125
|
+
scopes,
|
|
126
|
+
opts.clientName ?? null,
|
|
127
|
+
registeredAt,
|
|
128
|
+
status,
|
|
129
|
+
);
|
|
130
|
+
return {
|
|
131
|
+
client: {
|
|
132
|
+
clientId,
|
|
133
|
+
clientSecretHash,
|
|
134
|
+
redirectUris: opts.redirectUris,
|
|
135
|
+
scopes: opts.scopes ?? [],
|
|
136
|
+
clientName: opts.clientName ?? null,
|
|
137
|
+
registeredAt,
|
|
138
|
+
status,
|
|
139
|
+
},
|
|
140
|
+
clientSecret,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Promote a `pending` client to `approved`. Idempotent — calling on an
|
|
146
|
+
* already-approved row is a no-op. Returns true when the row was found and
|
|
147
|
+
* is now approved (whether by this call or already), false when no such
|
|
148
|
+
* client exists. Used by `parachute auth approve-client`.
|
|
149
|
+
*/
|
|
150
|
+
export function approveClient(db: Database, clientId: string): boolean {
|
|
151
|
+
const existing = getClient(db, clientId);
|
|
152
|
+
if (!existing) return false;
|
|
153
|
+
if (existing.status === "approved") return true;
|
|
154
|
+
db.prepare("UPDATE clients SET status = 'approved' WHERE client_id = ?").run(clientId);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** List clients filtered by status. Used by `parachute auth pending-clients`. */
|
|
159
|
+
export function listClientsByStatus(db: Database, status: ClientStatus): OAuthClient[] {
|
|
160
|
+
const rows = db
|
|
161
|
+
.query<Row, [string]>("SELECT * FROM clients WHERE status = ? ORDER BY registered_at")
|
|
162
|
+
.all(status);
|
|
163
|
+
return rows.map(rowToClient);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getClient(db: Database, clientId: string): OAuthClient | null {
|
|
167
|
+
const row = db.query<Row, [string]>("SELECT * FROM clients WHERE client_id = ?").get(clientId);
|
|
168
|
+
return row ? rowToClient(row) : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Returns the registered redirect URI matching `candidate` exactly, or throws.
|
|
173
|
+
* RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no
|
|
174
|
+
* loose comparison) — anything looser is an open-redirect waiting to happen.
|
|
175
|
+
*/
|
|
176
|
+
export function requireRegisteredRedirectUri(client: OAuthClient, candidate: string): string {
|
|
177
|
+
if (!client.redirectUris.includes(candidate)) {
|
|
178
|
+
throw new InvalidRedirectUriError(candidate);
|
|
179
|
+
}
|
|
180
|
+
return candidate;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function verifyClientSecret(client: OAuthClient, presented: string): boolean {
|
|
184
|
+
if (!client.clientSecretHash) return false;
|
|
185
|
+
const presentedHash = createHash("sha256").update(presented).digest("hex");
|
|
186
|
+
return timingSafeEqualHex(client.clientSecretHash, presentedHash);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function timingSafeEqualHex(a: string, b: string): boolean {
|
|
190
|
+
if (a.length !== b.length) return false;
|
|
191
|
+
let diff = 0;
|
|
192
|
+
for (let i = 0; i < a.length; i++) {
|
|
193
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
194
|
+
}
|
|
195
|
+
return diff === 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Light validation — refuses obviously-wrong shapes (relative paths, javascript:
|
|
200
|
+
* URIs). Doesn't try to match a registered URI; that's `requireRegisteredRedirectUri`.
|
|
201
|
+
*/
|
|
202
|
+
export function isValidRedirectUri(uri: string): boolean {
|
|
203
|
+
try {
|
|
204
|
+
const u = new URL(uri);
|
|
205
|
+
if (u.protocol === "javascript:" || u.protocol === "data:") return false;
|
|
206
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/cloudflare/config.ts
CHANGED
|
@@ -3,8 +3,30 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { CONFIG_DIR } from "../config.ts";
|
|
4
4
|
|
|
5
5
|
export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
|
|
6
|
-
|
|
7
|
-
export const
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TUNNEL_NAME = "parachute";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-tunnel config + log file paths. Each tunnel gets its own subdirectory
|
|
11
|
+
* under `~/.parachute/cloudflared/<tunnelName>/` so multiple tunnels on one
|
|
12
|
+
* box don't trample each other's config.yml or interleave log lines.
|
|
13
|
+
*
|
|
14
|
+
* The default tunnel ("parachute") lives at
|
|
15
|
+
* `~/.parachute/cloudflared/parachute/{config.yml,cloudflared.log}` — a
|
|
16
|
+
* location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
|
|
17
|
+
* Re-running `parachute expose public --cloudflare` regenerates the file
|
|
18
|
+
* at the new path; the legacy file is left in place but unused.
|
|
19
|
+
*/
|
|
20
|
+
export function cloudflaredPathsFor(tunnelName: string): {
|
|
21
|
+
configPath: string;
|
|
22
|
+
logPath: string;
|
|
23
|
+
} {
|
|
24
|
+
const dir = join(CLOUDFLARED_DIR, tunnelName);
|
|
25
|
+
return {
|
|
26
|
+
configPath: join(dir, "config.yml"),
|
|
27
|
+
logPath: join(dir, "cloudflared.log"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
8
30
|
|
|
9
31
|
export interface TunnelConfigOpts {
|
|
10
32
|
tunnelUuid: string;
|
|
@@ -48,10 +70,7 @@ ingress:
|
|
|
48
70
|
`;
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
export function writeConfig(
|
|
52
|
-
opts: TunnelConfigOpts,
|
|
53
|
-
configPath: string = CLOUDFLARED_CONFIG_PATH,
|
|
54
|
-
): string {
|
|
73
|
+
export function writeConfig(opts: TunnelConfigOpts, configPath: string): string {
|
|
55
74
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
56
75
|
writeFileSync(configPath, renderConfig(opts));
|
|
57
76
|
return configPath;
|