@openparachute/hub 0.3.0-rc.1 → 0.5.1
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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
package/src/oauth-ui.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded HTML templates for the OAuth login + consent + error screens.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of `oauth-handlers.ts` so the handlers stay focused on protocol
|
|
5
|
+
* logic and the templates stay focused on presentation. Pure functions —
|
|
6
|
+
* no DB access, no side channels.
|
|
7
|
+
*
|
|
8
|
+
* Design choices:
|
|
9
|
+
* - **No external font CDN.** OAuth screens see who's logging in and what
|
|
10
|
+
* they're authorizing; loading fonts from Google would leak that to a
|
|
11
|
+
* third party. We use system-font fallbacks that approximate the
|
|
12
|
+
* parachute.computer brand (Instrument Serif → Georgia for headings,
|
|
13
|
+
* DM Sans → -apple-system for body, ui-monospace for `<code>`).
|
|
14
|
+
* - **Inline CSS in `<style>`.** Single-file delivery; no extra round-trip
|
|
15
|
+
* for a stylesheet, no caching headaches when the hub is bound to a
|
|
16
|
+
* loopback origin.
|
|
17
|
+
* - **Scope explanations come from `scope-explanations.ts`.** First-party
|
|
18
|
+
* scopes get a one-sentence operator-facing label; admin scopes get a
|
|
19
|
+
* red border so the operator looks twice. Unknown scopes (third-party
|
|
20
|
+
* module scopes that the hub doesn't know about) render verbatim.
|
|
21
|
+
* - **No JavaScript.** Entirely form-based. Submit is the only interaction.
|
|
22
|
+
*/
|
|
23
|
+
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
24
|
+
import { type ScopeExplanation, explainScope } from "./scope-explanations.ts";
|
|
25
|
+
|
|
26
|
+
/** Brand palette — kept in sync with parachute.computer/style.css. */
|
|
27
|
+
const PALETTE = {
|
|
28
|
+
bg: "#faf8f4",
|
|
29
|
+
bgSoft: "#f3f0ea",
|
|
30
|
+
fg: "#2c2a26",
|
|
31
|
+
fgMuted: "#6b6860",
|
|
32
|
+
fgDim: "#9a9690",
|
|
33
|
+
accent: "#4a7c59",
|
|
34
|
+
accentHover: "#3d6849",
|
|
35
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
36
|
+
border: "#e4e0d8",
|
|
37
|
+
borderLight: "#ece9e2",
|
|
38
|
+
cardBg: "#ffffff",
|
|
39
|
+
danger: "#a3392b",
|
|
40
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
44
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
45
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
46
|
+
|
|
47
|
+
export function escapeHtml(s: string): string {
|
|
48
|
+
return s
|
|
49
|
+
.replace(/&/g, "&")
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/"/g, """)
|
|
53
|
+
.replace(/'/g, "'");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AuthorizeFormParams {
|
|
57
|
+
clientId: string;
|
|
58
|
+
redirectUri: string;
|
|
59
|
+
responseType: string;
|
|
60
|
+
scope: string;
|
|
61
|
+
codeChallenge: string;
|
|
62
|
+
codeChallengeMethod: string;
|
|
63
|
+
state: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LoginViewProps {
|
|
67
|
+
params: AuthorizeFormParams;
|
|
68
|
+
errorMessage?: string;
|
|
69
|
+
csrfToken: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ConsentViewProps {
|
|
73
|
+
params: AuthorizeFormParams;
|
|
74
|
+
clientName: string;
|
|
75
|
+
clientId: string;
|
|
76
|
+
scopes: string[];
|
|
77
|
+
csrfToken: string;
|
|
78
|
+
/**
|
|
79
|
+
* Set when the request includes one or more unnamed `vault:<verb>` scopes.
|
|
80
|
+
* The consent screen renders a vault selector; on submit, the picked vault
|
|
81
|
+
* narrows every unnamed scope to `vault:<picked>:<verb>` (Q1 of the
|
|
82
|
+
* vault-config-and-scopes design — force the picker, don't default).
|
|
83
|
+
*/
|
|
84
|
+
vaultPicker?: VaultPicker;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface VaultPicker {
|
|
88
|
+
/** Verbs (`read`, `write`, `admin`) requested in unnamed shape. */
|
|
89
|
+
unnamedVerbs: string[];
|
|
90
|
+
/** Vault names registered on this host. Empty → caller can't approve. */
|
|
91
|
+
availableVaults: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ErrorViewProps {
|
|
95
|
+
title: string;
|
|
96
|
+
message: string;
|
|
97
|
+
status: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderLogin(props: LoginViewProps): string {
|
|
101
|
+
const { params, errorMessage, csrfToken } = props;
|
|
102
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
103
|
+
const body = `
|
|
104
|
+
<div class="card">
|
|
105
|
+
<div class="card-header">
|
|
106
|
+
<div class="brand">
|
|
107
|
+
<span class="brand-mark">⌬</span>
|
|
108
|
+
<span class="brand-name">Parachute</span>
|
|
109
|
+
</div>
|
|
110
|
+
<h1>Sign in</h1>
|
|
111
|
+
<p class="subtitle">to continue to your hub</p>
|
|
112
|
+
</div>
|
|
113
|
+
${error}
|
|
114
|
+
<form method="POST" action="/oauth/authorize" class="auth-form">
|
|
115
|
+
<input type="hidden" name="__action" value="login" />
|
|
116
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
117
|
+
${renderHiddenInputs(params)}
|
|
118
|
+
<label class="field">
|
|
119
|
+
<span class="field-label">Username</span>
|
|
120
|
+
<input type="text" name="username" autocomplete="username" autofocus required />
|
|
121
|
+
</label>
|
|
122
|
+
<label class="field">
|
|
123
|
+
<span class="field-label">Password</span>
|
|
124
|
+
<input type="password" name="password" autocomplete="current-password" required />
|
|
125
|
+
</label>
|
|
126
|
+
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
127
|
+
</form>
|
|
128
|
+
</div>`;
|
|
129
|
+
return baseDocument("Sign in to Parachute Hub", body);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderConsent(props: ConsentViewProps): string {
|
|
133
|
+
const { params, clientName, clientId, scopes, vaultPicker, csrfToken } = props;
|
|
134
|
+
const scopeRows =
|
|
135
|
+
scopes.length === 0
|
|
136
|
+
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
137
|
+
: scopes.map(renderScopeRow).join("\n");
|
|
138
|
+
const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
|
|
139
|
+
const approveDisabled =
|
|
140
|
+
vaultPicker && vaultPicker.availableVaults.length === 0 ? " disabled" : "";
|
|
141
|
+
const body = `
|
|
142
|
+
<div class="card">
|
|
143
|
+
<div class="card-header">
|
|
144
|
+
<div class="brand">
|
|
145
|
+
<span class="brand-mark">⌬</span>
|
|
146
|
+
<span class="brand-name">Parachute</span>
|
|
147
|
+
</div>
|
|
148
|
+
<h1>Authorize <span class="client-name">${escapeHtml(clientName)}</span>?</h1>
|
|
149
|
+
<p class="subtitle">
|
|
150
|
+
This app is requesting access to your Parachute account.
|
|
151
|
+
</p>
|
|
152
|
+
<p class="client-meta">
|
|
153
|
+
<span class="client-meta-label">client_id</span>
|
|
154
|
+
<code>${escapeHtml(clientId)}</code>
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<section class="scopes">
|
|
158
|
+
<h2 class="scopes-title">Permissions requested</h2>
|
|
159
|
+
<ul class="scope-list">${scopeRows}</ul>
|
|
160
|
+
</section>
|
|
161
|
+
<form method="POST" action="/oauth/authorize" class="auth-form consent-form">
|
|
162
|
+
<input type="hidden" name="__action" value="consent" />
|
|
163
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
164
|
+
${renderHiddenInputs(params)}
|
|
165
|
+
${pickerSection}
|
|
166
|
+
<div class="button-row">
|
|
167
|
+
<button type="submit" name="approve" value="yes" class="btn btn-primary"${approveDisabled}>Approve</button>
|
|
168
|
+
<button type="submit" name="approve" value="no" class="btn btn-secondary">Deny</button>
|
|
169
|
+
</div>
|
|
170
|
+
</form>
|
|
171
|
+
</div>`;
|
|
172
|
+
return baseDocument(`Authorize ${clientName}`, body);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderVaultPicker(picker: VaultPicker): string {
|
|
176
|
+
const verbList = picker.unnamedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`).join(", ");
|
|
177
|
+
if (picker.availableVaults.length === 0) {
|
|
178
|
+
return `
|
|
179
|
+
<section class="vault-picker vault-picker-empty">
|
|
180
|
+
<h2 class="scopes-title">Pick a vault</h2>
|
|
181
|
+
<p class="picker-help">
|
|
182
|
+
${verbList} need to be bound to a specific vault, but no vaults exist on this host yet.
|
|
183
|
+
Create one with <code>parachute-vault create <name></code> and try again.
|
|
184
|
+
</p>
|
|
185
|
+
</section>`;
|
|
186
|
+
}
|
|
187
|
+
const options = picker.availableVaults
|
|
188
|
+
.map(
|
|
189
|
+
(name, i) => `
|
|
190
|
+
<label class="vault-option">
|
|
191
|
+
<input type="radio" name="vault_pick" value="${escapeHtml(name)}"${i === 0 ? " checked" : ""} required />
|
|
192
|
+
<span class="vault-option-name"><code>${escapeHtml(name)}</code></span>
|
|
193
|
+
</label>`,
|
|
194
|
+
)
|
|
195
|
+
.join("");
|
|
196
|
+
return `
|
|
197
|
+
<section class="vault-picker">
|
|
198
|
+
<h2 class="scopes-title">Pick a vault</h2>
|
|
199
|
+
<p class="picker-help">
|
|
200
|
+
${verbList} apply to the vault you select below.
|
|
201
|
+
</p>
|
|
202
|
+
<div class="vault-options">${options}
|
|
203
|
+
</div>
|
|
204
|
+
</section>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function renderError(props: ErrorViewProps): string {
|
|
208
|
+
const body = `
|
|
209
|
+
<div class="card">
|
|
210
|
+
<div class="card-header">
|
|
211
|
+
<div class="brand">
|
|
212
|
+
<span class="brand-mark">⌬</span>
|
|
213
|
+
<span class="brand-name">Parachute</span>
|
|
214
|
+
</div>
|
|
215
|
+
<h1 class="error-title">${escapeHtml(props.title)}</h1>
|
|
216
|
+
<p class="subtitle">${escapeHtml(props.message)}</p>
|
|
217
|
+
</div>
|
|
218
|
+
<p class="error-help">
|
|
219
|
+
If you reached this from a third-party app, the app's OAuth configuration
|
|
220
|
+
may be wrong. You can safely close this window.
|
|
221
|
+
</p>
|
|
222
|
+
</div>`;
|
|
223
|
+
return baseDocument(props.title, body);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderScopeRow(scope: string): string {
|
|
227
|
+
const explanation = explainScope(scope);
|
|
228
|
+
if (!explanation) {
|
|
229
|
+
return `<li class="scope scope-unknown">
|
|
230
|
+
<code class="scope-name">${escapeHtml(scope)}</code>
|
|
231
|
+
<span class="scope-label scope-label-muted">Defined by the requesting app — no built-in description.</span>
|
|
232
|
+
</li>`;
|
|
233
|
+
}
|
|
234
|
+
const cls = `scope scope-${explanation.level}`;
|
|
235
|
+
const badge = badgeForLevel(explanation);
|
|
236
|
+
return `<li class="${cls}">
|
|
237
|
+
<div class="scope-head">
|
|
238
|
+
<code class="scope-name">${escapeHtml(scope)}</code>
|
|
239
|
+
${badge}
|
|
240
|
+
</div>
|
|
241
|
+
<span class="scope-label">${escapeHtml(explanation.label)}</span>
|
|
242
|
+
</li>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function badgeForLevel(explanation: ScopeExplanation): string {
|
|
246
|
+
switch (explanation.level) {
|
|
247
|
+
case "admin":
|
|
248
|
+
return `<span class="badge badge-admin">admin</span>`;
|
|
249
|
+
case "write":
|
|
250
|
+
return `<span class="badge badge-write">write</span>`;
|
|
251
|
+
case "send":
|
|
252
|
+
return `<span class="badge badge-send">send</span>`;
|
|
253
|
+
case "read":
|
|
254
|
+
return `<span class="badge badge-read">read</span>`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function renderHiddenInputs(p: AuthorizeFormParams): string {
|
|
259
|
+
const fields: [string, string][] = [
|
|
260
|
+
["client_id", p.clientId],
|
|
261
|
+
["redirect_uri", p.redirectUri],
|
|
262
|
+
["response_type", p.responseType],
|
|
263
|
+
["scope", p.scope],
|
|
264
|
+
["code_challenge", p.codeChallenge],
|
|
265
|
+
["code_challenge_method", p.codeChallengeMethod],
|
|
266
|
+
];
|
|
267
|
+
if (p.state) fields.push(["state", p.state]);
|
|
268
|
+
return fields
|
|
269
|
+
.map(([k, v]) => `<input type="hidden" name="${k}" value="${escapeHtml(v)}" />`)
|
|
270
|
+
.join("\n ");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function baseDocument(title: string, body: string): string {
|
|
274
|
+
return `<!doctype html>
|
|
275
|
+
<html lang="en">
|
|
276
|
+
<head>
|
|
277
|
+
<meta charset="utf-8" />
|
|
278
|
+
<title>${escapeHtml(title)}</title>
|
|
279
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
280
|
+
<meta name="referrer" content="no-referrer" />
|
|
281
|
+
<style>${STYLES}</style>
|
|
282
|
+
</head>
|
|
283
|
+
<body>
|
|
284
|
+
<main>
|
|
285
|
+
${body}
|
|
286
|
+
</main>
|
|
287
|
+
</body>
|
|
288
|
+
</html>`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const STYLES = `
|
|
292
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
293
|
+
html, body { margin: 0; padding: 0; }
|
|
294
|
+
body {
|
|
295
|
+
font-family: ${FONT_SANS};
|
|
296
|
+
background: ${PALETTE.bg};
|
|
297
|
+
color: ${PALETTE.fg};
|
|
298
|
+
line-height: 1.55;
|
|
299
|
+
min-height: 100vh;
|
|
300
|
+
-webkit-font-smoothing: antialiased;
|
|
301
|
+
-moz-osx-font-smoothing: grayscale;
|
|
302
|
+
}
|
|
303
|
+
main {
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
justify-content: center;
|
|
307
|
+
min-height: 100vh;
|
|
308
|
+
padding: 1.5rem;
|
|
309
|
+
}
|
|
310
|
+
.card {
|
|
311
|
+
width: 100%;
|
|
312
|
+
max-width: 30rem;
|
|
313
|
+
background: ${PALETTE.cardBg};
|
|
314
|
+
border: 1px solid ${PALETTE.border};
|
|
315
|
+
border-radius: 12px;
|
|
316
|
+
padding: 2rem 1.75rem;
|
|
317
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
318
|
+
}
|
|
319
|
+
.card-header { margin-bottom: 1.5rem; }
|
|
320
|
+
.brand {
|
|
321
|
+
display: flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
gap: 0.5rem;
|
|
324
|
+
color: ${PALETTE.accent};
|
|
325
|
+
font-weight: 500;
|
|
326
|
+
font-size: 0.95rem;
|
|
327
|
+
margin-bottom: 1.25rem;
|
|
328
|
+
}
|
|
329
|
+
.brand-mark { font-size: 1.1rem; line-height: 1; }
|
|
330
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
331
|
+
h1 {
|
|
332
|
+
font-family: ${FONT_SERIF};
|
|
333
|
+
font-weight: 400;
|
|
334
|
+
font-size: 1.75rem;
|
|
335
|
+
line-height: 1.2;
|
|
336
|
+
margin: 0 0 0.4rem;
|
|
337
|
+
color: ${PALETTE.fg};
|
|
338
|
+
}
|
|
339
|
+
h1 .client-name {
|
|
340
|
+
font-style: italic;
|
|
341
|
+
color: ${PALETTE.accent};
|
|
342
|
+
}
|
|
343
|
+
.subtitle {
|
|
344
|
+
margin: 0;
|
|
345
|
+
color: ${PALETTE.fgMuted};
|
|
346
|
+
font-size: 0.95rem;
|
|
347
|
+
}
|
|
348
|
+
.client-meta {
|
|
349
|
+
margin: 0.75rem 0 0;
|
|
350
|
+
font-size: 0.8rem;
|
|
351
|
+
color: ${PALETTE.fgDim};
|
|
352
|
+
display: flex;
|
|
353
|
+
gap: 0.4rem;
|
|
354
|
+
align-items: baseline;
|
|
355
|
+
flex-wrap: wrap;
|
|
356
|
+
}
|
|
357
|
+
.client-meta-label { text-transform: uppercase; letter-spacing: 0.05em; }
|
|
358
|
+
.client-meta code {
|
|
359
|
+
font-family: ${FONT_MONO};
|
|
360
|
+
font-size: 0.78rem;
|
|
361
|
+
background: ${PALETTE.bgSoft};
|
|
362
|
+
padding: 0.1rem 0.4rem;
|
|
363
|
+
border-radius: 4px;
|
|
364
|
+
color: ${PALETTE.fgMuted};
|
|
365
|
+
word-break: break-all;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
|
369
|
+
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
370
|
+
.field-label {
|
|
371
|
+
font-size: 0.85rem;
|
|
372
|
+
font-weight: 500;
|
|
373
|
+
color: ${PALETTE.fgMuted};
|
|
374
|
+
letter-spacing: 0.01em;
|
|
375
|
+
}
|
|
376
|
+
input[type=text], input[type=password] {
|
|
377
|
+
font: inherit;
|
|
378
|
+
width: 100%;
|
|
379
|
+
padding: 0.6rem 0.75rem;
|
|
380
|
+
border: 1px solid ${PALETTE.border};
|
|
381
|
+
border-radius: 6px;
|
|
382
|
+
background: ${PALETTE.bg};
|
|
383
|
+
color: ${PALETTE.fg};
|
|
384
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
385
|
+
}
|
|
386
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
387
|
+
outline: none;
|
|
388
|
+
border-color: ${PALETTE.accent};
|
|
389
|
+
background: ${PALETTE.cardBg};
|
|
390
|
+
box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.btn {
|
|
394
|
+
font: inherit;
|
|
395
|
+
font-weight: 500;
|
|
396
|
+
padding: 0.65rem 1.25rem;
|
|
397
|
+
border-radius: 6px;
|
|
398
|
+
border: 1px solid transparent;
|
|
399
|
+
cursor: pointer;
|
|
400
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
401
|
+
min-height: 2.5rem;
|
|
402
|
+
}
|
|
403
|
+
.btn-primary {
|
|
404
|
+
background: ${PALETTE.accent};
|
|
405
|
+
color: ${PALETTE.cardBg};
|
|
406
|
+
margin-top: 0.4rem;
|
|
407
|
+
}
|
|
408
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
409
|
+
.btn-secondary {
|
|
410
|
+
background: ${PALETTE.cardBg};
|
|
411
|
+
color: ${PALETTE.fgMuted};
|
|
412
|
+
border-color: ${PALETTE.border};
|
|
413
|
+
}
|
|
414
|
+
.btn-secondary:hover {
|
|
415
|
+
color: ${PALETTE.fg};
|
|
416
|
+
border-color: ${PALETTE.fgDim};
|
|
417
|
+
}
|
|
418
|
+
.button-row {
|
|
419
|
+
display: flex;
|
|
420
|
+
gap: 0.6rem;
|
|
421
|
+
margin-top: 0.5rem;
|
|
422
|
+
}
|
|
423
|
+
.button-row .btn { flex: 1; }
|
|
424
|
+
.consent-form { gap: 0; }
|
|
425
|
+
|
|
426
|
+
.error-banner {
|
|
427
|
+
background: ${PALETTE.dangerSoft};
|
|
428
|
+
border: 1px solid ${PALETTE.danger};
|
|
429
|
+
border-radius: 6px;
|
|
430
|
+
color: ${PALETTE.danger};
|
|
431
|
+
padding: 0.6rem 0.8rem;
|
|
432
|
+
margin: 0 0 1rem;
|
|
433
|
+
font-size: 0.9rem;
|
|
434
|
+
}
|
|
435
|
+
.error-title { color: ${PALETTE.danger}; }
|
|
436
|
+
.error-help {
|
|
437
|
+
margin-top: 1.5rem;
|
|
438
|
+
padding-top: 1.25rem;
|
|
439
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
440
|
+
color: ${PALETTE.fgMuted};
|
|
441
|
+
font-size: 0.88rem;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.scopes { margin: 0 0 1.5rem; }
|
|
445
|
+
.scopes-title {
|
|
446
|
+
font-family: ${FONT_SANS};
|
|
447
|
+
font-size: 0.78rem;
|
|
448
|
+
font-weight: 600;
|
|
449
|
+
color: ${PALETTE.fgMuted};
|
|
450
|
+
text-transform: uppercase;
|
|
451
|
+
letter-spacing: 0.06em;
|
|
452
|
+
margin: 0 0 0.6rem;
|
|
453
|
+
}
|
|
454
|
+
.scope-list {
|
|
455
|
+
list-style: none;
|
|
456
|
+
margin: 0;
|
|
457
|
+
padding: 0;
|
|
458
|
+
display: flex;
|
|
459
|
+
flex-direction: column;
|
|
460
|
+
gap: 0.5rem;
|
|
461
|
+
}
|
|
462
|
+
.scope {
|
|
463
|
+
border: 1px solid ${PALETTE.border};
|
|
464
|
+
border-radius: 6px;
|
|
465
|
+
padding: 0.6rem 0.75rem;
|
|
466
|
+
background: ${PALETTE.bg};
|
|
467
|
+
}
|
|
468
|
+
.scope-empty {
|
|
469
|
+
color: ${PALETTE.fgMuted};
|
|
470
|
+
font-size: 0.9rem;
|
|
471
|
+
background: ${PALETTE.bgSoft};
|
|
472
|
+
border-style: dashed;
|
|
473
|
+
}
|
|
474
|
+
.scope-head {
|
|
475
|
+
display: flex;
|
|
476
|
+
align-items: center;
|
|
477
|
+
gap: 0.5rem;
|
|
478
|
+
margin-bottom: 0.2rem;
|
|
479
|
+
flex-wrap: wrap;
|
|
480
|
+
}
|
|
481
|
+
.scope-name {
|
|
482
|
+
font-family: ${FONT_MONO};
|
|
483
|
+
font-size: 0.85rem;
|
|
484
|
+
color: ${PALETTE.fg};
|
|
485
|
+
}
|
|
486
|
+
.scope-label {
|
|
487
|
+
font-size: 0.88rem;
|
|
488
|
+
color: ${PALETTE.fgMuted};
|
|
489
|
+
display: block;
|
|
490
|
+
}
|
|
491
|
+
.scope-label-muted { color: ${PALETTE.fgDim}; font-style: italic; }
|
|
492
|
+
.scope-admin {
|
|
493
|
+
border-color: ${PALETTE.danger};
|
|
494
|
+
background: ${PALETTE.dangerSoft};
|
|
495
|
+
}
|
|
496
|
+
.scope-admin .scope-name { color: ${PALETTE.danger}; }
|
|
497
|
+
|
|
498
|
+
.vault-picker {
|
|
499
|
+
margin: 0 0 1.25rem;
|
|
500
|
+
padding: 0.75rem 0.85rem;
|
|
501
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
502
|
+
border-radius: 6px;
|
|
503
|
+
background: ${PALETTE.bgSoft};
|
|
504
|
+
}
|
|
505
|
+
.vault-picker .scopes-title { margin-bottom: 0.4rem; }
|
|
506
|
+
.picker-help {
|
|
507
|
+
margin: 0 0 0.6rem;
|
|
508
|
+
font-size: 0.88rem;
|
|
509
|
+
color: ${PALETTE.fgMuted};
|
|
510
|
+
}
|
|
511
|
+
.picker-help code {
|
|
512
|
+
font-family: ${FONT_MONO};
|
|
513
|
+
font-size: 0.82rem;
|
|
514
|
+
background: ${PALETTE.cardBg};
|
|
515
|
+
padding: 0.05rem 0.35rem;
|
|
516
|
+
border-radius: 4px;
|
|
517
|
+
color: ${PALETTE.fg};
|
|
518
|
+
}
|
|
519
|
+
.vault-options {
|
|
520
|
+
display: flex;
|
|
521
|
+
flex-direction: column;
|
|
522
|
+
gap: 0.4rem;
|
|
523
|
+
}
|
|
524
|
+
.vault-option {
|
|
525
|
+
display: flex;
|
|
526
|
+
align-items: center;
|
|
527
|
+
gap: 0.5rem;
|
|
528
|
+
padding: 0.45rem 0.6rem;
|
|
529
|
+
border: 1px solid ${PALETTE.border};
|
|
530
|
+
border-radius: 6px;
|
|
531
|
+
background: ${PALETTE.cardBg};
|
|
532
|
+
cursor: pointer;
|
|
533
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
534
|
+
}
|
|
535
|
+
.vault-option:hover { border-color: ${PALETTE.accent}; }
|
|
536
|
+
.vault-option input[type=radio]:focus { outline: 2px solid ${PALETTE.accent}; outline-offset: 2px; }
|
|
537
|
+
.vault-option-name code {
|
|
538
|
+
font-family: ${FONT_MONO};
|
|
539
|
+
font-size: 0.88rem;
|
|
540
|
+
color: ${PALETTE.fg};
|
|
541
|
+
}
|
|
542
|
+
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
543
|
+
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
544
|
+
|
|
545
|
+
.badge {
|
|
546
|
+
display: inline-block;
|
|
547
|
+
font-size: 0.7rem;
|
|
548
|
+
text-transform: uppercase;
|
|
549
|
+
letter-spacing: 0.06em;
|
|
550
|
+
font-weight: 600;
|
|
551
|
+
padding: 0.1rem 0.45rem;
|
|
552
|
+
border-radius: 999px;
|
|
553
|
+
line-height: 1.4;
|
|
554
|
+
}
|
|
555
|
+
.badge-read { background: ${PALETTE.bgSoft}; color: ${PALETTE.fgMuted}; }
|
|
556
|
+
.badge-write { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
|
|
557
|
+
.badge-send { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
|
|
558
|
+
.badge-admin { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
|
|
559
|
+
|
|
560
|
+
@media (max-width: 480px) {
|
|
561
|
+
main { padding: 0.75rem; }
|
|
562
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
563
|
+
h1 { font-size: 1.5rem; }
|
|
564
|
+
.button-row { flex-direction: column; }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
@media (prefers-color-scheme: dark) {
|
|
568
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
569
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
570
|
+
h1 { color: #f0ece4; }
|
|
571
|
+
.subtitle, .field-label { color: #a8a29a; }
|
|
572
|
+
input[type=text], input[type=password] { background: #1f1c18; border-color: #3a362f; color: #e8e4dc; }
|
|
573
|
+
input[type=text]:focus, input[type=password]:focus { background: #25221d; }
|
|
574
|
+
.scope { background: #1f1c18; border-color: #3a362f; }
|
|
575
|
+
.scope-name { color: #e8e4dc; }
|
|
576
|
+
.client-meta code { background: #1f1c18; color: #a8a29a; }
|
|
577
|
+
.btn-secondary { background: #25221d; border-color: #3a362f; color: #a8a29a; }
|
|
578
|
+
.btn-secondary:hover { color: #e8e4dc; border-color: #6b6860; }
|
|
579
|
+
.error-help { border-color: #3a362f; color: #a8a29a; }
|
|
580
|
+
.scope-empty { background: #1a1815; }
|
|
581
|
+
}
|
|
582
|
+
`;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator token — long-lived hub-issued JWT that local CLI tools use to
|
|
3
|
+
* authenticate against on-box services (vault / scribe / channel) without
|
|
4
|
+
* running an interactive OAuth dance every time.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: modules require auth on every request — there is no
|
|
7
|
+
* "loopback is trusted" bypass, because browser extensions and compromised
|
|
8
|
+
* postinstalls can hit 127.0.0.1 too. The operator token is the on-box
|
|
9
|
+
* caller's bearer credential; it lives in `~/.parachute/operator.token`
|
|
10
|
+
* with mode 0600 so a different unix user can't read it.
|
|
11
|
+
*
|
|
12
|
+
* Browser apps follow the OAuth flow and never touch this file. Service
|
|
13
|
+
* accounts (cron jobs, oncall scripts) read it; that's the whole point.
|
|
14
|
+
*
|
|
15
|
+
* Rotation: cheap. `parachute auth rotate-operator` mints a fresh token
|
|
16
|
+
* and overwrites the file. The previous token is *not* revoked at the
|
|
17
|
+
* issuer — the hub doesn't track operator-token jtis — so a leaked file
|
|
18
|
+
* stays valid until its 1-year TTL elapses. Treat operator.token like an
|
|
19
|
+
* SSH private key.
|
|
20
|
+
*/
|
|
21
|
+
import type { Database } from "bun:sqlite";
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { configDir } from "./config.ts";
|
|
25
|
+
import { signAccessToken } from "./jwt-sign.ts";
|
|
26
|
+
|
|
27
|
+
export const OPERATOR_TOKEN_FILENAME = "operator.token";
|
|
28
|
+
export const OPERATOR_TOKEN_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
29
|
+
export const OPERATOR_TOKEN_AUDIENCE = "operator";
|
|
30
|
+
export const OPERATOR_TOKEN_CLIENT_ID = "parachute-hub";
|
|
31
|
+
export const OPERATOR_TOKEN_SCOPES = [
|
|
32
|
+
"hub:admin",
|
|
33
|
+
"parachute:host:admin",
|
|
34
|
+
"vault:admin",
|
|
35
|
+
"scribe:admin",
|
|
36
|
+
"channel:send",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function operatorTokenPath(dir: string = configDir()): string {
|
|
40
|
+
return join(dir, OPERATOR_TOKEN_FILENAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MintOperatorTokenOpts {
|
|
44
|
+
/**
|
|
45
|
+
* Hub origin — written into the JWT's `iss` claim. On-box services
|
|
46
|
+
* (vault, scribe, channel) reject tokens whose `iss` doesn't match the
|
|
47
|
+
* `PARACHUTE_HUB_ORIGIN` they were started with. Callers derive this via
|
|
48
|
+
* `deriveHubOrigin()`.
|
|
49
|
+
*/
|
|
50
|
+
issuer: string;
|
|
51
|
+
/** Override the JWT-sign clock — tests pin time. */
|
|
52
|
+
now?: () => Date;
|
|
53
|
+
/** Override the random jti — tests pin it. */
|
|
54
|
+
jti?: string;
|
|
55
|
+
/** Override the audience claim. Defaults to "operator". */
|
|
56
|
+
audience?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function mintOperatorToken(
|
|
60
|
+
db: Database,
|
|
61
|
+
userId: string,
|
|
62
|
+
opts: MintOperatorTokenOpts,
|
|
63
|
+
): Promise<{ token: string; jti: string; expiresAt: string }> {
|
|
64
|
+
return signAccessToken(db, {
|
|
65
|
+
sub: userId,
|
|
66
|
+
scopes: OPERATOR_TOKEN_SCOPES,
|
|
67
|
+
audience: opts.audience ?? OPERATOR_TOKEN_AUDIENCE,
|
|
68
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
69
|
+
issuer: opts.issuer,
|
|
70
|
+
ttlSeconds: OPERATOR_TOKEN_TTL_SECONDS,
|
|
71
|
+
...(opts.jti !== undefined ? { jti: opts.jti } : {}),
|
|
72
|
+
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Atomically writes the token to `<dir>/operator.token` with mode 0600.
|
|
78
|
+
* Atomic = write to `<path>.tmp` then rename, so a half-written file never
|
|
79
|
+
* exists at the canonical path.
|
|
80
|
+
*/
|
|
81
|
+
export async function writeOperatorTokenFile(
|
|
82
|
+
token: string,
|
|
83
|
+
dir: string = configDir(),
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
await fs.mkdir(dir, { recursive: true });
|
|
86
|
+
const path = operatorTokenPath(dir);
|
|
87
|
+
const tmp = `${path}.tmp`;
|
|
88
|
+
await fs.writeFile(tmp, `${token}\n`, { mode: 0o600 });
|
|
89
|
+
await fs.rename(tmp, path);
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads the operator token file, trims trailing whitespace. Returns null
|
|
95
|
+
* if the file doesn't exist (caller decides whether that's an error). Any
|
|
96
|
+
* other read error propagates.
|
|
97
|
+
*/
|
|
98
|
+
export async function readOperatorTokenFile(dir: string = configDir()): Promise<string | null> {
|
|
99
|
+
const path = operatorTokenPath(dir);
|
|
100
|
+
try {
|
|
101
|
+
const buf = await fs.readFile(path, "utf8");
|
|
102
|
+
const trimmed = buf.trim();
|
|
103
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface IssueOperatorTokenResult {
|
|
111
|
+
token: string;
|
|
112
|
+
jti: string;
|
|
113
|
+
expiresAt: string;
|
|
114
|
+
path: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mint + write in one call. Used by `parachute auth set-password` (after
|
|
119
|
+
* password set) and `parachute auth rotate-operator`.
|
|
120
|
+
*/
|
|
121
|
+
export async function issueOperatorToken(
|
|
122
|
+
db: Database,
|
|
123
|
+
userId: string,
|
|
124
|
+
opts: MintOperatorTokenOpts & { dir?: string },
|
|
125
|
+
): Promise<IssueOperatorTokenResult> {
|
|
126
|
+
const minted = await mintOperatorToken(db, userId, opts);
|
|
127
|
+
const path = await writeOperatorTokenFile(minted.token, opts.dir);
|
|
128
|
+
return { ...minted, path };
|
|
129
|
+
}
|