@openparachute/hub 0.5.7 → 0.5.10-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/operator-token.ts
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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 /
|
|
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, `/
|
|
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 `/
|
|
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([
|
|
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
|
-
* `/
|
|
124
|
+
* `/login?next=<path>`, OAuth's DCR endpoint falls through to
|
|
125
125
|
* status=`pending` (closes #199).
|
|
126
126
|
*/
|
|
127
127
|
export function findActiveSession(
|