@openparachute/hub 0.5.2 → 0.5.7
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-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +648 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +147 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
package/src/hub-server.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* /.well-known/jwks.json → JWKS from hub.db
|
|
17
17
|
* /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
|
|
18
18
|
* /oauth/authorize (GET + POST) → login → consent → auth code
|
|
19
|
+
* /oauth/authorize/approve (POST) → inline DCR approve form (#208)
|
|
19
20
|
* /oauth/token (POST) → authorization_code + refresh_token grants
|
|
20
21
|
* /oauth/register (POST) → RFC 7591 dynamic client registration
|
|
21
22
|
* anything else → 404
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
} from "./module-manifest.ts";
|
|
61
62
|
import {
|
|
62
63
|
authorizationServerMetadata,
|
|
64
|
+
handleApproveClientPost,
|
|
63
65
|
handleAuthorizeGet,
|
|
64
66
|
handleAuthorizePost,
|
|
65
67
|
handleRegister,
|
|
@@ -67,6 +69,11 @@ import {
|
|
|
67
69
|
handleToken,
|
|
68
70
|
} from "./oauth-handlers.ts";
|
|
69
71
|
import { clearPid, writePid } from "./process-state.ts";
|
|
72
|
+
import {
|
|
73
|
+
FIRST_PARTY_FALLBACKS,
|
|
74
|
+
effectivePublicExposure,
|
|
75
|
+
shortNameForManifest,
|
|
76
|
+
} from "./service-spec.ts";
|
|
70
77
|
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
71
78
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
72
79
|
import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
@@ -136,9 +143,16 @@ export function findVaultUpstream(
|
|
|
136
143
|
for (const s of services) {
|
|
137
144
|
if (!isVaultEntry(s)) continue;
|
|
138
145
|
for (const path of s.paths) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
// Normalize trailing slashes before comparison (#197). A services.json
|
|
147
|
+
// entry written with `paths: ["/vault/default/"]` would otherwise only
|
|
148
|
+
// match the exact pathname `/vault/default/` and never any sub-path,
|
|
149
|
+
// because `pathname.startsWith("/vault/default//")` is always false.
|
|
150
|
+
// The "|| '/'" branch keeps a bare-root mount "/" stable rather than
|
|
151
|
+
// collapsing it to an empty string.
|
|
152
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
153
|
+
if (pathname === norm || pathname.startsWith(`${norm}/`)) {
|
|
154
|
+
if (!best || norm.length > best.mount.length) {
|
|
155
|
+
best = { port: s.port, mount: norm, entry: s };
|
|
142
156
|
}
|
|
143
157
|
}
|
|
144
158
|
}
|
|
@@ -146,6 +160,65 @@ export function findVaultUpstream(
|
|
|
146
160
|
return best;
|
|
147
161
|
}
|
|
148
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
|
|
165
|
+
* every request reaches it via one of three trusted forwarders (or directly
|
|
166
|
+
* over loopback). The forwarder injects characteristic headers that we use to
|
|
167
|
+
* classify; nothing else can reach the listener, so spoofing isn't a concern.
|
|
168
|
+
*
|
|
169
|
+
* "loopback" — direct localhost call (CLI, on-box service, dev shell).
|
|
170
|
+
* "tailnet" — `tailscale serve` forwarding an authed tailnet user.
|
|
171
|
+
* "public" — `tailscale funnel` (public-over-tailnet, unauthed) OR a
|
|
172
|
+
* cloudflared tunnel forwarding from the public internet.
|
|
173
|
+
*
|
|
174
|
+
* Used to gate `publicExposure: "loopback"` services on the generic
|
|
175
|
+
* `/<svc>/*` dispatch (the hub's only layer-gate). Hub-owned paths (`/`,
|
|
176
|
+
* `/admin/*`, `/api/*`, `/hub/*`, `/oauth/*`, `/.well-known/*`, `/vault/*`,
|
|
177
|
+
* `/vaults`) reach all layers and rely on app-level auth (admin session
|
|
178
|
+
* cookie + 2FA, OAuth, per-service tokens) — they are NOT layer-blocked.
|
|
179
|
+
*/
|
|
180
|
+
export type RequestLayer = "loopback" | "tailnet" | "public";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Classify the trust layer for an incoming request by inspecting proxy
|
|
184
|
+
* headers. Order matters: cloudflared headers come first because cloudflared
|
|
185
|
+
* could in principle be deployed alongside tailscale on the same node.
|
|
186
|
+
*
|
|
187
|
+
* Header reference (verified against tailscale serve.go on 2026-05-08):
|
|
188
|
+
* - `Tailscale-User-Login` is set ONLY by `tailscale serve` for an authed
|
|
189
|
+
* tailnet user. Tagged-source nodes don't get it. Funnel never sets it.
|
|
190
|
+
* - `Tailscale-Funnel-Request: ?1` is set ONLY by Tailscale Funnel.
|
|
191
|
+
* Mutually exclusive with `Tailscale-User-Login` (the serve.go path
|
|
192
|
+
* returns early when funneled).
|
|
193
|
+
* - `CF-Ray` and `CF-Connecting-IP` are set by Cloudflare's edge for
|
|
194
|
+
* anything proxied through a cloudflared tunnel.
|
|
195
|
+
*
|
|
196
|
+
* Spoofing isn't a concern: hub binds `127.0.0.1:1939`, so external requests
|
|
197
|
+
* can't reach the listener except via these trusted forwarders. Tailscale
|
|
198
|
+
* specifically strips the same headers from incoming requests before
|
|
199
|
+
* re-injecting them, so even a malicious tailnet peer can't impersonate a
|
|
200
|
+
* different user. We could mirror that strip-on-arrival defense, but it's
|
|
201
|
+
* belt-and-braces given the bind shape.
|
|
202
|
+
*
|
|
203
|
+
* Default to "loopback" when no proxy headers are present — that's the
|
|
204
|
+
* direct-localhost case. Funnel without `Tailscale-Funnel-Request` would
|
|
205
|
+
* also fall here, but Tailscale always sets the header on funneled
|
|
206
|
+
* requests, so this branch only fires for true loopback callers.
|
|
207
|
+
*/
|
|
208
|
+
export function layerOf(req: Request): RequestLayer {
|
|
209
|
+
const h = req.headers;
|
|
210
|
+
if (h.get("cf-ray") !== null || h.get("cf-connecting-ip") !== null) return "public";
|
|
211
|
+
// Match the structured-header value (`?1`) rather than mere presence:
|
|
212
|
+
// serve.go only ever emits `?1`, so insisting on the canonical value keeps
|
|
213
|
+
// the classifier's intent obvious to a future reader (don't loosen this to
|
|
214
|
+
// `!== null` — Tailscale's contract is the value, not the header name).
|
|
215
|
+
// CF-Ray / CF-Connecting-IP are open-string identifiers with no canonical
|
|
216
|
+
// value to compare against, hence the presence-check above.
|
|
217
|
+
if (h.get("tailscale-funnel-request") === "?1") return "public";
|
|
218
|
+
if (h.get("tailscale-user-login") !== null) return "tailnet";
|
|
219
|
+
return "loopback";
|
|
220
|
+
}
|
|
221
|
+
|
|
149
222
|
/**
|
|
150
223
|
* Forward a request to a loopback service on `127.0.0.1:<port>`. By default
|
|
151
224
|
* the incoming pathname + query are preserved verbatim; pass `targetPath` to
|
|
@@ -229,7 +302,20 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
|
|
|
229
302
|
const url = new URL(req.url);
|
|
230
303
|
const match = findVaultUpstream(services, url.pathname);
|
|
231
304
|
if (!match) return undefined;
|
|
232
|
-
|
|
305
|
+
// Layer-gate on `publicExposure: "loopback"` — hide the entry from non-
|
|
306
|
+
// loopback callers as if it doesn't exist. "allowed" / "auth-required"
|
|
307
|
+
// pass through; the service does its own auth.
|
|
308
|
+
if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
|
|
309
|
+
return new Response("not found", { status: 404 });
|
|
310
|
+
}
|
|
311
|
+
// Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
|
|
312
|
+
// PARTY_FALLBACKS as a fallback source. No first-party vault fallback
|
|
313
|
+
// declares stripPrefix today (vault expects the full `/vault/<name>/*`
|
|
314
|
+
// path), so this is a no-op in practice — but reading the same shape in
|
|
315
|
+
// both proxies keeps the dispatch surface consistent for future readers.
|
|
316
|
+
const stripPrefix = stripPrefixFor(match.entry);
|
|
317
|
+
const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
|
|
318
|
+
return proxyRequest(req, match.port, "vault", targetPath);
|
|
233
319
|
}
|
|
234
320
|
|
|
235
321
|
/**
|
|
@@ -248,9 +334,18 @@ export function findServiceUpstream(
|
|
|
248
334
|
for (const s of services) {
|
|
249
335
|
if (isVaultEntry(s)) continue;
|
|
250
336
|
for (const path of s.paths) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
337
|
+
// Normalize trailing slashes before comparison (#197). A services.json
|
|
338
|
+
// entry written with `paths: ["/notes/"]` would otherwise only match
|
|
339
|
+
// the exact pathname `/notes/` and never `/notes/assets/index.js` —
|
|
340
|
+
// `pathname.startsWith("/notes//")` is always false because URLs
|
|
341
|
+
// don't have double slashes. Result: SPA shell loads but every asset
|
|
342
|
+
// 404s (notes blank-screen on Aaron's box, 2026-05-08).
|
|
343
|
+
// The "|| '/'" branch keeps a bare-root mount "/" stable rather than
|
|
344
|
+
// collapsing it to an empty string.
|
|
345
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
346
|
+
if (pathname === norm || pathname.startsWith(`${norm}/`)) {
|
|
347
|
+
if (!best || norm.length > best.mount.length) {
|
|
348
|
+
best = { port: s.port, mount: norm, entry: s };
|
|
254
349
|
}
|
|
255
350
|
}
|
|
256
351
|
}
|
|
@@ -290,12 +385,42 @@ async function proxyToService(req: Request, manifestPath: string): Promise<Respo
|
|
|
290
385
|
const url = new URL(req.url);
|
|
291
386
|
const match = findServiceUpstream(services, url.pathname);
|
|
292
387
|
if (!match) return undefined;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
388
|
+
// Layer-gate on `publicExposure: "loopback"`. From the perspective of a
|
|
389
|
+
// tailnet/public caller, a loopback-only service must be indistinguishable
|
|
390
|
+
// from "not installed" — 404, not 403, so we don't leak the existence of
|
|
391
|
+
// the route. "allowed" / "auth-required" pass through; the service does
|
|
392
|
+
// its own auth.
|
|
393
|
+
if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
|
|
394
|
+
return new Response("not found", { status: 404 });
|
|
395
|
+
}
|
|
396
|
+
// Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
|
|
397
|
+
// Scribe v0.4.0 doesn't write `stripPrefix: true` to its services.json
|
|
398
|
+
// entry — the declaration only lives in hub's SCRIBE_FALLBACK manifest.
|
|
399
|
+
// Pre-#187 this didn't matter because the per-service tailscale serve
|
|
400
|
+
// plan baked the path into the target URL; post-#187 routing went through
|
|
401
|
+
// hub which wasn't consulting the fallback registry. Same shape as how
|
|
402
|
+
// `effectivePublicExposure` already handles fallback derivation in
|
|
403
|
+
// service-spec.ts. Explicit-on-entry still wins; absent → fallback →
|
|
404
|
+
// false (preserving existing keep-prefix default for unknown services).
|
|
405
|
+
const stripPrefix = stripPrefixFor(match.entry);
|
|
406
|
+
const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
|
|
296
407
|
return proxyRequest(req, match.port, match.entry.name, targetPath);
|
|
297
408
|
}
|
|
298
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Resolve effective `stripPrefix` for a service entry. Explicit on-entry
|
|
412
|
+
* wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (so
|
|
413
|
+
* scribe's vendored fallback supplies `stripPrefix: true` even when scribe's
|
|
414
|
+
* own boot doesn't write it). Defaults to `false` — keep the prefix —
|
|
415
|
+
* matching the pre-#196 dispatch behavior for unknown / third-party services.
|
|
416
|
+
*/
|
|
417
|
+
function stripPrefixFor(entry: ServiceEntry): boolean {
|
|
418
|
+
if (entry.stripPrefix !== undefined) return entry.stripPrefix;
|
|
419
|
+
const short = shortNameForManifest(entry.name);
|
|
420
|
+
const fb = short !== undefined ? FIRST_PARTY_FALLBACKS[short] : undefined;
|
|
421
|
+
return fb?.manifest.stripPrefix ?? false;
|
|
422
|
+
}
|
|
423
|
+
|
|
299
424
|
export interface HubFetchDeps {
|
|
300
425
|
/**
|
|
301
426
|
* Lazily opens (or returns a cached handle to) the hub DB. Optional so
|
|
@@ -629,6 +754,18 @@ export function hubFetch(
|
|
|
629
754
|
return new Response("method not allowed", { status: 405 });
|
|
630
755
|
}
|
|
631
756
|
|
|
757
|
+
// Inline approve form for the operator-driven pending-client flow (#208).
|
|
758
|
+
// Receives `client_id` + `csrf_token` + `return_to` from the form rendered
|
|
759
|
+
// by handleAuthorizeGet when the operator hits a pending client. Three
|
|
760
|
+
// gates inside the handler: CSRF, active session, same-origin Origin.
|
|
761
|
+
if (pathname === "/oauth/authorize/approve") {
|
|
762
|
+
if (!getDb) {
|
|
763
|
+
return new Response("hub db not configured", { status: 503 });
|
|
764
|
+
}
|
|
765
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
766
|
+
return handleApproveClientPost(getDb(), req, oauthDeps(req));
|
|
767
|
+
}
|
|
768
|
+
|
|
632
769
|
if (pathname === "/oauth/token") {
|
|
633
770
|
if (!getDb) {
|
|
634
771
|
return new Response("hub db not configured", { status: 503 });
|
package/src/notes-serve.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { existsSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
28
29
|
import { dirname, join, resolve } from "node:path";
|
|
29
30
|
|
|
30
31
|
interface Args {
|
|
@@ -67,16 +68,76 @@ export function normalizeMount(raw: string): string {
|
|
|
67
68
|
return raw.replace(/\/+$/, "");
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Candidate base directories that `Bun.resolveSync` walks from when looking
|
|
73
|
+
* for `@openparachute/notes/package.json`. Order matters:
|
|
74
|
+
*
|
|
75
|
+
* 1. `process.cwd()` — works when notes-serve is invoked from inside the
|
|
76
|
+
* notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
|
|
77
|
+
* any project that depends on `@openparachute/notes`.
|
|
78
|
+
* 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
|
|
79
|
+
* layout. This is where `bun add -g @openparachute/notes` lands the
|
|
80
|
+
* package, and where `bun link @openparachute/notes` symlinks it.
|
|
81
|
+
* 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
|
|
82
|
+
*
|
|
83
|
+
* Hub itself does NOT depend on `@openparachute/notes`, so when
|
|
84
|
+
* `parachute start notes` is run from the hub repo dir, the cwd-relative
|
|
85
|
+
* resolve walks ancestral node_modules and finds nothing. Bun does not
|
|
86
|
+
* auto-consult the global install dir, so bun-linked installs fail to
|
|
87
|
+
* resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
|
|
88
|
+
* `/notes/` because of this — fixed by trying the global install dirs.
|
|
89
|
+
*
|
|
90
|
+
* Exported (and parameterized via `cwd`/`home`) so tests can drive the
|
|
91
|
+
* resolution order against a real fixture install without monkey-patching
|
|
92
|
+
* `Bun.resolveSync`.
|
|
93
|
+
*/
|
|
94
|
+
export function notesDistCandidates(cwd: string, home: string): string[] {
|
|
95
|
+
return [cwd, join(home, ".bun/install/global/node_modules"), join(home, ".bun/install/global")];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ResolveNotesDistDeps {
|
|
99
|
+
cwd?: string;
|
|
100
|
+
home?: string;
|
|
101
|
+
/** Override `Bun.resolveSync` for tests. */
|
|
102
|
+
resolveSync?: (specifier: string, base: string) => string;
|
|
103
|
+
existsSync?: (path: string) => boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
|
|
107
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
108
|
+
const home = deps.home ?? homedir();
|
|
109
|
+
const resolveSync = deps.resolveSync ?? Bun.resolveSync;
|
|
110
|
+
const exists = deps.existsSync ?? existsSync;
|
|
111
|
+
const candidates = notesDistCandidates(cwd, home);
|
|
112
|
+
const resolveErrors: string[] = [];
|
|
113
|
+
for (const base of candidates) {
|
|
114
|
+
let pkgPath: string;
|
|
115
|
+
try {
|
|
116
|
+
pkgPath = resolveSync("@openparachute/notes/package.json", base);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const root = dirname(pkgPath);
|
|
122
|
+
const dist = join(root, "dist");
|
|
123
|
+
if (!exists(dist)) {
|
|
124
|
+
// Found the package but it has no dist/. This is a hard error
|
|
125
|
+
// (package shipped without a prebuilt bundle); don't fall through to
|
|
126
|
+
// other candidates — they'd resolve to the same package and report
|
|
127
|
+
// the same problem.
|
|
128
|
+
throw new Error(
|
|
129
|
+
`@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return dist;
|
|
78
133
|
}
|
|
79
|
-
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveNotesDist(): string {
|
|
140
|
+
return resolveNotesDistFrom();
|
|
80
141
|
}
|
|
81
142
|
|
|
82
143
|
function mimeFor(path: string): string | undefined {
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
|
|
9
9
|
* - GET /oauth/authorize (login → consent → code)
|
|
10
10
|
* - POST /oauth/authorize (form posts: login + consent)
|
|
11
|
+
* - POST /oauth/authorize/approve (operator-driven inline DCR approval, #208)
|
|
11
12
|
* - POST /oauth/token (grant_type=authorization_code | refresh_token)
|
|
12
13
|
* - POST /oauth/register (RFC 7591 DCR)
|
|
13
14
|
* - POST /oauth/revoke (RFC 7009 token revocation)
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
type ClientStatus,
|
|
36
37
|
type OAuthClient,
|
|
37
38
|
type RegisteredClient,
|
|
39
|
+
approveClient,
|
|
38
40
|
getClient,
|
|
39
41
|
isValidRedirectUri,
|
|
40
42
|
registerClient,
|
|
@@ -53,7 +55,13 @@ import {
|
|
|
53
55
|
signAccessToken,
|
|
54
56
|
signRefreshToken,
|
|
55
57
|
} from "./jwt-sign.ts";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
type AuthorizeFormParams,
|
|
60
|
+
renderApprovePending,
|
|
61
|
+
renderConsent,
|
|
62
|
+
renderError,
|
|
63
|
+
renderLogin,
|
|
64
|
+
} from "./oauth-ui.ts";
|
|
57
65
|
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
58
66
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
59
67
|
import {
|
|
@@ -64,6 +72,7 @@ import {
|
|
|
64
72
|
SESSION_TTL_MS,
|
|
65
73
|
buildSessionCookie,
|
|
66
74
|
createSession,
|
|
75
|
+
findActiveSession,
|
|
67
76
|
findSession,
|
|
68
77
|
parseSessionCookie,
|
|
69
78
|
} from "./sessions.ts";
|
|
@@ -291,12 +300,63 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
|
|
|
291
300
|
};
|
|
292
301
|
}
|
|
293
302
|
|
|
294
|
-
/**
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
/**
|
|
304
|
+
* "App not yet approved" page (#74) for /oauth/authorize. When the request
|
|
305
|
+
* carries a valid operator session AND a same-origin Origin/Referer, render
|
|
306
|
+
* the inline approve form (#208) so one click flips the client to `approved`
|
|
307
|
+
* and the OAuth flow re-enters at consent. Otherwise fall back to the
|
|
308
|
+
* pre-#208 CLI-only message ("ask operator to run `parachute auth
|
|
309
|
+
* approve-client <id>`").
|
|
310
|
+
*
|
|
311
|
+
* The session-bound approve gate mirrors the same-origin DCR auto-approve
|
|
312
|
+
* gate on `/oauth/register` (#199, #200): valid session cookie + matching
|
|
313
|
+
* Origin/Referer = trusted operator action. Cross-origin or session-less
|
|
314
|
+
* GETs see the CLI-fallback message; the button never renders for them, so
|
|
315
|
+
* the POST handler can't be tricked into approving via a hand-crafted form
|
|
316
|
+
* either (CSRF token won't match).
|
|
317
|
+
*
|
|
318
|
+
* The form's `return_to` carries the original `/oauth/authorize?...` URL so
|
|
319
|
+
* the post-approve redirect lands the operator back on the same flow with
|
|
320
|
+
* the now-approved client. The POST handler validates `return_to` is a
|
|
321
|
+
* hub-relative path before following it (open-redirect defense).
|
|
322
|
+
*/
|
|
323
|
+
function pendingClientResponse(
|
|
324
|
+
db: Database,
|
|
325
|
+
req: Request,
|
|
326
|
+
client: OAuthClient,
|
|
327
|
+
authorizeUrl: URL,
|
|
328
|
+
deps: OAuthDeps,
|
|
329
|
+
): Response {
|
|
330
|
+
const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
|
|
331
|
+
.split(" ")
|
|
332
|
+
.filter((s) => s.length > 0);
|
|
333
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
334
|
+
const sameOrigin = originMatchesIssuer(req, deps.issuer);
|
|
335
|
+
const csrf = ensureCsrfToken(req);
|
|
336
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
337
|
+
if (session && sameOrigin) {
|
|
338
|
+
const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
|
|
339
|
+
return htmlResponse(
|
|
340
|
+
renderApprovePending({
|
|
341
|
+
clientName: client.clientName ?? client.clientId,
|
|
342
|
+
clientId: client.clientId,
|
|
343
|
+
redirectUris: client.redirectUris,
|
|
344
|
+
requestedScopes,
|
|
345
|
+
approveForm: { csrfToken: csrf.token, returnTo },
|
|
346
|
+
}),
|
|
347
|
+
403,
|
|
348
|
+
extra,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return htmlResponse(
|
|
352
|
+
renderApprovePending({
|
|
353
|
+
clientName: client.clientName ?? client.clientId,
|
|
354
|
+
clientId: client.clientId,
|
|
355
|
+
redirectUris: client.redirectUris,
|
|
356
|
+
requestedScopes,
|
|
357
|
+
}),
|
|
299
358
|
403,
|
|
359
|
+
extra,
|
|
300
360
|
);
|
|
301
361
|
}
|
|
302
362
|
|
|
@@ -345,7 +405,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
345
405
|
// matched it against a registered client. Render an HTML error.
|
|
346
406
|
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
347
407
|
}
|
|
348
|
-
if (client.status !== "approved")
|
|
408
|
+
if (client.status !== "approved") {
|
|
409
|
+
return pendingClientResponse(db, req, client, url, deps);
|
|
410
|
+
}
|
|
349
411
|
try {
|
|
350
412
|
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
351
413
|
} catch {
|
|
@@ -536,7 +598,18 @@ async function handleConsentSubmit(
|
|
|
536
598
|
if (!client) {
|
|
537
599
|
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
538
600
|
}
|
|
539
|
-
if (client.status !== "approved")
|
|
601
|
+
if (client.status !== "approved") {
|
|
602
|
+
// Defensive: consent only renders for approved clients, so a non-approved
|
|
603
|
+
// status here means the row was unapproved between render and submit (or
|
|
604
|
+
// the form was hand-crafted). The approve UI requires a known authorize
|
|
605
|
+
// URL to round-trip via `return_to`, which we don't reconstruct here —
|
|
606
|
+
// surface the static error and let the operator restart from the SPA.
|
|
607
|
+
return htmlError(
|
|
608
|
+
"App not yet approved",
|
|
609
|
+
`This client_id is registered but has not been approved. Run \`parachute auth approve-client ${client.clientId}\` from a terminal, then try again.`,
|
|
610
|
+
403,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
540
613
|
try {
|
|
541
614
|
requireRegisteredRedirectUri(client, params.redirectUri);
|
|
542
615
|
} catch {
|
|
@@ -602,6 +675,104 @@ async function handleConsentSubmit(
|
|
|
602
675
|
return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
|
|
603
676
|
}
|
|
604
677
|
|
|
678
|
+
/**
|
|
679
|
+
* POST /oauth/authorize/approve — operator-driven inline approval of a
|
|
680
|
+
* pending DCR client (closes #208). The cross-origin SPA case the
|
|
681
|
+
* same-origin DCR auto-approve (#199, #200) doesn't cover: an SPA on a
|
|
682
|
+
* different origin can't ride the cookie path during DCR, so its
|
|
683
|
+
* freshly-registered client_id lands `pending` and the operator hits
|
|
684
|
+
* "App not yet approved" on /oauth/authorize. This endpoint flips that
|
|
685
|
+
* client to `approved` in one click and redirects back into the OAuth flow.
|
|
686
|
+
*
|
|
687
|
+
* Three-belt security model. All three must pass:
|
|
688
|
+
*
|
|
689
|
+
* 1. Valid CSRF token (double-submit cookie). Defends against a malicious
|
|
690
|
+
* cross-origin POST that rides the session cookie's SameSite=Lax.
|
|
691
|
+
* Token was minted at GET render time and embedded in the form.
|
|
692
|
+
* 2. Active operator session (`findActiveSession`). The operator must be
|
|
693
|
+
* logged into this hub from the browser submitting the form — no
|
|
694
|
+
* session means no operator authority to approve anything.
|
|
695
|
+
* 3. Origin/Referer matches the issuer (`originMatchesIssuer`). Same
|
|
696
|
+
* shape as the DCR auto-approve gate (#199, #200): a same-origin POST
|
|
697
|
+
* proves the form was rendered by *this hub*, not a forged page.
|
|
698
|
+
*
|
|
699
|
+
* `return_to` validation: the form embeds the original authorize URL so
|
|
700
|
+
* the post-approve redirect lands the operator back on `/oauth/authorize`
|
|
701
|
+
* with the now-approved client. We refuse anything that doesn't start with
|
|
702
|
+
* `/oauth/authorize?` — open-redirect defense, plus a hand-crafted form
|
|
703
|
+
* trying to use this endpoint as a generic redirect-after-approve gadget
|
|
704
|
+
* shouldn't succeed at smuggling an off-path target.
|
|
705
|
+
*/
|
|
706
|
+
export async function handleApproveClientPost(
|
|
707
|
+
db: Database,
|
|
708
|
+
req: Request,
|
|
709
|
+
deps: OAuthDeps,
|
|
710
|
+
): Promise<Response> {
|
|
711
|
+
const form = await req.formData();
|
|
712
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
713
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
714
|
+
return htmlError(
|
|
715
|
+
"Invalid form submission",
|
|
716
|
+
"The form's CSRF token did not match. Reload the page and try again.",
|
|
717
|
+
403,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
721
|
+
if (!session) {
|
|
722
|
+
return htmlError(
|
|
723
|
+
"Sign in required",
|
|
724
|
+
"You must be signed in to this hub to approve an app. Sign in and try again.",
|
|
725
|
+
401,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
if (!originMatchesIssuer(req, deps.issuer)) {
|
|
729
|
+
return htmlError(
|
|
730
|
+
"Cross-origin request rejected",
|
|
731
|
+
"The approve form must be submitted from this hub's own origin.",
|
|
732
|
+
403,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const clientId = String(form.get("client_id") ?? "");
|
|
736
|
+
if (!clientId) {
|
|
737
|
+
return htmlError("Invalid form submission", "Missing client_id.", 400);
|
|
738
|
+
}
|
|
739
|
+
const client = getClient(db, clientId);
|
|
740
|
+
if (!client) {
|
|
741
|
+
return htmlError("Unknown application", "This client_id is not registered with this hub.", 404);
|
|
742
|
+
}
|
|
743
|
+
// Validate return_to BEFORE the DB mutation: if an authenticated operator
|
|
744
|
+
// submits a hand-crafted form with a bad return_to, we refuse without
|
|
745
|
+
// committing the client to `approved`. Practical risk is low (all three
|
|
746
|
+
// belts already passed), but ordering matters — validate, then mutate.
|
|
747
|
+
const returnTo = String(form.get("return_to") ?? "");
|
|
748
|
+
if (!isSafeAuthorizeReturnTo(returnTo)) {
|
|
749
|
+
return htmlError(
|
|
750
|
+
"Invalid form submission",
|
|
751
|
+
"The return_to value is not a hub-relative /oauth/authorize URL.",
|
|
752
|
+
400,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
approveClient(db, clientId);
|
|
756
|
+
return redirectResponse(returnTo);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Validate a form-submitted `return_to` value. Must be a hub-relative URL
|
|
761
|
+
* (no scheme, no double-slash) targeting `/oauth/authorize` with a query
|
|
762
|
+
* string — anything else is either an open-redirect attempt or a misuse of
|
|
763
|
+
* the endpoint. Empty string is rejected (the form always supplies one).
|
|
764
|
+
*/
|
|
765
|
+
function isSafeAuthorizeReturnTo(value: string): boolean {
|
|
766
|
+
if (!value) return false;
|
|
767
|
+
// Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
|
|
768
|
+
// single-slash root-relative paths are allowed.
|
|
769
|
+
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
|
770
|
+
// Must target the authorize endpoint with a query string. The OAuth flow
|
|
771
|
+
// re-enters via GET /oauth/authorize?<original-params>; anything off-path
|
|
772
|
+
// is a misuse.
|
|
773
|
+
return value.startsWith("/oauth/authorize?");
|
|
774
|
+
}
|
|
775
|
+
|
|
605
776
|
function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
|
|
606
777
|
return {
|
|
607
778
|
clientId: String(form.get("client_id") ?? ""),
|
|
@@ -1064,20 +1235,72 @@ interface RegisterRequestBody {
|
|
|
1064
1235
|
token_endpoint_auth_method?: string;
|
|
1065
1236
|
}
|
|
1066
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* CSRF defense for the cookie-based DCR auto-approve path (closes #199).
|
|
1240
|
+
*
|
|
1241
|
+
* Compares the request's `Origin` (or `Referer` as fallback) against the
|
|
1242
|
+
* configured issuer origin. URL.origin compares scheme + host + port —
|
|
1243
|
+
* port-only mismatches reject. A request with neither header is treated as
|
|
1244
|
+
* suspicious and rejected: cookie-bearing POSTs from same-origin browsers
|
|
1245
|
+
* always send Origin (per Fetch standard) and almost always send Referer,
|
|
1246
|
+
* so a header-stripped request is more likely a curl probe or a privacy
|
|
1247
|
+
* extension on a third-party site than a legitimate same-origin caller.
|
|
1248
|
+
*
|
|
1249
|
+
* SameSite=Lax on the session cookie (sessions.ts:buildSessionCookie) is the
|
|
1250
|
+
* browser-side defense layer; this function is the server-side belt.
|
|
1251
|
+
*/
|
|
1252
|
+
function originMatchesIssuer(req: Request, issuer: string): boolean {
|
|
1253
|
+
const origin = req.headers.get("origin");
|
|
1254
|
+
if (origin) {
|
|
1255
|
+
try {
|
|
1256
|
+
return new URL(origin).origin === new URL(issuer).origin;
|
|
1257
|
+
} catch {
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const referer = req.headers.get("referer");
|
|
1262
|
+
if (referer) {
|
|
1263
|
+
try {
|
|
1264
|
+
return new URL(referer).origin === new URL(issuer).origin;
|
|
1265
|
+
} catch {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1067
1272
|
/**
|
|
1068
1273
|
* POST /oauth/register — RFC 7591 Dynamic Client Registration.
|
|
1069
1274
|
*
|
|
1070
1275
|
* Approval gate (closes #74). New rows land as `pending` by default and
|
|
1071
1276
|
* cannot participate in OAuth flows until an operator runs
|
|
1072
|
-
* `parachute auth approve-client <id>`.
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1277
|
+
* `parachute auth approve-client <id>`. Two bypass paths:
|
|
1278
|
+
*
|
|
1279
|
+
* 1. **Operator-bearer** (#74). `Authorization: Bearer <operator-token>` whose
|
|
1280
|
+
* token carries the `hub:admin` scope — the install-time path used by
|
|
1281
|
+
* first-party modules so `parachute install vault` can self-register
|
|
1282
|
+
* without a human follow-up.
|
|
1283
|
+
* 2. **Operator-session** (#199). A valid `parachute_hub_session` cookie
|
|
1284
|
+
* plus a same-origin `Origin`/`Referer` header. The browser path: an
|
|
1285
|
+
* operator hitting their own SPA from their own browser is by definition
|
|
1286
|
+
* operator-authenticated, so re-requiring approval is friction without
|
|
1287
|
+
* benefit. CSRF defense is `originMatchesIssuer` + the cookie's
|
|
1288
|
+
* `SameSite=Lax` attribute.
|
|
1076
1289
|
*
|
|
1077
1290
|
* If a bearer is presented but invalid or insufficient, we reject with the
|
|
1078
1291
|
* RFC 6750 shape rather than silently downgrading to the public path: a
|
|
1079
1292
|
* caller who tried to authenticate but failed wants to know why, not get
|
|
1080
1293
|
* `pending` back and wonder why their module can't OAuth.
|
|
1294
|
+
*
|
|
1295
|
+
* Access-control matrix:
|
|
1296
|
+
* no auth → pending
|
|
1297
|
+
* bearer (hub:admin) → approved (#74)
|
|
1298
|
+
* bearer (other scope) → 403 insufficient_scope
|
|
1299
|
+
* bearer (malformed) → 401 invalid_token
|
|
1300
|
+
* session cookie + same-origin → approved (#199)
|
|
1301
|
+
* session cookie + cross-origin → pending (CSRF defense)
|
|
1302
|
+
* session cookie + no Origin/Referer → pending
|
|
1303
|
+
* expired/unknown session → pending
|
|
1081
1304
|
*/
|
|
1082
1305
|
export async function handleRegister(
|
|
1083
1306
|
db: Database,
|
|
@@ -1124,6 +1347,20 @@ export async function handleRegister(
|
|
|
1124
1347
|
throw err;
|
|
1125
1348
|
}
|
|
1126
1349
|
}
|
|
1350
|
+
// Operator-session auto-approve (closes #199). The browser path:
|
|
1351
|
+
// operator-authenticated SPA on the hub's own origin can self-register a
|
|
1352
|
+
// client without dropping to a terminal. Two gates: (1) a live (un-expired)
|
|
1353
|
+
// session row keyed by the cookie, (2) Origin/Referer matches the issuer
|
|
1354
|
+
// origin so a cross-site forgery can't ride the cookie. Quietly stays
|
|
1355
|
+
// `pending` on any failure — unlike the bearer path, we don't surface an
|
|
1356
|
+
// error, because absence of session/origin is the *normal* unauthenticated
|
|
1357
|
+
// public-DCR shape.
|
|
1358
|
+
if (status === "pending") {
|
|
1359
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
1360
|
+
if (session && originMatchesIssuer(req, deps.issuer)) {
|
|
1361
|
+
status = "approved";
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1127
1364
|
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
1128
1365
|
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
1129
1366
|
let registered: RegisteredClient;
|