@openparachute/hub 0.5.7 → 0.5.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) 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 +526 -67
  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 +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. 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,193 @@ 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
+ /**
289
+ * Disambiguated outcome of `useOperatorTokenWithAutoRotate`. The prior
290
+ * surface (boolean `refreshed`) conflated three operationally distinct
291
+ * cases under a single `false` value: (a) token is fresh, no rotation
292
+ * needed; (b) within rotation window but rotation skipped due to
293
+ * privilege guard; (c) within rotation window but rotation skipped due
294
+ * to missing sub claim. This shape surfaces each case so future
295
+ * rotation telemetry / admin UI can branch on them. See hub#216 for the
296
+ * motivation; hub#212 Phase 2 is the natural consumer.
297
+ *
298
+ * - `fresh` — remaining lifetime above the auto-rotation threshold;
299
+ * the token on disk was returned as-is. The healthy steady state.
300
+ * - `rotated` — rotation fired; the on-disk token was overwritten.
301
+ * `rotated` companion field on `UsedOperatorToken` carries the new
302
+ * path / scopeSet / expiry.
303
+ * - `skipped` — within rotation window, but rotation was deliberately
304
+ * not performed. `reason` distinguishes:
305
+ * - `aud-mismatch`: the on-disk JWT carries a non-operator audience.
306
+ * This is the privilege-escalation guard (a hand-stashed scope-narrow
307
+ * JWT must not be silently upgraded). See operator-token.ts line ~360.
308
+ * - `no-sub`: the on-disk JWT lacks a `sub` claim, so the helper can't
309
+ * safely re-mint (don't know who the token belongs to). Surfaced via
310
+ * the caller's downstream invalid-token error path.
311
+ * - `no-scope-set`: the on-disk JWT carries `aud: operator` but lacks
312
+ * (or has an unrecognized) `pa_scope_set` claim. Refusing to rotate
313
+ * here is hub#224's hardening: the prior behaviour fell back to the
314
+ * default scope-set (admin) on rotation, which silently widened a
315
+ * narrow-scope token of unknown provenance. Legitimately-issued
316
+ * operator tokens always carry a recognized `pa_scope_set` (set by
317
+ * `issueOperatorToken`); a token without it is either a hand-crafted
318
+ * JWT (don't widen) or a pre-#213 legacy token (operator should
319
+ * explicitly `parachute auth rotate-operator` to recover).
320
+ */
321
+ export type RotationStatus =
322
+ | { kind: "fresh" }
323
+ | { kind: "rotated" }
324
+ | { kind: "skipped"; reason: "aud-mismatch" | "no-sub" | "no-scope-set" };
325
+
326
+ export interface UsedOperatorToken {
327
+ /** The operator token plaintext to present as bearer. After auto-rotation, this is the freshly-minted token. */
328
+ token: string;
329
+ /** Validated payload of `token` (post-rotation if a rotation occurred). */
330
+ payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
331
+ /** Set when this call rotated the on-disk token. The new path on disk. */
332
+ rotated?: { path: string; scopeSet: OperatorScopeSet; expiresAt: string };
333
+ /**
334
+ * Disambiguated rotation outcome. See {@link RotationStatus}. Callers that
335
+ * only need "did the token rotate?" can check `status.kind === "rotated"`
336
+ * or the presence of the `rotated` companion field (equivalent). Future
337
+ * telemetry / admin UI can branch on `skipped.reason` to surface why a
338
+ * rotation didn't fire.
339
+ */
340
+ status: RotationStatus;
341
+ }
342
+
343
+ /**
344
+ * The canonical "use the operator token in a CLI flow" helper. Reads
345
+ * `~/.parachute/operator.token`, validates against `db` + `issuer`, and:
346
+ *
347
+ * - If the token has fully expired: throws `OperatorTokenExpiredError`
348
+ * with an actionable message. Does NOT auto-rotate from a dead token —
349
+ * auto-rotating an expired token would defeat the lifetime cap.
350
+ * - If the remaining lifetime is below the auto-rotate threshold (7d):
351
+ * re-mints under the same scope-set, writes back to disk, and returns
352
+ * the new token. Operator never sees an expiry surprise as long as
353
+ * they exercise the CLI at least weekly.
354
+ * - Otherwise: returns the original token + payload.
355
+ *
356
+ * Callers receive the (possibly fresh) token to present onward. The
357
+ * scope-set is preserved across rotations via the `pa_scope_set` claim;
358
+ * tokens that lack a recognized `pa_scope_set` claim are NOT auto-rotated
359
+ * (hub#224 hardening — the prior fallback to the default scope-set
360
+ * silently widened narrow-scope tokens of unknown provenance). Operators
361
+ * holding a legacy token without the claim should `parachute auth
362
+ * rotate-operator` to recover.
363
+ *
364
+ * ## Test-author note (hub#224)
365
+ *
366
+ * Tests that stash a JWT at `~/.parachute/operator.token` to exercise
367
+ * downstream gate-check behaviour need to side-step auto-rotation, or
368
+ * the helper will silently swap the test's narrow token for a fresh
369
+ * admin one before the gate runs. Two safe shapes:
370
+ *
371
+ * 1. **Long TTL.** Mint with `ttlSeconds: 30 * 24 * 60 * 60` (30d) so
372
+ * `remaining > OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS` and the
373
+ * helper returns the token as-is (`status.kind === "fresh"`). This
374
+ * is the pattern in `bootstrapWithOperatorScopes` (hub#222 tests).
375
+ * 2. **Non-operator audience.** Sign with `audience: "scribe"` (or any
376
+ * non-`"operator"` value) so the line ~398 guard fires
377
+ * (`status.kind === "skipped"`, `reason: "aud-mismatch"`). This is
378
+ * the pattern in the pre-#222 narrow-rejection test.
379
+ *
380
+ * `aud: "operator"` + short TTL + recognized `pa_scope_set` is the
381
+ * auto-rotation path. `aud: "operator"` + short TTL + missing/invalid
382
+ * `pa_scope_set` lands on the no-scope-set skip (hub#224). Either way,
383
+ * if you're seeing a "narrow token magically became admin," check
384
+ * the test's `audience` + TTL first.
385
+ */
386
+ export async function useOperatorTokenWithAutoRotate(
387
+ db: Database,
388
+ opts: UseOperatorTokenOpts,
389
+ ): Promise<UsedOperatorToken | null> {
390
+ const dir = opts.configDir ?? configDir();
391
+ const token = await readOperatorTokenFile(dir);
392
+ if (!token) return null;
393
+ const now = opts.now ?? (() => new Date());
394
+
395
+ // Validation failures (signature mismatch, wrong issuer, missing kid,
396
+ // expired-by-jose) bubble out for the caller to render the right message.
397
+ const validated = await validateAccessToken(db, token, opts.issuer);
398
+ const { payload } = validated;
399
+
400
+ const exp = typeof payload.exp === "number" ? payload.exp : 0;
401
+ const nowSec = Math.floor(now().getTime() / 1000);
402
+ const remaining = exp - nowSec;
403
+
404
+ // jose's verify will reject expired tokens before we get here, so this
405
+ // branch is defensive; callers that catch validateAccessToken errors and
406
+ // re-call this with a hand-rolled payload would land here.
407
+ if (remaining <= 0) {
408
+ throw new OperatorTokenExpiredError(
409
+ "your operator token has expired; run `parachute auth rotate-operator` to re-mint",
410
+ );
411
+ }
412
+
413
+ if (remaining > OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS) {
414
+ return { token, payload, status: { kind: "fresh" } };
415
+ }
416
+
417
+ // Within rotation window — but only auto-rotate if this is genuinely an
418
+ // operator token. The audience check is the privilege-escalation guard:
419
+ // an arbitrary scope-narrow JWT (aud: "scribe", "vault", …) hand-stashed
420
+ // at ~/.parachute/operator.token must NOT be silently upgraded to a full
421
+ // operator token by the hub. Legitimate operator-tokens minted via
422
+ // `set-password` / `rotate-operator` carry `aud: "operator"`.
423
+ if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
424
+ return { token, payload, status: { kind: "skipped", reason: "aud-mismatch" } };
425
+ }
426
+
427
+ // Re-mint preserving scope-set.
428
+ const sub = typeof payload.sub === "string" ? payload.sub : null;
429
+ if (!sub) {
430
+ // No sub claim — can't safely auto-rotate (don't know who the token
431
+ // belongs to). Return as-is; the caller will likely surface this as an
432
+ // invalid-token error downstream.
433
+ return { token, payload, status: { kind: "skipped", reason: "no-sub" } };
434
+ }
435
+ // Refuse to auto-rotate without a recognized scope-set claim. The prior
436
+ // behaviour fell back to OPERATOR_TOKEN_DEFAULT_SCOPE_SET (admin), which
437
+ // silently widened a narrow `aud: "operator"` token of unknown provenance
438
+ // — hand-crafted or pre-#213 legacy. hub#224's hardening: a missing or
439
+ // invalid `pa_scope_set` means "I don't know what scope to re-mint under,"
440
+ // so don't re-mint at all. The operator can recover via an explicit
441
+ // `parachute auth rotate-operator` (which always sets the claim).
442
+ const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
443
+ if (!isOperatorScopeSet(claimedSet)) {
444
+ return { token, payload, status: { kind: "skipped", reason: "no-scope-set" } };
445
+ }
446
+ const scopeSet: OperatorScopeSet = claimedSet;
447
+ const issued = await issueOperatorToken(db, sub, {
448
+ dir,
449
+ issuer: opts.issuer,
450
+ scopeSet,
451
+ now: opts.now,
452
+ });
453
+ const reValidated = await validateAccessToken(db, issued.token, opts.issuer);
454
+ return {
455
+ token: issued.token,
456
+ payload: reValidated.payload,
457
+ rotated: { path: issued.path, scopeSet: issued.scopeSet, expiresAt: issued.expiresAt },
458
+ status: { kind: "rotated" },
459
+ };
460
+ }
@@ -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(