@openparachute/hub 0.5.7 → 0.5.9-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +338 -65
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +266 -5
  20. package/src/__tests__/operator-token.test.ts +379 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/status.test.ts +199 -0
  23. package/src/__tests__/well-known.test.ts +69 -0
  24. package/src/admin-clients.ts +139 -0
  25. package/src/admin-handlers.ts +32 -254
  26. package/src/admin-host-admin-token.ts +25 -10
  27. package/src/admin-login-ui.ts +256 -0
  28. package/src/admin-vault-admin-token.ts +1 -1
  29. package/src/api-me.ts +124 -0
  30. package/src/api-mint-token.ts +239 -0
  31. package/src/api-revocation-list.ts +59 -0
  32. package/src/api-revoke-token.ts +153 -0
  33. package/src/api-tokens.ts +224 -0
  34. package/src/commands/auth.ts +408 -51
  35. package/src/commands/expose-2fa-warning.ts +6 -6
  36. package/src/commands/status.ts +74 -10
  37. package/src/csrf.ts +6 -3
  38. package/src/help.ts +10 -4
  39. package/src/hub-db.ts +63 -0
  40. package/src/hub-server.ts +426 -97
  41. package/src/hub.ts +272 -149
  42. package/src/install-source.ts +291 -0
  43. package/src/jwt-sign.ts +265 -5
  44. package/src/module-manifest.ts +48 -10
  45. package/src/oauth-handlers.ts +183 -54
  46. package/src/oauth-ui.ts +23 -2
  47. package/src/operator-token.ts +272 -18
  48. package/src/origin-check.ts +127 -0
  49. package/src/rate-limit.ts +5 -2
  50. package/src/scope-explanations.ts +33 -2
  51. package/src/sessions.ts +1 -1
  52. package/src/well-known.ts +54 -1
  53. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  54. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  55. package/web/ui/dist/index.html +2 -2
  56. package/src/__tests__/admin-config.test.ts +0 -281
  57. package/src/admin-config-ui.ts +0 -534
  58. package/src/admin-config.ts +0 -226
  59. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  60. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -12,30 +12,116 @@
12
12
  * Browser apps follow the OAuth flow and never touch this file. Service
13
13
  * accounts (cron jobs, oncall scripts) read it; that's the whole point.
14
14
  *
15
- * Rotation: cheap. `parachute auth rotate-operator` mints a fresh token
16
- * and overwrites the file. The previous token is *not* revoked at the
17
- * issuer the hub doesn't track operator-token jtis so a leaked file
18
- * stays valid until its 1-year TTL elapses. Treat operator.token like an
19
- * SSH private key.
15
+ * Lifetime: 90 days by default (was 365d through 0.5.7). The opportunistic
16
+ * auto-rotation helper `useOperatorTokenWithAutoRotate` re-mints any
17
+ * within-7d-of-expiry token in-place, so an operator who runs the CLI at
18
+ * least weekly never sees an expiry surprise. Fully expired tokens fail
19
+ * with an explicit re-auth message — auto-rotating from a dead token would
20
+ * defeat the lifetime cap (security: forces a manual re-auth touch).
21
+ *
22
+ * Operator-token jtis are tracked in the hub `tokens` registry as of
23
+ * hub#212 Phase 1 (created_via='operator_mint'); per-jti revocation is
24
+ * enforced via validateAccessToken's row.revokedAt check. A leaked file
25
+ * still stays valid until either its TTL elapses or the operator
26
+ * explicitly revokes the jti — treat operator.token like an SSH private
27
+ * key.
20
28
  */
21
29
  import type { Database } from "bun:sqlite";
22
30
  import { promises as fs } from "node:fs";
23
31
  import { join } from "node:path";
24
32
  import { configDir } from "./config.ts";
25
- import { signAccessToken } from "./jwt-sign.ts";
33
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
26
34
 
27
35
  export const OPERATOR_TOKEN_FILENAME = "operator.token";
28
- export const OPERATOR_TOKEN_TTL_SECONDS = 365 * 24 * 60 * 60;
36
+ /** Default operator-token lifetime 90 days, was 365d through 0.5.7 (#213). */
37
+ export const OPERATOR_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
38
+ /**
39
+ * Auto-rotation threshold. When a CLI flow validates an operator token whose
40
+ * remaining lifetime is less than this, it silently re-mints with the same
41
+ * scope-set + a fresh full TTL. 7 days picked so a once-a-week operator
42
+ * never sees expiry; longer would let stale tokens accumulate, shorter
43
+ * would re-mint too often.
44
+ */
45
+ export const OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
29
46
  export const OPERATOR_TOKEN_AUDIENCE = "operator";
30
47
  export const OPERATOR_TOKEN_CLIENT_ID = "parachute-hub";
31
- export const OPERATOR_TOKEN_SCOPES = [
32
- "hub:admin",
33
- "parachute:host:admin",
34
- "vault:admin",
35
- "scribe:admin",
36
- "channel:send",
48
+
49
+ /**
50
+ * Named scope-sets a `parachute auth rotate-operator` invocation can choose
51
+ * via `--scope-set`. Each set encodes the minimum scopes required for that
52
+ * operator-flow's API surface; `admin` is the back-compat superset and
53
+ * stays the default.
54
+ *
55
+ * Per-command gating rolls out incrementally:
56
+ * - Auth surfaces (hub#221 `revoke-token`, hub#222 `mint-token`) gate on
57
+ * `parachute:host:auth` — both `admin` and `auth` scope-sets carry it.
58
+ * - Other CLI commands (`install`, `start`, `expose`, etc.) still accept
59
+ * any token with `hub:admin` and don't yet check the narrower scopes;
60
+ * a future follow-up wires per-command enforcement so a `start`-set
61
+ * token can only lifecycle-manage, not install. Until then,
62
+ * `--scope-set` is a tool the cautious operator can opt into without
63
+ * breaking anyone.
64
+ *
65
+ * The fine-grained `parachute:host:install/start/expose/auth/vault` scopes
66
+ * are operator-only (non-requestable via public OAuth), like
67
+ * `parachute:host:admin` — registered in `scope-explanations.ts`.
68
+ */
69
+ export type OperatorScopeSet = "install" | "start" | "expose" | "auth" | "vault" | "admin";
70
+
71
+ export const OPERATOR_TOKEN_SCOPE_SET_NAMES: readonly OperatorScopeSet[] = [
72
+ "install",
73
+ "start",
74
+ "expose",
75
+ "auth",
76
+ "vault",
77
+ "admin",
37
78
  ];
38
79
 
80
+ /**
81
+ * Scopes embedded for each named set. `admin` preserves the pre-#213
82
+ * `OPERATOR_TOKEN_SCOPES` set verbatim plus the new fine-grained host
83
+ * scopes (which `admin` is a superset of by definition).
84
+ */
85
+ export const OPERATOR_TOKEN_SCOPE_SETS: Readonly<Record<OperatorScopeSet, readonly string[]>> = {
86
+ install: ["parachute:host:install", "vault:read"],
87
+ start: ["parachute:host:start"],
88
+ expose: ["parachute:host:expose"],
89
+ auth: ["parachute:host:auth"],
90
+ vault: ["parachute:host:vault"],
91
+ admin: [
92
+ "hub:admin",
93
+ "parachute:host:admin",
94
+ "parachute:host:install",
95
+ "parachute:host:start",
96
+ "parachute:host:expose",
97
+ "parachute:host:auth",
98
+ "parachute:host:vault",
99
+ "vault:admin",
100
+ "scribe:admin",
101
+ "channel:send",
102
+ ],
103
+ };
104
+
105
+ /**
106
+ * Pre-#213 export: the broad "admin" scope-set as a flat array. Kept for
107
+ * back-compat with callers (e.g. existing tests) that imported the constant
108
+ * directly. New callers should use `OPERATOR_TOKEN_SCOPE_SETS.admin`.
109
+ */
110
+ export const OPERATOR_TOKEN_SCOPES = OPERATOR_TOKEN_SCOPE_SETS.admin;
111
+
112
+ /** Custom JWT claim that records which scope-set this operator token was minted under. */
113
+ export const OPERATOR_TOKEN_SCOPE_SET_CLAIM = "pa_scope_set";
114
+
115
+ /** Default scope-set when none is specified. Preserves pre-#213 behavior. */
116
+ export const OPERATOR_TOKEN_DEFAULT_SCOPE_SET: OperatorScopeSet = "admin";
117
+
118
+ export function isOperatorScopeSet(value: unknown): value is OperatorScopeSet {
119
+ return (
120
+ typeof value === "string" &&
121
+ (OPERATOR_TOKEN_SCOPE_SET_NAMES as readonly string[]).includes(value)
122
+ );
123
+ }
124
+
39
125
  export function operatorTokenPath(dir: string = configDir()): string {
40
126
  return join(dir, OPERATOR_TOKEN_FILENAME);
41
127
  }
@@ -54,23 +140,47 @@ export interface MintOperatorTokenOpts {
54
140
  jti?: string;
55
141
  /** Override the audience claim. Defaults to "operator". */
56
142
  audience?: string;
143
+ /** Which named scope-set to mint under. Defaults to "admin" (pre-#213 behavior). */
144
+ scopeSet?: OperatorScopeSet;
145
+ /** Override the lifetime. Tests pin this; production uses the default. */
146
+ ttlSeconds?: number;
57
147
  }
58
148
 
59
149
  export async function mintOperatorToken(
60
150
  db: Database,
61
151
  userId: string,
62
152
  opts: MintOperatorTokenOpts,
63
- ): Promise<{ token: string; jti: string; expiresAt: string }> {
64
- return signAccessToken(db, {
153
+ ): Promise<{ token: string; jti: string; expiresAt: string; scopeSet: OperatorScopeSet }> {
154
+ const scopeSet = opts.scopeSet ?? OPERATOR_TOKEN_DEFAULT_SCOPE_SET;
155
+ const scopes = [...OPERATOR_TOKEN_SCOPE_SETS[scopeSet]];
156
+ const minted = await signAccessToken(db, {
65
157
  sub: userId,
66
- scopes: OPERATOR_TOKEN_SCOPES,
158
+ scopes,
67
159
  audience: opts.audience ?? OPERATOR_TOKEN_AUDIENCE,
68
160
  clientId: OPERATOR_TOKEN_CLIENT_ID,
69
161
  issuer: opts.issuer,
70
- ttlSeconds: OPERATOR_TOKEN_TTL_SECONDS,
162
+ ttlSeconds: opts.ttlSeconds ?? OPERATOR_TOKEN_TTL_SECONDS,
163
+ extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: scopeSet },
71
164
  ...(opts.jti !== undefined ? { jti: opts.jti } : {}),
72
165
  ...(opts.now !== undefined ? { now: opts.now } : {}),
73
166
  });
167
+ // Register every operator-mint with the unified token registry (hub#212
168
+ // Phase 1). Per design: operator-mint rows have user_id NULL; the
169
+ // subject column carries the canonical "operator" identity string.
170
+ // (Storing user_id here would require an FK-valid users row, which the
171
+ // operator-mint path doesn't always have access to in test fixtures —
172
+ // and conceptually the operator is a role, not a hub user.) Powers the
173
+ // revocation list endpoint.
174
+ recordTokenMint(db, {
175
+ jti: minted.jti,
176
+ createdVia: "operator_mint",
177
+ subject: "operator",
178
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
179
+ scopes,
180
+ expiresAt: minted.expiresAt,
181
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
182
+ });
183
+ return { ...minted, scopeSet };
74
184
  }
75
185
 
76
186
  /**
@@ -86,6 +196,10 @@ export async function writeOperatorTokenFile(
86
196
  const path = operatorTokenPath(dir);
87
197
  const tmp = `${path}.tmp`;
88
198
  await fs.writeFile(tmp, `${token}\n`, { mode: 0o600 });
199
+ // Defense-in-depth: if the file already existed with looser permissions,
200
+ // some platforms (Linux, macOS) preserve the prior inode's mode rather
201
+ // than honoring the create-mode hint on rename. Force 0600 explicitly.
202
+ await fs.chmod(tmp, 0o600);
89
203
  await fs.rename(tmp, path);
90
204
  return path;
91
205
  }
@@ -94,11 +208,19 @@ export async function writeOperatorTokenFile(
94
208
  * Reads the operator token file, trims trailing whitespace. Returns null
95
209
  * if the file doesn't exist (caller decides whether that's an error). Any
96
210
  * other read error propagates.
211
+ *
212
+ * On read, checks file permissions. If the file is group- or world-readable
213
+ * (mode bits 0o077 set), logs a warning but does NOT fail the read — a
214
+ * read-only failure here would lock operators out of every CLI command,
215
+ * with no in-CLI way to recover. The warning + remediation hint
216
+ * (`chmod 0600 <path>`) lets the operator self-correct without losing
217
+ * access. New writes via `writeOperatorTokenFile` are always 0600.
97
218
  */
98
219
  export async function readOperatorTokenFile(dir: string = configDir()): Promise<string | null> {
99
220
  const path = operatorTokenPath(dir);
100
221
  try {
101
222
  const buf = await fs.readFile(path, "utf8");
223
+ await warnIfWorldReadable(path);
102
224
  const trimmed = buf.trim();
103
225
  return trimmed.length > 0 ? trimmed : null;
104
226
  } catch (err) {
@@ -107,16 +229,35 @@ export async function readOperatorTokenFile(dir: string = configDir()): Promise<
107
229
  }
108
230
  }
109
231
 
232
+ async function warnIfWorldReadable(path: string): Promise<void> {
233
+ try {
234
+ const stat = await fs.stat(path);
235
+ const looseBits = stat.mode & 0o077;
236
+ if (looseBits !== 0) {
237
+ const mode = (stat.mode & 0o777).toString(8).padStart(4, "0");
238
+ console.error(
239
+ `parachute: operator token file at ${path} has mode ${mode} (group/other can read it). Run \`chmod 0600 ${path}\` to lock it down.`,
240
+ );
241
+ }
242
+ } catch {
243
+ // If stat fails (file vanished between read and stat, or platform
244
+ // doesn't expose mode bits), skip the warning — the read already
245
+ // succeeded, and this is defense-in-depth, not a hard gate.
246
+ }
247
+ }
248
+
110
249
  export interface IssueOperatorTokenResult {
111
250
  token: string;
112
251
  jti: string;
113
252
  expiresAt: string;
114
253
  path: string;
254
+ scopeSet: OperatorScopeSet;
115
255
  }
116
256
 
117
257
  /**
118
258
  * Mint + write in one call. Used by `parachute auth set-password` (after
119
- * password set) and `parachute auth rotate-operator`.
259
+ * password set), `parachute auth rotate-operator`, and the auto-rotation
260
+ * path inside `useOperatorTokenWithAutoRotate`.
120
261
  */
121
262
  export async function issueOperatorToken(
122
263
  db: Database,
@@ -127,3 +268,116 @@ export async function issueOperatorToken(
127
268
  const path = await writeOperatorTokenFile(minted.token, opts.dir);
128
269
  return { ...minted, path };
129
270
  }
271
+
272
+ export class OperatorTokenExpiredError extends Error {
273
+ override name = "OperatorTokenExpiredError";
274
+ }
275
+
276
+ export interface UseOperatorTokenOpts {
277
+ /** Hub origin used as `iss` validator. Required. */
278
+ issuer: string;
279
+ /** configDir override (where operator.token lives). Defaults to `configDir()`. */
280
+ configDir?: string;
281
+ /**
282
+ * Override the rotation clock. Tests pin this; production uses
283
+ * `() => new Date()`.
284
+ */
285
+ now?: () => Date;
286
+ }
287
+
288
+ export interface UsedOperatorToken {
289
+ /** The operator token plaintext to present as bearer. After auto-rotation, this is the freshly-minted token. */
290
+ token: string;
291
+ /** Validated payload of `token` (post-rotation if a rotation occurred). */
292
+ payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
293
+ /** Set when this call rotated the on-disk token. The new path on disk. */
294
+ rotated?: { path: string; scopeSet: OperatorScopeSet; expiresAt: string };
295
+ /** True if the on-disk token was within the auto-rotation threshold (informational). */
296
+ refreshed: boolean;
297
+ }
298
+
299
+ /**
300
+ * The canonical "use the operator token in a CLI flow" helper. Reads
301
+ * `~/.parachute/operator.token`, validates against `db` + `issuer`, and:
302
+ *
303
+ * - If the token has fully expired: throws `OperatorTokenExpiredError`
304
+ * with an actionable message. Does NOT auto-rotate from a dead token —
305
+ * auto-rotating an expired token would defeat the lifetime cap.
306
+ * - If the remaining lifetime is below the auto-rotate threshold (7d):
307
+ * re-mints under the same scope-set, writes back to disk, and returns
308
+ * the new token. Operator never sees an expiry surprise as long as
309
+ * they exercise the CLI at least weekly.
310
+ * - Otherwise: returns the original token + payload.
311
+ *
312
+ * Callers receive the (possibly fresh) token to present onward. The
313
+ * scope-set is preserved across rotations via the `pa_scope_set` claim;
314
+ * tokens minted before #213 don't carry the claim and are treated as
315
+ * `admin` (back-compat).
316
+ */
317
+ export async function useOperatorTokenWithAutoRotate(
318
+ db: Database,
319
+ opts: UseOperatorTokenOpts,
320
+ ): Promise<UsedOperatorToken | null> {
321
+ const dir = opts.configDir ?? configDir();
322
+ const token = await readOperatorTokenFile(dir);
323
+ if (!token) return null;
324
+ const now = opts.now ?? (() => new Date());
325
+
326
+ // Validation failures (signature mismatch, wrong issuer, missing kid,
327
+ // expired-by-jose) bubble out for the caller to render the right message.
328
+ const validated = await validateAccessToken(db, token, opts.issuer);
329
+ const { payload } = validated;
330
+
331
+ const exp = typeof payload.exp === "number" ? payload.exp : 0;
332
+ const nowSec = Math.floor(now().getTime() / 1000);
333
+ const remaining = exp - nowSec;
334
+
335
+ // jose's verify will reject expired tokens before we get here, so this
336
+ // branch is defensive; callers that catch validateAccessToken errors and
337
+ // re-call this with a hand-rolled payload would land here.
338
+ if (remaining <= 0) {
339
+ throw new OperatorTokenExpiredError(
340
+ "your operator token has expired; run `parachute auth rotate-operator` to re-mint",
341
+ );
342
+ }
343
+
344
+ if (remaining > OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS) {
345
+ return { token, payload, refreshed: false };
346
+ }
347
+
348
+ // Within rotation window — but only auto-rotate if this is genuinely an
349
+ // operator token. The audience check is the privilege-escalation guard:
350
+ // an arbitrary scope-narrow JWT (aud: "scribe", "vault", …) hand-stashed
351
+ // at ~/.parachute/operator.token must NOT be silently upgraded to a full
352
+ // operator token by the hub. Legitimate operator-tokens minted via
353
+ // `set-password` / `rotate-operator` carry `aud: "operator"`.
354
+ if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
355
+ return { token, payload, refreshed: false };
356
+ }
357
+
358
+ // Re-mint preserving scope-set.
359
+ const sub = typeof payload.sub === "string" ? payload.sub : null;
360
+ if (!sub) {
361
+ // No sub claim — can't safely auto-rotate (don't know who the token
362
+ // belongs to). Return as-is; the caller will likely surface this as an
363
+ // invalid-token error downstream.
364
+ return { token, payload, refreshed: false };
365
+ }
366
+ const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
367
+ const scopeSet: OperatorScopeSet = isOperatorScopeSet(claimedSet)
368
+ ? claimedSet
369
+ : OPERATOR_TOKEN_DEFAULT_SCOPE_SET;
370
+ const issued = await issueOperatorToken(db, sub, {
371
+ dir,
372
+ issuer: opts.issuer,
373
+ scopeSet,
374
+ now: opts.now,
375
+ });
376
+ const reValidated = await validateAccessToken(db, issued.token, opts.issuer);
377
+ return {
378
+ token: issued.token,
379
+ payload: reValidated.payload,
380
+ rotated: { path: issued.path, scopeSet: issued.scopeSet, expiresAt: issued.expiresAt },
381
+ refreshed: true,
382
+ };
383
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Same-origin defense for cookie-based POST endpoints. The function is the
3
+ * server-side belt for `SameSite=Lax` cookies: a cookie-bearing POST coming
4
+ * from anywhere other than the hub's own origin gets rejected here so a
5
+ * stolen-cookie + forged-form attack can't ride the session.
6
+ *
7
+ * The check used to compare strictly against `deps.issuer` (the configured
8
+ * OAuth issuer URL). That's correct only on a single-origin hub. Real
9
+ * self-hosted setups have multiple legitimate origins:
10
+ *
11
+ * - Loopback (`http://localhost:1939`, `http://127.0.0.1:1939`) — what
12
+ * the operator hits when running everything on their box.
13
+ * - Tailnet hostname (e.g. `https://parachute.taildf9ce2.ts.net`) — what
14
+ * they hit from a remote device on the tailnet.
15
+ * - Funnel hostname (when public-funnel is up).
16
+ * - Custom domain (when an operator wires a cname).
17
+ *
18
+ * The strict `issuer === origin` check rejected legitimate operator paths
19
+ * from any of the non-issuer origins (closes #245). We now match against
20
+ * the SET of origins hub is bound to. Real third-party origins still get
21
+ * rejected; legitimate operator paths from any bound origin work.
22
+ *
23
+ * Header-stripped fallback (also closes #245): Tailscale Serve and some
24
+ * reverse proxies don't always forward Origin/Referer on POSTs. When both
25
+ * are absent, we fall back to the Host header. Host is browser-controlled
26
+ * but reflects "what the operator's browser thought it was talking to";
27
+ * matching it against a bound origin is weaker than Origin/Referer but
28
+ * preserves the same-origin signal in the proxy-stripped legitimate-flow
29
+ * case. CSRF + session gates upstream are the real auth defense — this is
30
+ * a belt for browser flows where the legitimate Origin/Referer got dropped.
31
+ */
32
+
33
+ /**
34
+ * Build the bound-origin set from the hub's configuration. Returns
35
+ * canonical "scheme://host:port" strings (no trailing slash). The set is
36
+ * order-independent; consumers compare exact-equal against parsed Origin
37
+ * URLs.
38
+ *
39
+ * - `issuer`: the configured OAuth issuer URL, always included.
40
+ * - `loopbackPort`: the hub's local listen port; both `localhost` and
41
+ * `127.0.0.1` aliases are included for that port.
42
+ * - `exposeHubOrigin`: the `hubOrigin` from `expose-state.json` if set;
43
+ * typically equal to `issuer` post-`parachute expose`, but kept as an
44
+ * independent input so a tailnet bring-up after hub start is reflected
45
+ * without restart.
46
+ *
47
+ * Malformed inputs are dropped silently — the function returns whatever it
48
+ * could parse. Callers should always include the issuer as a baseline so
49
+ * an empty parse-result is never the whole story.
50
+ */
51
+ export function buildHubBoundOrigins(opts: {
52
+ issuer: string;
53
+ loopbackPort?: number;
54
+ exposeHubOrigin?: string;
55
+ }): readonly string[] {
56
+ const set = new Set<string>();
57
+ const add = (raw: string | undefined) => {
58
+ if (!raw) return;
59
+ try {
60
+ const u = new URL(raw);
61
+ set.add(u.origin);
62
+ } catch {
63
+ // Malformed URL — skip.
64
+ }
65
+ };
66
+ add(opts.issuer);
67
+ add(opts.exposeHubOrigin);
68
+ if (typeof opts.loopbackPort === "number" && Number.isInteger(opts.loopbackPort)) {
69
+ set.add(`http://localhost:${opts.loopbackPort}`);
70
+ set.add(`http://127.0.0.1:${opts.loopbackPort}`);
71
+ }
72
+ return Array.from(set);
73
+ }
74
+
75
+ /**
76
+ * True when the request's Origin/Referer (or Host as fallback) matches any
77
+ * of the hub-bound origins. The check has three tiers in priority order:
78
+ *
79
+ * 1. `Origin` header — the canonical CSRF signal per Fetch standard.
80
+ * 2. `Referer` header — fallback when Origin is absent (rare but spec).
81
+ * 3. `Host` header — last-resort match when both above are stripped
82
+ * (proxy-mangling case). Compares against the host:port of each bound
83
+ * origin (not the scheme), since the proxy may have terminated TLS
84
+ * and the Host header reflects the operator's address bar regardless
85
+ * of how the request reached us.
86
+ *
87
+ * Empty `boundOrigins` always returns false — defense fails closed when no
88
+ * origin info is configured.
89
+ */
90
+ export function isSameOriginRequest(req: Request, boundOrigins: readonly string[]): boolean {
91
+ if (boundOrigins.length === 0) return false;
92
+ const boundOriginSet = new Set(boundOrigins);
93
+
94
+ const origin = req.headers.get("origin");
95
+ if (origin) {
96
+ try {
97
+ return boundOriginSet.has(new URL(origin).origin);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ const referer = req.headers.get("referer");
103
+ if (referer) {
104
+ try {
105
+ return boundOriginSet.has(new URL(referer).origin);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+ const host = req.headers.get("host");
111
+ if (host) {
112
+ // Build the set of acceptable host:port strings from the bound origins.
113
+ // Match on `host:port` only, scheme-agnostic — the Host header doesn't
114
+ // carry scheme, and a proxy may have terminated TLS upstream.
115
+ const boundHosts = new Set<string>();
116
+ for (const origin of boundOrigins) {
117
+ try {
118
+ const u = new URL(origin);
119
+ boundHosts.add(u.host); // includes port if non-default
120
+ } catch {
121
+ // Skip malformed.
122
+ }
123
+ }
124
+ return boundHosts.has(host);
125
+ }
126
+ return false;
127
+ }
package/src/rate-limit.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Per-IP rate-limit on `POST /admin/login`. Lands as a floor under brute-force
2
+ * Per-IP rate-limit on `POST /login`. Lands as a floor under brute-force
3
3
  * after hub#187 collapsed the public-reach matrix: with a cloudflare tunnel
4
- * up, `/admin/login` is now reachable from the open internet, and 2FA (#186)
4
+ * up, `/login` is now reachable from the open internet, and 2FA (#186)
5
5
  * is the next PR rather than this one. A 5-attempts-per-15-minute bucket per
6
6
  * IP is the standard login-form floor; it's not the primary defense, just the
7
7
  * one that turns "infinite credential grinding" into "rotate IPs".
8
8
  *
9
+ * (Endpoint was `/admin/login` pre-rename; bucket logic is path-agnostic so
10
+ * the rename was a comment-only change here.)
11
+ *
9
12
  * Shape: sliding window. Each key keeps the last N attempt timestamps; on a
10
13
  * new attempt we prune anything older than the window, count what remains,
11
14
  * decide allow / deny, and (on allow) append the current timestamp. Sliding
@@ -66,6 +66,30 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
66
66
  "Provision and manage vaults across this host (create new vaults, configure cross-vault settings).",
67
67
  level: "admin",
68
68
  },
69
+ // Fine-grained host scopes (#213) — the `parachute auth rotate-operator
70
+ // --scope-set <set>` vocabulary. Each is a narrowing of `parachute:host:admin`:
71
+ // an operator who wants a tighter token mints with one of these and uses
72
+ // it in place of the broad operator.token. Operator-only (non-requestable).
73
+ "parachute:host:install": {
74
+ label: "Install or upgrade Parachute modules on this host.",
75
+ level: "admin",
76
+ },
77
+ "parachute:host:start": {
78
+ label: "Lifecycle Parachute modules on this host (start, stop, restart, status).",
79
+ level: "admin",
80
+ },
81
+ "parachute:host:expose": {
82
+ label: "Bring tailnet or public exposure layers up and down on this host.",
83
+ level: "admin",
84
+ },
85
+ "parachute:host:auth": {
86
+ label: "Mint hub-issued tokens and manage user accounts on this host.",
87
+ level: "admin",
88
+ },
89
+ "parachute:host:vault": {
90
+ label: "Administer vaults on this host (create, configure, delete).",
91
+ level: "admin",
92
+ },
69
93
  };
70
94
 
71
95
  /**
@@ -83,7 +107,7 @@ export const FIRST_PARTY_SCOPES = Object.keys(SCOPE_EXPLANATIONS).sort();
83
107
  * - `parachute auth rotate-operator` writes the long-lived operator token
84
108
  * (`~/.parachute/operator.token`, mode 0600) for service accounts.
85
109
  * - `GET /admin/host-admin-token` exchanges a valid `parachute_hub_session`
86
- * cookie (set by `/admin/login` after a password check) for a
110
+ * cookie (set by `/login` after a password check) for a
87
111
  * short-lived JWT consumed by the in-tree vault-management SPA.
88
112
  *
89
113
  * Both surfaces predicate on local-operator identity that the public OAuth
@@ -104,7 +128,14 @@ export const FIRST_PARTY_SCOPES = Object.keys(SCOPE_EXPLANATIONS).sort();
104
128
  * intentional: the blast radius of compromised cross-vault admin doesn't
105
129
  * justify third-party requestability.
106
130
  */
107
- export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set(["parachute:host:admin"]);
131
+ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
132
+ "parachute:host:admin",
133
+ "parachute:host:install",
134
+ "parachute:host:start",
135
+ "parachute:host:expose",
136
+ "parachute:host:auth",
137
+ "parachute:host:vault",
138
+ ]);
108
139
 
109
140
  /**
110
141
  * Per-vault `vault:<name>:admin` scopes are also non-requestable: they let
package/src/sessions.ts CHANGED
@@ -121,7 +121,7 @@ export function parseSessionCookie(cookieHeader: string | null): string | null {
121
121
  * callers don't repeat the parse+find+null-check dance.
122
122
  *
123
123
  * Caller decides what to do on null — admin pages redirect to
124
- * `/admin/login?next=<path>`, OAuth's DCR endpoint falls through to
124
+ * `/login?next=<path>`, OAuth's DCR endpoint falls through to
125
125
  * status=`pending` (closes #199).
126
126
  */
127
127
  export function findActiveSession(
package/src/well-known.ts CHANGED
@@ -26,6 +26,14 @@ export interface WellKnownVaultEntry {
26
26
  * to iterate without having to know every service's shortName ahead of time.
27
27
  * `infoUrl` points at the service's `/.parachute/info` endpoint (relative to
28
28
  * its mount path) which the hub fetches client-side for displayName/tagline.
29
+ *
30
+ * `displayName` and `uiUrl` are both optional — the discovery page renders
31
+ * a Services tile when `uiUrl` is present, falling back to the manifest
32
+ * short name when `displayName` is absent. Both are sourced via hub-server's
33
+ * `loadUiUrls`/`loadManagementUrls`-style readers from the module's
34
+ * `installDir/.parachute/module.json`, NOT from services.json (which gets
35
+ * overwritten on service boot per the "services own the write side"
36
+ * contract — see hub#238 commit message for the C-not-B trace).
29
37
  */
30
38
  export interface WellKnownServicesEntry {
31
39
  name: string;
@@ -33,6 +41,16 @@ export interface WellKnownServicesEntry {
33
41
  path: string;
34
42
  version: string;
35
43
  infoUrl: string;
44
+ /**
45
+ * Human-readable label for the discovery page. Sourced from
46
+ * `module.json:displayName` when available; falls back to
47
+ * `services.json:displayName` written at install time.
48
+ */
49
+ displayName?: string;
50
+ /** Where the service's primary user-facing UI lives, sourced from `module.json:uiUrl`. */
51
+ uiUrl?: string;
52
+ /** One-line subtitle for the discovery tile, sourced from `services.json:tagline`. */
53
+ tagline?: string;
36
54
  }
37
55
 
38
56
  /**
@@ -107,6 +125,19 @@ export interface BuildWellKnownOpts {
107
125
  * in. Returning `undefined` means "no admin SPA" and hub renders no link.
108
126
  */
109
127
  managementUrlFor?: (entry: ServiceEntry) => string | undefined;
128
+ /**
129
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:uiUrl`,
130
+ * if any. Same shape as `managementUrlFor`. Returning `undefined` means
131
+ * "no user-facing UI" and discovery omits the Services tile (e.g. vault
132
+ * has no `uiUrl` — its content browses through Notes).
133
+ */
134
+ uiUrlFor?: (entry: ServiceEntry) => string | undefined;
135
+ /**
136
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:displayName`.
137
+ * Hub-server reads this at request time; falls back to the entry's own
138
+ * `displayName` (from services.json) when absent.
139
+ */
140
+ displayNameFor?: (entry: ServiceEntry) => string | undefined;
110
141
  }
111
142
 
112
143
  /** Join a base origin and a path without double slashes — "/" stays "/". */
@@ -131,7 +162,29 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
131
162
  for (const path of pathsToEmit) {
132
163
  const url = new URL(path, `${base}/`).toString();
133
164
  const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();
134
- doc.services.push({ name: s.name, url, path, version: s.version, infoUrl });
165
+ const entry: WellKnownServicesEntry = {
166
+ name: s.name,
167
+ url,
168
+ path,
169
+ version: s.version,
170
+ infoUrl,
171
+ };
172
+ const displayName = opts.displayNameFor?.(s) ?? s.displayName;
173
+ if (displayName !== undefined) entry.displayName = displayName;
174
+ // Tagline rides on services.json (set by service-spec at install or
175
+ // by the service's own boot-time upsert). Read directly from the
176
+ // entry — no installDir round-trip needed since it's already
177
+ // persisted server-side and reasonably stable across reboots.
178
+ if (s.tagline !== undefined) entry.tagline = s.tagline;
179
+ // Resolve uiUrl: relative path → absolute URL against `base`; full
180
+ // http(s) URL → verbatim. Same rule managementUrl uses.
181
+ const uiUrlRaw = opts.uiUrlFor?.(s);
182
+ if (uiUrlRaw !== undefined) {
183
+ entry.uiUrl = /^https?:\/\//i.test(uiUrlRaw)
184
+ ? uiUrlRaw
185
+ : new URL(uiUrlRaw, `${base}/`).toString();
186
+ }
187
+ doc.services.push(entry);
135
188
  if (isVault) {
136
189
  const managementUrl = opts.managementUrlFor?.(s);
137
190
  const entry: WellKnownVaultEntry = {