@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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded HTML for the hub's pre-auth surfaces: the `/login` form and the
|
|
3
|
+
* generic admin error page surfaced when CSRF or rate-limit gates fire on
|
|
4
|
+
* `/login` and `/logout`. Same privacy posture as `oauth-ui.ts` (no third-
|
|
5
|
+
* party fonts, inline CSS, no JS) — these pages are pre-auth and have to
|
|
6
|
+
* stand alone without the SPA shell.
|
|
7
|
+
*
|
|
8
|
+
* History: this file was `admin-config-ui.ts` and held the server-rendered
|
|
9
|
+
* `/admin/config` module-config portal (hub#46). #240 retired the portal
|
|
10
|
+
* post-SPA-rework; the file shed everything except the two renderers below.
|
|
11
|
+
* Renamed to `admin-login-ui.ts` in #241 so the filename matches the content.
|
|
12
|
+
*
|
|
13
|
+
* Pure functions — DB, sessions live in `admin-handlers.ts`.
|
|
14
|
+
*/
|
|
15
|
+
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
16
|
+
import { escapeHtml } from "./oauth-ui.ts";
|
|
17
|
+
|
|
18
|
+
// --- shared chrome ---------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const PALETTE = {
|
|
21
|
+
bg: "#faf8f4",
|
|
22
|
+
bgSoft: "#f3f0ea",
|
|
23
|
+
fg: "#2c2a26",
|
|
24
|
+
fgMuted: "#6b6860",
|
|
25
|
+
fgDim: "#9a9690",
|
|
26
|
+
accent: "#4a7c59",
|
|
27
|
+
accentHover: "#3d6849",
|
|
28
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
29
|
+
border: "#e4e0d8",
|
|
30
|
+
borderLight: "#ece9e2",
|
|
31
|
+
cardBg: "#ffffff",
|
|
32
|
+
danger: "#a3392b",
|
|
33
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
34
|
+
success: "#3d6849",
|
|
35
|
+
successSoft: "rgba(61, 104, 73, 0.08)",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
39
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
40
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
41
|
+
|
|
42
|
+
function escapeAttr(s: string): string {
|
|
43
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function baseDocument(title: string, body: string): string {
|
|
47
|
+
return `<!doctype html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8" />
|
|
51
|
+
<title>${escapeHtml(title)}</title>
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
53
|
+
<meta name="referrer" content="no-referrer" />
|
|
54
|
+
<style>${STYLES}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<main>
|
|
58
|
+
${body}
|
|
59
|
+
</main>
|
|
60
|
+
</body>
|
|
61
|
+
</html>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function header(): string {
|
|
65
|
+
return `
|
|
66
|
+
<div class="brand">
|
|
67
|
+
<span class="brand-mark">⌬</span>
|
|
68
|
+
<span class="brand-name">Parachute</span>
|
|
69
|
+
<span class="brand-tag">admin</span>
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- /login ---------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export interface AdminLoginProps {
|
|
76
|
+
/** Continuation path after successful login — submitted as a hidden field. */
|
|
77
|
+
next: string;
|
|
78
|
+
csrfToken: string;
|
|
79
|
+
errorMessage?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderAdminLogin(props: AdminLoginProps): string {
|
|
83
|
+
const { next, csrfToken, errorMessage } = props;
|
|
84
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
85
|
+
const body = `
|
|
86
|
+
<div class="card">
|
|
87
|
+
<div class="card-header">
|
|
88
|
+
${header()}
|
|
89
|
+
<h1>Sign in</h1>
|
|
90
|
+
<p class="subtitle">to administer this hub</p>
|
|
91
|
+
</div>
|
|
92
|
+
${error}
|
|
93
|
+
<form method="POST" action="/login" class="auth-form">
|
|
94
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
95
|
+
<input type="hidden" name="next" value="${escapeAttr(next)}" />
|
|
96
|
+
<label class="field">
|
|
97
|
+
<span class="field-label">Username</span>
|
|
98
|
+
<input type="text" name="username" autocomplete="username" autofocus required />
|
|
99
|
+
</label>
|
|
100
|
+
<label class="field">
|
|
101
|
+
<span class="field-label">Password</span>
|
|
102
|
+
<input type="password" name="password" autocomplete="current-password" required />
|
|
103
|
+
</label>
|
|
104
|
+
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
105
|
+
</form>
|
|
106
|
+
</div>`;
|
|
107
|
+
return baseDocument("Sign in to Parachute Hub admin", body);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- error page ------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export function renderAdminError(props: { title: string; message: string }): string {
|
|
113
|
+
const body = `
|
|
114
|
+
<div class="card">
|
|
115
|
+
${header()}
|
|
116
|
+
<h1 class="error-title">${escapeHtml(props.title)}</h1>
|
|
117
|
+
<p class="subtitle">${escapeHtml(props.message)}</p>
|
|
118
|
+
</div>`;
|
|
119
|
+
return baseDocument(props.title, body);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- styles ----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const STYLES = `
|
|
125
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
126
|
+
html, body { margin: 0; padding: 0; }
|
|
127
|
+
body {
|
|
128
|
+
font-family: ${FONT_SANS};
|
|
129
|
+
background: ${PALETTE.bg};
|
|
130
|
+
color: ${PALETTE.fg};
|
|
131
|
+
line-height: 1.55;
|
|
132
|
+
min-height: 100vh;
|
|
133
|
+
-webkit-font-smoothing: antialiased;
|
|
134
|
+
-moz-osx-font-smoothing: grayscale;
|
|
135
|
+
}
|
|
136
|
+
main {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
min-height: 100vh;
|
|
141
|
+
padding: 1.5rem;
|
|
142
|
+
}
|
|
143
|
+
.card {
|
|
144
|
+
width: 100%;
|
|
145
|
+
max-width: 30rem;
|
|
146
|
+
background: ${PALETTE.cardBg};
|
|
147
|
+
border: 1px solid ${PALETTE.border};
|
|
148
|
+
border-radius: 12px;
|
|
149
|
+
padding: 2rem 1.75rem;
|
|
150
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
151
|
+
}
|
|
152
|
+
.card-header { margin-bottom: 1.5rem; }
|
|
153
|
+
.brand {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 0.5rem;
|
|
157
|
+
color: ${PALETTE.accent};
|
|
158
|
+
font-weight: 500;
|
|
159
|
+
font-size: 0.95rem;
|
|
160
|
+
margin-bottom: 1.25rem;
|
|
161
|
+
}
|
|
162
|
+
.brand-mark { font-size: 1.1rem; line-height: 1; }
|
|
163
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
164
|
+
.brand-tag {
|
|
165
|
+
text-transform: uppercase;
|
|
166
|
+
letter-spacing: 0.06em;
|
|
167
|
+
font-size: 0.7rem;
|
|
168
|
+
color: ${PALETTE.fgMuted};
|
|
169
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
170
|
+
padding: 0.05rem 0.4rem;
|
|
171
|
+
border-radius: 999px;
|
|
172
|
+
}
|
|
173
|
+
h1 {
|
|
174
|
+
font-family: ${FONT_SERIF};
|
|
175
|
+
font-weight: 400;
|
|
176
|
+
font-size: 1.75rem;
|
|
177
|
+
line-height: 1.2;
|
|
178
|
+
margin: 0 0 0.4rem;
|
|
179
|
+
color: ${PALETTE.fg};
|
|
180
|
+
}
|
|
181
|
+
.subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
|
|
182
|
+
|
|
183
|
+
.auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
|
184
|
+
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
185
|
+
.field-label {
|
|
186
|
+
font-size: 0.85rem;
|
|
187
|
+
font-weight: 500;
|
|
188
|
+
color: ${PALETTE.fgMuted};
|
|
189
|
+
letter-spacing: 0.01em;
|
|
190
|
+
font-family: ${FONT_MONO};
|
|
191
|
+
}
|
|
192
|
+
input[type=text], input[type=password] {
|
|
193
|
+
font: inherit;
|
|
194
|
+
width: 100%;
|
|
195
|
+
padding: 0.6rem 0.75rem;
|
|
196
|
+
border: 1px solid ${PALETTE.border};
|
|
197
|
+
border-radius: 6px;
|
|
198
|
+
background: ${PALETTE.bg};
|
|
199
|
+
color: ${PALETTE.fg};
|
|
200
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
201
|
+
}
|
|
202
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
203
|
+
outline: none;
|
|
204
|
+
border-color: ${PALETTE.accent};
|
|
205
|
+
background: ${PALETTE.cardBg};
|
|
206
|
+
box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.btn {
|
|
210
|
+
font: inherit;
|
|
211
|
+
font-weight: 500;
|
|
212
|
+
padding: 0.65rem 1.25rem;
|
|
213
|
+
border-radius: 6px;
|
|
214
|
+
border: 1px solid transparent;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
217
|
+
min-height: 2.5rem;
|
|
218
|
+
}
|
|
219
|
+
.btn-primary {
|
|
220
|
+
background: ${PALETTE.accent};
|
|
221
|
+
color: ${PALETTE.cardBg};
|
|
222
|
+
margin-top: 0.4rem;
|
|
223
|
+
}
|
|
224
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
225
|
+
|
|
226
|
+
.error-banner {
|
|
227
|
+
background: ${PALETTE.dangerSoft};
|
|
228
|
+
border: 1px solid ${PALETTE.danger};
|
|
229
|
+
border-radius: 6px;
|
|
230
|
+
color: ${PALETTE.danger};
|
|
231
|
+
padding: 0.6rem 0.8rem;
|
|
232
|
+
margin: 0 0 1rem;
|
|
233
|
+
font-size: 0.9rem;
|
|
234
|
+
}
|
|
235
|
+
.error-title { color: ${PALETTE.danger}; }
|
|
236
|
+
|
|
237
|
+
@media (max-width: 480px) {
|
|
238
|
+
main { padding: 0.75rem; }
|
|
239
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
240
|
+
h1 { font-size: 1.5rem; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@media (prefers-color-scheme: dark) {
|
|
244
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
245
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
246
|
+
h1 { color: #f0ece4; }
|
|
247
|
+
.subtitle, .field-label { color: #a8a29a; }
|
|
248
|
+
input[type=text], input[type=password] {
|
|
249
|
+
background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
|
|
250
|
+
}
|
|
251
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
252
|
+
background: #25221d;
|
|
253
|
+
}
|
|
254
|
+
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
255
|
+
}
|
|
256
|
+
`;
|
|
@@ -55,7 +55,7 @@ export async function handleVaultAdminToken(
|
|
|
55
55
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
56
56
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
57
57
|
if (!session) {
|
|
58
|
-
return jsonError(401, "unauthenticated", "no admin session — sign in at /
|
|
58
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
59
59
|
}
|
|
60
60
|
const scope = `vault:${vaultName}:admin`;
|
|
61
61
|
// Per-vault audience: vault validates the JWT's `aud` claim against
|
package/src/api-me.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/me` — public who-am-I endpoint for hub-served surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Reads the `parachute_hub_session` cookie. If present and active, returns
|
|
5
|
+
* the user identity AND a CSRF token bound to the existing CSRF cookie (or
|
|
6
|
+
* a freshly-minted one). Otherwise returns the minimal `{ hasSession: false }`
|
|
7
|
+
* payload.
|
|
8
|
+
*
|
|
9
|
+
* Public — no auth required (returns "not signed in" rather than 401 when
|
|
10
|
+
* no session, so the SPA / discovery page can render a consistent affordance
|
|
11
|
+
* regardless of auth state). No CORS — same-origin only; hub-served UIs
|
|
12
|
+
* are same-origin.
|
|
13
|
+
*
|
|
14
|
+
* Response shape:
|
|
15
|
+
*
|
|
16
|
+
* { hasSession: false }
|
|
17
|
+
* { hasSession: true, user: { id, displayName }, csrf: "<token>" }
|
|
18
|
+
*
|
|
19
|
+
* `displayName` is the user's `username` today — there's no separate
|
|
20
|
+
* display-name field on the User shape. Surfaced under a different key
|
|
21
|
+
* here so a future profile-name migration can land without breaking
|
|
22
|
+
* SPA / discovery consumers.
|
|
23
|
+
*
|
|
24
|
+
* Why include the CSRF token only when signed in: there's nothing to
|
|
25
|
+
* sign-out (or otherwise mutate) without a session. Minting a token in
|
|
26
|
+
* the unsigned-in case would just bloat the response and prime a cookie
|
|
27
|
+
* the consumer has no use for.
|
|
28
|
+
*
|
|
29
|
+
* Why a dedicated endpoint rather than probing a session-gated SPA page:
|
|
30
|
+
* those redirect to /login when unauthenticated, which is exactly the
|
|
31
|
+
* wrong UX for an unconditionally-fetched "show sign-in affordance" call.
|
|
32
|
+
* `/api/me` cleanly returns either state without a bounce.
|
|
33
|
+
*
|
|
34
|
+
* The CSRF token returned here is the same token any same-session
|
|
35
|
+
* `<form>` would carry — the consumer (SPA fetch POST or
|
|
36
|
+
* server-rendered discovery form) submits it back as `__csrf` against
|
|
37
|
+
* the existing logout / mutation handlers.
|
|
38
|
+
*/
|
|
39
|
+
import type { Database } from "bun:sqlite";
|
|
40
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
41
|
+
import { findActiveSession } from "./sessions.ts";
|
|
42
|
+
import { getUserById } from "./users.ts";
|
|
43
|
+
|
|
44
|
+
export interface ApiMeDeps {
|
|
45
|
+
db: Database;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SignedInUser {
|
|
49
|
+
id: string;
|
|
50
|
+
displayName: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Discriminated union mirroring the client-side `MeResponse` shape in
|
|
55
|
+
* `web/ui/src/lib/api.ts`. The two early returns below (no-session,
|
|
56
|
+
* deleted-user) construct the `false` arm; the success path constructs
|
|
57
|
+
* the `true` arm. Typing it as a union (rather than an interface with
|
|
58
|
+
* optional fields) means the compiler refuses any future construction
|
|
59
|
+
* that mixes states — e.g. `{ hasSession: false, user: staleUser }`
|
|
60
|
+
* fails at the type-check, not just at code-review.
|
|
61
|
+
*/
|
|
62
|
+
type ApiMeResponse = { hasSession: false } | { hasSession: true; user: SignedInUser; csrf: string };
|
|
63
|
+
|
|
64
|
+
export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
|
|
65
|
+
if (req.method !== "GET") {
|
|
66
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const session = findActiveSession(deps.db, req);
|
|
70
|
+
if (!session) {
|
|
71
|
+
return ok({ hasSession: false });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const user = getUserById(deps.db, session.userId);
|
|
75
|
+
if (!user) {
|
|
76
|
+
// Session row points at a deleted user — treat as not signed in.
|
|
77
|
+
// The session row should be cleaned up by some future sweep, but
|
|
78
|
+
// surfacing a stale identity to the UI would be worse than a
|
|
79
|
+
// momentary "signed out" affordance.
|
|
80
|
+
return ok({ hasSession: false });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mint a CSRF token (or reuse the existing cookie's). When this is the
|
|
84
|
+
// first request to set the cookie, attach Set-Cookie so the browser
|
|
85
|
+
// stores it for future logout submission.
|
|
86
|
+
const csrf = ensureCsrfToken(req);
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
"content-type": "application/json",
|
|
89
|
+
// Don't cache — session can change at any time (login, logout,
|
|
90
|
+
// expiry). The endpoint is cheap; revalidate on every request.
|
|
91
|
+
"cache-control": "no-store",
|
|
92
|
+
};
|
|
93
|
+
if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
|
|
94
|
+
|
|
95
|
+
const body: ApiMeResponse = {
|
|
96
|
+
hasSession: true,
|
|
97
|
+
user: {
|
|
98
|
+
id: user.id,
|
|
99
|
+
displayName: user.username,
|
|
100
|
+
},
|
|
101
|
+
csrf: csrf.token,
|
|
102
|
+
};
|
|
103
|
+
return new Response(JSON.stringify(body), { status: 200, headers });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ok(body: ApiMeResponse): Response {
|
|
107
|
+
return new Response(JSON.stringify(body), {
|
|
108
|
+
status: 200,
|
|
109
|
+
headers: {
|
|
110
|
+
"content-type": "application/json",
|
|
111
|
+
"cache-control": "no-store",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
117
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
118
|
+
status,
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "application/json",
|
|
121
|
+
"cache-control": "no-store",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/auth/mint-token` — HTTP companion to `parachute auth mint-token`.
|
|
3
|
+
*
|
|
4
|
+
* Same arg/return shape as the CLI; just the network path. Used by:
|
|
5
|
+
*
|
|
6
|
+
* - automation that doesn't have CLI access (CI runners, cloud agents)
|
|
7
|
+
* but does hold an operator-bearer with `parachute:host:auth` scope;
|
|
8
|
+
* - the future admin SPA when the operator wants to mint a one-shot
|
|
9
|
+
* scope-narrow token without dropping to a terminal.
|
|
10
|
+
*
|
|
11
|
+
* Auth: `Authorization: Bearer <token>` where `token`'s `scope` claim
|
|
12
|
+
* contains `parachute:host:auth`. The operator's local operator.token
|
|
13
|
+
* (admin scope-set) covers this; a narrow `--scope-set=auth` operator
|
|
14
|
+
* token also covers this.
|
|
15
|
+
*
|
|
16
|
+
* Why a separate endpoint instead of extending /admin/host-admin-token:
|
|
17
|
+
* that endpoint is session-cookie-gated for the SPA's needs and only
|
|
18
|
+
* mints `parachute:host:admin`. This endpoint is bearer-gated for
|
|
19
|
+
* automation and mints arbitrary scope/permissions tuples per request.
|
|
20
|
+
*
|
|
21
|
+
* Every successful mint writes a row to the `tokens` registry
|
|
22
|
+
* (`created_via='cli_mint'` — same provenance as the CLI path, since
|
|
23
|
+
* HTTP mint is just CLI-by-network). Powers the
|
|
24
|
+
* `/.well-known/parachute-revocation.json` endpoint.
|
|
25
|
+
*/
|
|
26
|
+
import type { Database } from "bun:sqlite";
|
|
27
|
+
import { inferAudience } from "./jwt-audience.ts";
|
|
28
|
+
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
29
|
+
import { isNonRequestableScope } from "./scope-explanations.ts";
|
|
30
|
+
|
|
31
|
+
/** Default lifetime when --expires-in / `expires_in` is omitted. Matches the CLI. */
|
|
32
|
+
export const API_MINT_TOKEN_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
33
|
+
/** Hard cap. Matches the CLI's --expires-in upper bound. */
|
|
34
|
+
export const API_MINT_TOKEN_MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
35
|
+
/** Scope required on the bearer token to call this endpoint. */
|
|
36
|
+
export const API_MINT_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
|
|
37
|
+
/** client_id stamped on minted tokens. Matches the CLI flow's value. */
|
|
38
|
+
export const API_MINT_TOKEN_CLIENT_ID = "parachute-hub";
|
|
39
|
+
|
|
40
|
+
export interface ApiMintTokenDeps {
|
|
41
|
+
db: Database;
|
|
42
|
+
/** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
|
|
43
|
+
issuer: string;
|
|
44
|
+
/** Test seam for time. */
|
|
45
|
+
now?: () => Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MintTokenRequest {
|
|
49
|
+
scope?: unknown;
|
|
50
|
+
audience?: unknown;
|
|
51
|
+
expires_in?: unknown;
|
|
52
|
+
subject?: unknown;
|
|
53
|
+
permissions?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps): Promise<Response> {
|
|
57
|
+
if (req.method !== "POST") {
|
|
58
|
+
return jsonError(405, "method_not_allowed", "use POST");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 1. Bearer presence + parsing.
|
|
62
|
+
const auth = req.headers.get("authorization");
|
|
63
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
64
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
65
|
+
}
|
|
66
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
67
|
+
if (!bearer) {
|
|
68
|
+
return jsonError(401, "unauthenticated", "empty bearer token");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Bearer validation (signature, issuer, expiry, revocation).
|
|
72
|
+
let bearerSub: string;
|
|
73
|
+
let bearerScopes: string[];
|
|
74
|
+
try {
|
|
75
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
76
|
+
const sub = validated.payload.sub;
|
|
77
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
78
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
79
|
+
}
|
|
80
|
+
bearerSub = sub;
|
|
81
|
+
bearerScopes =
|
|
82
|
+
typeof validated.payload.scope === "string"
|
|
83
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
84
|
+
: [];
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3. Scope gate.
|
|
91
|
+
if (!bearerScopes.includes(API_MINT_TOKEN_REQUIRED_SCOPE)) {
|
|
92
|
+
return jsonError(
|
|
93
|
+
403,
|
|
94
|
+
"insufficient_scope",
|
|
95
|
+
`bearer token lacks ${API_MINT_TOKEN_REQUIRED_SCOPE}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4. Body parsing.
|
|
100
|
+
let body: MintTokenRequest;
|
|
101
|
+
try {
|
|
102
|
+
body = (await req.json()) as MintTokenRequest;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
+
return jsonError(400, "invalid_request", `body must be valid JSON — ${msg}`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof body !== "object" || body === null) {
|
|
108
|
+
return jsonError(400, "invalid_request", "body must be a JSON object");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 5. Required + typed field extraction.
|
|
112
|
+
if (typeof body.scope !== "string" || body.scope.trim().length === 0) {
|
|
113
|
+
return jsonError(400, "invalid_request", "scope is required and must be a non-empty string");
|
|
114
|
+
}
|
|
115
|
+
const scopes = body.scope.split(/\s+/).filter((s) => s.length > 0);
|
|
116
|
+
if (scopes.length === 0) {
|
|
117
|
+
return jsonError(400, "invalid_request", "scope must contain at least one scope");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Privilege-diffusion guard: mint paths cannot themselves mint tokens
|
|
121
|
+
// carrying non-requestable scopes (parachute:host:admin, the host:*
|
|
122
|
+
// narrow scopes, vault:<name>:admin). Holder of `parachute:host:auth`
|
|
123
|
+
// can mint vault/scribe/agent verb scopes for downstream services, but
|
|
124
|
+
// cannot mint another `:auth` (or any other non-requestable) without
|
|
125
|
+
// forced re-auth via the operator.token rotation path. Same set the
|
|
126
|
+
// public OAuth flow already rejects.
|
|
127
|
+
const blocked = scopes.filter((s) => isNonRequestableScope(s));
|
|
128
|
+
if (blocked.length > 0) {
|
|
129
|
+
return jsonError(
|
|
130
|
+
400,
|
|
131
|
+
"invalid_scope",
|
|
132
|
+
`scope ${blocked.join(", ")} is not requestable via mint-token; use OAuth flow or operator rotation`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let audience: string;
|
|
137
|
+
if (body.audience === undefined) {
|
|
138
|
+
audience = inferAudience(scopes);
|
|
139
|
+
} else if (typeof body.audience === "string" && body.audience.length > 0) {
|
|
140
|
+
audience = body.audience;
|
|
141
|
+
} else {
|
|
142
|
+
return jsonError(400, "invalid_request", "audience must be a non-empty string when present");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let ttlSeconds = API_MINT_TOKEN_DEFAULT_TTL_SECONDS;
|
|
146
|
+
if (body.expires_in !== undefined) {
|
|
147
|
+
if (typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in)) {
|
|
148
|
+
return jsonError(400, "invalid_request", "expires_in must be a positive integer (seconds)");
|
|
149
|
+
}
|
|
150
|
+
if (!Number.isInteger(body.expires_in) || body.expires_in <= 0) {
|
|
151
|
+
return jsonError(400, "invalid_request", "expires_in must be a positive integer (seconds)");
|
|
152
|
+
}
|
|
153
|
+
if (body.expires_in > API_MINT_TOKEN_MAX_TTL_SECONDS) {
|
|
154
|
+
return jsonError(
|
|
155
|
+
400,
|
|
156
|
+
"invalid_request",
|
|
157
|
+
`expires_in exceeds 365d cap (${API_MINT_TOKEN_MAX_TTL_SECONDS} seconds)`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
ttlSeconds = body.expires_in;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let subject: string;
|
|
164
|
+
if (body.subject === undefined) {
|
|
165
|
+
subject = bearerSub;
|
|
166
|
+
} else if (typeof body.subject === "string" && body.subject.length > 0) {
|
|
167
|
+
subject = body.subject;
|
|
168
|
+
} else {
|
|
169
|
+
return jsonError(400, "invalid_request", "subject must be a non-empty string when present");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let permissionsClaim: Record<string, unknown> | undefined;
|
|
173
|
+
let permissionsCanonical: string | undefined;
|
|
174
|
+
if (body.permissions !== undefined) {
|
|
175
|
+
if (
|
|
176
|
+
typeof body.permissions !== "object" ||
|
|
177
|
+
body.permissions === null ||
|
|
178
|
+
Array.isArray(body.permissions)
|
|
179
|
+
) {
|
|
180
|
+
return jsonError(400, "invalid_request", "permissions must be a JSON object");
|
|
181
|
+
}
|
|
182
|
+
permissionsClaim = body.permissions as Record<string, unknown>;
|
|
183
|
+
permissionsCanonical = JSON.stringify(permissionsClaim);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 6. Mint + register.
|
|
187
|
+
const minted = await signAccessToken(deps.db, {
|
|
188
|
+
sub: subject,
|
|
189
|
+
scopes,
|
|
190
|
+
audience,
|
|
191
|
+
clientId: API_MINT_TOKEN_CLIENT_ID,
|
|
192
|
+
issuer: deps.issuer,
|
|
193
|
+
ttlSeconds,
|
|
194
|
+
...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
|
|
195
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
recordTokenMint(deps.db, {
|
|
199
|
+
jti: minted.jti,
|
|
200
|
+
createdVia: "cli_mint",
|
|
201
|
+
subject,
|
|
202
|
+
// user_id intentionally omitted — CLI-mint rows store subject only,
|
|
203
|
+
// matching the CLI path's shape (so HTTP and CLI mints look identical
|
|
204
|
+
// in the registry). The bearer's user identity is implicit via the
|
|
205
|
+
// bearer's own user_id (which is in its own tokens row).
|
|
206
|
+
clientId: API_MINT_TOKEN_CLIENT_ID,
|
|
207
|
+
scopes,
|
|
208
|
+
expiresAt: minted.expiresAt,
|
|
209
|
+
...(permissionsCanonical !== undefined ? { permissions: permissionsCanonical } : {}),
|
|
210
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return new Response(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
jti: minted.jti,
|
|
216
|
+
token: minted.token,
|
|
217
|
+
expires_at: minted.expiresAt,
|
|
218
|
+
scope: scopes.join(" "),
|
|
219
|
+
...(permissionsClaim !== undefined ? { permissions: permissionsClaim } : {}),
|
|
220
|
+
}),
|
|
221
|
+
{
|
|
222
|
+
status: 200,
|
|
223
|
+
headers: {
|
|
224
|
+
"content-type": "application/json",
|
|
225
|
+
"cache-control": "no-store",
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
232
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
233
|
+
status,
|
|
234
|
+
headers: {
|
|
235
|
+
"content-type": "application/json",
|
|
236
|
+
"cache-control": "no-store",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /.well-known/parachute-revocation.json` — public list of revoked,
|
|
3
|
+
* not-yet-expired token jtis. Resource servers (vault, scribe, agent)
|
|
4
|
+
* fetch this on a 60s TTL and reject any presented JWT whose jti appears.
|
|
5
|
+
*
|
|
6
|
+
* Public endpoint (no auth). The list itself is harmless to expose: it's
|
|
7
|
+
* a list of opaque IDs whose only utility is "this token shouldn't be
|
|
8
|
+
* accepted." A leaked list doesn't enable any new attack — at worst, an
|
|
9
|
+
* attacker learns which compromise the operator already cleaned up.
|
|
10
|
+
*
|
|
11
|
+
* Already-expired jtis are filtered out: every consumer checks `exp`
|
|
12
|
+
* itself, so listing expired tokens just bloats the response. The
|
|
13
|
+
* revocation list exists for *unexpired* tokens whose validity got cut
|
|
14
|
+
* short. Once `exp` passes, a row falls off the list naturally.
|
|
15
|
+
*
|
|
16
|
+
* Caching: 60s `Cache-Control: max-age=60` matches the consumer's
|
|
17
|
+
* polling cadence (Phase 4 wires the 60s TTL on the resource-server
|
|
18
|
+
* side). Shorter cache = revocation propagates faster but burns more
|
|
19
|
+
* CPU on this endpoint; 60s is the published convergence target.
|
|
20
|
+
*/
|
|
21
|
+
import type { Database } from "bun:sqlite";
|
|
22
|
+
import { listActiveRevocations } from "./jwt-sign.ts";
|
|
23
|
+
|
|
24
|
+
export const REVOCATION_LIST_MOUNT = "/.well-known/parachute-revocation.json";
|
|
25
|
+
/** Consumer cache TTL in seconds. Resource servers should poll on this cadence. */
|
|
26
|
+
export const REVOCATION_LIST_CACHE_SECONDS = 60;
|
|
27
|
+
|
|
28
|
+
export interface RevocationListDeps {
|
|
29
|
+
db: Database;
|
|
30
|
+
/** Test seam for time. */
|
|
31
|
+
now?: () => Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RevocationListBody {
|
|
35
|
+
generated_at: string;
|
|
36
|
+
jtis: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function handleRevocationList(req: Request, deps: RevocationListDeps): Response {
|
|
40
|
+
if (req.method !== "GET") {
|
|
41
|
+
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
|
|
42
|
+
status: 405,
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const now = deps.now?.() ?? new Date();
|
|
47
|
+
const jtis = listActiveRevocations(deps.db, now);
|
|
48
|
+
const body: RevocationListBody = {
|
|
49
|
+
generated_at: now.toISOString(),
|
|
50
|
+
jtis,
|
|
51
|
+
};
|
|
52
|
+
return new Response(JSON.stringify(body), {
|
|
53
|
+
status: 200,
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
"cache-control": `public, max-age=${REVOCATION_LIST_CACHE_SECONDS}`,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|