@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.
- 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 +338 -65
- 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 +266 -5
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/status.test.ts +199 -0
- 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/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/status.ts +74 -10
- package/src/csrf.ts +6 -3
- package/src/help.ts +10 -4
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +426 -97
- 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 +183 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +272 -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/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,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 /
|
|
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(
|
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
|
-
|
|
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 = {
|