@openparachute/hub 0.5.2 → 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 +159 -320
- 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 +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- 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 +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- 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/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -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/oauth-ui.ts
CHANGED
|
@@ -97,6 +97,41 @@ export interface ErrorViewProps {
|
|
|
97
97
|
status: number;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Props for the "App not yet approved" view rendered when an unapproved
|
|
102
|
+
* client lands on `/oauth/authorize`. When `session` is true the operator is
|
|
103
|
+
* authenticated to this hub from the browser making the request, so we render
|
|
104
|
+
* an inline approve form (closes #208). When false we fall back to the
|
|
105
|
+
* pre-#208 CLI-only message.
|
|
106
|
+
*/
|
|
107
|
+
export interface ApprovePendingViewProps {
|
|
108
|
+
/** Display name to show — falls back to client_id when no name was supplied at DCR. */
|
|
109
|
+
clientName: string;
|
|
110
|
+
clientId: string;
|
|
111
|
+
redirectUris: string[];
|
|
112
|
+
/** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
|
|
113
|
+
requestedScopes: string[];
|
|
114
|
+
/**
|
|
115
|
+
* Vault hint from the original `/oauth/authorize?vault=<name>` query param,
|
|
116
|
+
* passed by Notes' VaultPopover (notes#115) when kicking the OAuth flow for
|
|
117
|
+
* a specific vault. Rendered alongside scopes so the operator can tell
|
|
118
|
+
* which vault they're approving access for on a multi-vault hub (closes
|
|
119
|
+
* #244). Single-vault hubs leave this absent and the section omits.
|
|
120
|
+
*/
|
|
121
|
+
requestedVault?: string;
|
|
122
|
+
/**
|
|
123
|
+
* When set, render the inline approve form. The form posts to
|
|
124
|
+
* `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
|
|
125
|
+
* server will redirect to after the approve commits — the original
|
|
126
|
+
* `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
|
|
127
|
+
* approved client and lands on the consent screen.
|
|
128
|
+
*/
|
|
129
|
+
approveForm?: {
|
|
130
|
+
csrfToken: string;
|
|
131
|
+
returnTo: string;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
export function renderLogin(props: LoginViewProps): string {
|
|
101
136
|
const { params, errorMessage, csrfToken } = props;
|
|
102
137
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
@@ -204,6 +239,92 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
204
239
|
</section>`;
|
|
205
240
|
}
|
|
206
241
|
|
|
242
|
+
/**
|
|
243
|
+
* "App not yet approved" page (#74). When the request carries a valid
|
|
244
|
+
* operator session (#208), render the inline approve form so one click lands
|
|
245
|
+
* the client as `approved` and re-enters the OAuth flow at consent. Without
|
|
246
|
+
* a session, fall back to the original CLI-only message — anyone hitting
|
|
247
|
+
* /oauth/authorize unauthenticated to the hub itself can't be trusted to
|
|
248
|
+
* approve a DCR client from the browser, so they need to drop to a terminal
|
|
249
|
+
* and run `parachute auth approve-client <id>`.
|
|
250
|
+
*
|
|
251
|
+
* The CLI fallback hint is shown in BOTH branches: a button-equipped operator
|
|
252
|
+
* may still want the CLI invocation handy (different machine, scriptable
|
|
253
|
+
* context). The button is the easy path; the CLI is always-available.
|
|
254
|
+
*/
|
|
255
|
+
export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
256
|
+
const { clientName, clientId, redirectUris, requestedScopes, requestedVault, approveForm } =
|
|
257
|
+
props;
|
|
258
|
+
const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
|
|
259
|
+
const scopeRows =
|
|
260
|
+
requestedScopes.length === 0
|
|
261
|
+
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
262
|
+
: requestedScopes.map(renderScopeRow).join("\n");
|
|
263
|
+
// Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
|
|
264
|
+
// `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
|
|
265
|
+
// alongside scopes so a multi-vault operator can tell which vault they're
|
|
266
|
+
// approving for. Missing on single-vault hubs / pre-vault-popover clients —
|
|
267
|
+
// section omits when absent.
|
|
268
|
+
const vaultRow = requestedVault
|
|
269
|
+
? `
|
|
270
|
+
<p class="approve-meta-row">
|
|
271
|
+
<span class="approve-meta-label">vault</span>
|
|
272
|
+
<code class="approve-meta-value">${escapeHtml(requestedVault)}</code>
|
|
273
|
+
</p>`
|
|
274
|
+
: "";
|
|
275
|
+
const formSection = approveForm
|
|
276
|
+
? `
|
|
277
|
+
<form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
|
|
278
|
+
${renderCsrfHiddenInput(approveForm.csrfToken)}
|
|
279
|
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
|
|
280
|
+
<input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
|
|
281
|
+
<button type="submit" class="btn btn-primary">Approve and continue</button>
|
|
282
|
+
</form>
|
|
283
|
+
<p class="approve-cli-hint">
|
|
284
|
+
Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
|
|
285
|
+
</p>`
|
|
286
|
+
: `
|
|
287
|
+
<p class="approve-cli-hint">
|
|
288
|
+
Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
|
|
289
|
+
from a terminal, then try again.
|
|
290
|
+
</p>`;
|
|
291
|
+
const body = `
|
|
292
|
+
<div class="card">
|
|
293
|
+
<div class="card-header">
|
|
294
|
+
<div class="brand">
|
|
295
|
+
<span class="brand-mark">⌬</span>
|
|
296
|
+
<span class="brand-name">Parachute</span>
|
|
297
|
+
</div>
|
|
298
|
+
<h1>App not yet approved</h1>
|
|
299
|
+
<p class="subtitle">
|
|
300
|
+
${escapeHtml(clientName)} is registered with this hub but hasn't been approved yet.
|
|
301
|
+
Review the details below before approving.
|
|
302
|
+
</p>
|
|
303
|
+
</div>
|
|
304
|
+
<section class="approve-meta">
|
|
305
|
+
<h2 class="scopes-title">Application</h2>
|
|
306
|
+
<p class="approve-meta-row">
|
|
307
|
+
<span class="approve-meta-label">name</span>
|
|
308
|
+
<code class="approve-meta-value">${escapeHtml(clientName)}</code>
|
|
309
|
+
</p>
|
|
310
|
+
<p class="approve-meta-row">
|
|
311
|
+
<span class="approve-meta-label">client_id</span>
|
|
312
|
+
<code class="approve-meta-value">${escapeHtml(clientId)}</code>
|
|
313
|
+
</p>${vaultRow}
|
|
314
|
+
<div class="approve-meta-row approve-meta-row-block">
|
|
315
|
+
<span class="approve-meta-label">redirect_uris</span>
|
|
316
|
+
<ul class="approve-redirect-list">${redirectList}</ul>
|
|
317
|
+
</div>
|
|
318
|
+
</section>
|
|
319
|
+
<section class="scopes">
|
|
320
|
+
<h2 class="scopes-title">Permissions requested</h2>
|
|
321
|
+
<ul class="scope-list">${scopeRows}</ul>
|
|
322
|
+
</section>
|
|
323
|
+
${formSection}
|
|
324
|
+
</div>`;
|
|
325
|
+
return baseDocument("App not yet approved", body);
|
|
326
|
+
}
|
|
327
|
+
|
|
207
328
|
export function renderError(props: ErrorViewProps): string {
|
|
208
329
|
const body = `
|
|
209
330
|
<div class="card">
|
|
@@ -542,6 +663,73 @@ const STYLES = `
|
|
|
542
663
|
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
543
664
|
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
544
665
|
|
|
666
|
+
.approve-meta {
|
|
667
|
+
margin: 0 0 1.25rem;
|
|
668
|
+
padding: 0.75rem 0.85rem;
|
|
669
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
670
|
+
border-radius: 6px;
|
|
671
|
+
background: ${PALETTE.bgSoft};
|
|
672
|
+
}
|
|
673
|
+
.approve-meta .scopes-title { margin-bottom: 0.5rem; }
|
|
674
|
+
.approve-meta-row {
|
|
675
|
+
margin: 0 0 0.4rem;
|
|
676
|
+
display: flex;
|
|
677
|
+
gap: 0.5rem;
|
|
678
|
+
align-items: baseline;
|
|
679
|
+
flex-wrap: wrap;
|
|
680
|
+
}
|
|
681
|
+
.approve-meta-row:last-child { margin-bottom: 0; }
|
|
682
|
+
.approve-meta-row-block { flex-direction: column; gap: 0.25rem; }
|
|
683
|
+
.approve-meta-label {
|
|
684
|
+
text-transform: uppercase;
|
|
685
|
+
letter-spacing: 0.05em;
|
|
686
|
+
font-size: 0.7rem;
|
|
687
|
+
color: ${PALETTE.fgDim};
|
|
688
|
+
}
|
|
689
|
+
.approve-meta-value {
|
|
690
|
+
font-family: ${FONT_MONO};
|
|
691
|
+
font-size: 0.82rem;
|
|
692
|
+
background: ${PALETTE.cardBg};
|
|
693
|
+
padding: 0.1rem 0.4rem;
|
|
694
|
+
border-radius: 4px;
|
|
695
|
+
color: ${PALETTE.fg};
|
|
696
|
+
word-break: break-all;
|
|
697
|
+
}
|
|
698
|
+
.approve-redirect-list {
|
|
699
|
+
list-style: none;
|
|
700
|
+
margin: 0;
|
|
701
|
+
padding: 0;
|
|
702
|
+
display: flex;
|
|
703
|
+
flex-direction: column;
|
|
704
|
+
gap: 0.25rem;
|
|
705
|
+
}
|
|
706
|
+
.approve-redirect-list li code {
|
|
707
|
+
font-family: ${FONT_MONO};
|
|
708
|
+
font-size: 0.82rem;
|
|
709
|
+
background: ${PALETTE.cardBg};
|
|
710
|
+
padding: 0.1rem 0.4rem;
|
|
711
|
+
border-radius: 4px;
|
|
712
|
+
color: ${PALETTE.fg};
|
|
713
|
+
word-break: break-all;
|
|
714
|
+
}
|
|
715
|
+
.approve-form { gap: 0; }
|
|
716
|
+
.approve-cli-hint {
|
|
717
|
+
margin-top: 1rem;
|
|
718
|
+
padding-top: 0.85rem;
|
|
719
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
720
|
+
color: ${PALETTE.fgMuted};
|
|
721
|
+
font-size: 0.85rem;
|
|
722
|
+
}
|
|
723
|
+
.approve-cli-hint code {
|
|
724
|
+
font-family: ${FONT_MONO};
|
|
725
|
+
font-size: 0.8rem;
|
|
726
|
+
background: ${PALETTE.bgSoft};
|
|
727
|
+
padding: 0.1rem 0.4rem;
|
|
728
|
+
border-radius: 4px;
|
|
729
|
+
color: ${PALETTE.fg};
|
|
730
|
+
word-break: break-all;
|
|
731
|
+
}
|
|
732
|
+
|
|
545
733
|
.badge {
|
|
546
734
|
display: inline-block;
|
|
547
735
|
font-size: 0.7rem;
|
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
|
+
}
|