@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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /vaults` — provision a new vault on the host.
|
|
3
|
+
*
|
|
4
|
+
* The hub's first authenticated, mutating endpoint. Until now the hub has
|
|
5
|
+
* been a pure issuer; Phase 1 of the vault-config-and-scopes design (D1)
|
|
6
|
+
* lifts vault provisioning into a hub UI surface so parachute-agent / hub-admin
|
|
7
|
+
* pages can mint a vault without shelling out to a terminal.
|
|
8
|
+
*
|
|
9
|
+
* Wire shape:
|
|
10
|
+
* POST /vaults
|
|
11
|
+
* Authorization: Bearer <jwt with parachute:host:admin>
|
|
12
|
+
* Content-Type: application/json
|
|
13
|
+
* { "name": "<vault-name>" }
|
|
14
|
+
*
|
|
15
|
+
* 201 → { name, url, version, token?, paths? }
|
|
16
|
+
* // vault freshly created. `token` (single-emit `pvt_*`) and
|
|
17
|
+
* // filesystem `paths` are present when the create path took the
|
|
18
|
+
* // `parachute-vault create --json` branch — that's the only time
|
|
19
|
+
* // the just-emitted token is captured. The first-vault-on-host
|
|
20
|
+
* // bootstrap (`parachute install vault`) doesn't emit JSON yet,
|
|
21
|
+
* // so a fresh-box response carries name/url/version only.
|
|
22
|
+
* 200 → { name, url, version }
|
|
23
|
+
* // idempotent re-POST: existing vault. Never includes `token` —
|
|
24
|
+
* // tokens are single-emit at create time, not retrievable later.
|
|
25
|
+
* 400 → { error: "invalid_request", error_description: ... }
|
|
26
|
+
* 401/403 → bearer-auth failure
|
|
27
|
+
* 500 → orchestration failure
|
|
28
|
+
*
|
|
29
|
+
* Orchestration:
|
|
30
|
+
* - If `parachute-vault` is NOT yet registered in services.json: shell
|
|
31
|
+
* out to `parachute install vault` (covers the bootstrap case for a
|
|
32
|
+
* fresh host; runs `parachute-vault init` which creates the default
|
|
33
|
+
* vault).
|
|
34
|
+
* - If `parachute-vault` IS already registered: shell out to
|
|
35
|
+
* `parachute-vault create --json <name>` (subsequent vaults). Stdout
|
|
36
|
+
* is parsed for the bootstrap creds (name, token, paths).
|
|
37
|
+
*
|
|
38
|
+
* The CLI is the single source of truth for "how do you create a vault";
|
|
39
|
+
* we don't reimplement DB+yaml+token writes here. Mirrors D1 in the design
|
|
40
|
+
* doc: hub orchestrates the CLI, doesn't replace it.
|
|
41
|
+
*
|
|
42
|
+
* Idempotency: name validation matches `parachute-vault create`'s rules
|
|
43
|
+
* (regex + "list" reserved), with `new` and `assets` also reserved at
|
|
44
|
+
* the hub edge for SPA-route shadowing. When a vault with the requested
|
|
45
|
+
* name already exists,
|
|
46
|
+
* we return 200 with the existing entry rather than re-running the CLI —
|
|
47
|
+
* the CLI itself rejects an existing name with exit 1, but a re-POST is
|
|
48
|
+
* usually a UI retry, not an error to the caller.
|
|
49
|
+
*/
|
|
50
|
+
import type { Database } from "bun:sqlite";
|
|
51
|
+
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
52
|
+
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
53
|
+
import { findService, readManifest } from "./services-manifest.ts";
|
|
54
|
+
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
55
|
+
|
|
56
|
+
/** Scope required to call POST /vaults. */
|
|
57
|
+
export const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mirror parachute-vault's `cmdCreate` validation rules, plus hub-only
|
|
61
|
+
* reservations for SPA-route shadowing. `list` matches the CLI; `new` and
|
|
62
|
+
* `assets` would collide with `/vault/new` (the SPA's create-vault route)
|
|
63
|
+
* and `/vault/assets/*` (the SPA's static asset bundle) respectively, so
|
|
64
|
+
* the hub rejects them at the API edge before a vault under those names
|
|
65
|
+
* can register and capture the proxy path.
|
|
66
|
+
*/
|
|
67
|
+
const VAULT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
68
|
+
const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
|
|
69
|
+
|
|
70
|
+
export interface CreateVaultRequest {
|
|
71
|
+
name: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Output shape of `parachute-vault create --json` (vault PR #184). */
|
|
75
|
+
export interface VaultCreateJson {
|
|
76
|
+
name: string;
|
|
77
|
+
token: string;
|
|
78
|
+
paths: {
|
|
79
|
+
vault_dir: string;
|
|
80
|
+
vault_db: string;
|
|
81
|
+
vault_config: string;
|
|
82
|
+
};
|
|
83
|
+
set_as_default: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Result of a single shell-out: exit code + captured stdout/stderr. */
|
|
87
|
+
export interface RunResult {
|
|
88
|
+
exitCode: number;
|
|
89
|
+
stdout: string;
|
|
90
|
+
/**
|
|
91
|
+
* Captured stderr. Always drained alongside stdout so a long-running
|
|
92
|
+
* child can't deadlock on a full pipe buffer (#97). Surfaced in error
|
|
93
|
+
* messages when exitCode != 0 so non-zero failures are diagnosable.
|
|
94
|
+
*/
|
|
95
|
+
stderr: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CreateVaultDeps {
|
|
99
|
+
db: Database;
|
|
100
|
+
/** Hub origin used to validate JWT `iss` and to build the response `url`. */
|
|
101
|
+
issuer: string;
|
|
102
|
+
/** Override the services.json path. Defaults to `~/.parachute/services.json`. */
|
|
103
|
+
manifestPath?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Test seam: run the orchestration command. Production spawns the real
|
|
106
|
+
* `parachute install` / `parachute-vault create` binaries; tests stub it
|
|
107
|
+
* to avoid touching the filesystem outside the temp dir. Stdout is
|
|
108
|
+
* captured so the create branch can parse `parachute-vault create --json`.
|
|
109
|
+
*/
|
|
110
|
+
runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ParseResult {
|
|
114
|
+
ok: true;
|
|
115
|
+
body: CreateVaultRequest;
|
|
116
|
+
}
|
|
117
|
+
interface ParseError {
|
|
118
|
+
ok: false;
|
|
119
|
+
status: number;
|
|
120
|
+
message: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function parseBody(req: Request): Promise<ParseResult | ParseError> {
|
|
124
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
125
|
+
if (!ctype.toLowerCase().includes("application/json")) {
|
|
126
|
+
return { ok: false, status: 400, message: "Content-Type must be application/json" };
|
|
127
|
+
}
|
|
128
|
+
let raw: unknown;
|
|
129
|
+
try {
|
|
130
|
+
raw = await req.json();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
133
|
+
return { ok: false, status: 400, message: `invalid JSON body: ${msg}` };
|
|
134
|
+
}
|
|
135
|
+
if (!raw || typeof raw !== "object") {
|
|
136
|
+
return { ok: false, status: 400, message: "request body must be a JSON object" };
|
|
137
|
+
}
|
|
138
|
+
const name = (raw as Record<string, unknown>).name;
|
|
139
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
140
|
+
return { ok: false, status: 400, message: '"name" must be a non-empty string' };
|
|
141
|
+
}
|
|
142
|
+
if (!VAULT_NAME_PATTERN.test(name)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 400,
|
|
146
|
+
message: "vault name must contain only letters, numbers, hyphens, and underscores",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (RESERVED_VAULT_NAMES.has(name)) {
|
|
150
|
+
return { ok: false, status: 400, message: `"${name}" is a reserved vault name` };
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, body: { name } };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
156
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
157
|
+
status,
|
|
158
|
+
headers: { "content-type": "application/json" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find an existing vault by name in services.json. Vaults live under one
|
|
164
|
+
* `parachute-vault` service entry, which may carry a multi-path array (per
|
|
165
|
+
* Q5 of the design — single entry, multi-path) or a per-vault `parachute-
|
|
166
|
+
* vault-<name>` entry. Delegates name resolution to `vaultInstanceNameFor`
|
|
167
|
+
* so well-known.ts, oauth-handlers.ts, and this lookup all agree (#143).
|
|
168
|
+
*/
|
|
169
|
+
function findExistingVault(
|
|
170
|
+
manifestPath: string,
|
|
171
|
+
name: string,
|
|
172
|
+
): { url: string; version: string; path: string } | null {
|
|
173
|
+
let manifest: ReturnType<typeof readManifest>;
|
|
174
|
+
try {
|
|
175
|
+
manifest = readManifest(manifestPath);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const target = `/vault/${name}`;
|
|
180
|
+
for (const svc of manifest.services) {
|
|
181
|
+
if (!isVaultEntry(svc)) continue;
|
|
182
|
+
if (svc.paths.length === 0) {
|
|
183
|
+
if (vaultInstanceNameFor(svc.name, undefined) === name) {
|
|
184
|
+
return { url: target, version: svc.version, path: target };
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
for (const path of svc.paths) {
|
|
189
|
+
if (vaultInstanceNameFor(svc.name, path) === name) {
|
|
190
|
+
return { url: path, version: svc.version, path };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildEntry(
|
|
198
|
+
name: string,
|
|
199
|
+
path: string,
|
|
200
|
+
version: string,
|
|
201
|
+
issuer: string,
|
|
202
|
+
): WellKnownVaultEntry {
|
|
203
|
+
const base = issuer.replace(/\/$/, "");
|
|
204
|
+
const url = new URL(path, `${base}/`).toString();
|
|
205
|
+
return { name, url, version };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
|
|
209
|
+
const proc = Bun.spawn([...cmd], { stdio: ["ignore", "pipe", "pipe"] });
|
|
210
|
+
// Drain both pipes in parallel — leaving stderr unread can deadlock long
|
|
211
|
+
// installs once the OS pipe buffer fills (#97). Captured stderr is folded
|
|
212
|
+
// into the orchestration error message on non-zero exit.
|
|
213
|
+
const [stdout, stderr] = await Promise.all([
|
|
214
|
+
new Response(proc.stdout).text(),
|
|
215
|
+
new Response(proc.stderr).text(),
|
|
216
|
+
]);
|
|
217
|
+
const exitCode = await proc.exited;
|
|
218
|
+
return { exitCode, stdout, stderr };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface OrchestrateOk {
|
|
222
|
+
ok: true;
|
|
223
|
+
/** Present only when create-with-json branch ran and parsed cleanly. */
|
|
224
|
+
createJson: VaultCreateJson | null;
|
|
225
|
+
}
|
|
226
|
+
interface OrchestrateError {
|
|
227
|
+
ok: false;
|
|
228
|
+
status: number;
|
|
229
|
+
message: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run the orchestration step. Picks `parachute install` (bootstrap) vs
|
|
234
|
+
* `parachute-vault create --json` (subsequent) based on whether vault is
|
|
235
|
+
* already registered in services.json. The create branch parses stdout for
|
|
236
|
+
* the just-emitted `pvt_*` token + filesystem paths so the caller can talk
|
|
237
|
+
* to the new vault — those creds are single-emit.
|
|
238
|
+
*/
|
|
239
|
+
async function orchestrate(
|
|
240
|
+
manifestPath: string,
|
|
241
|
+
name: string,
|
|
242
|
+
runCommand: (cmd: readonly string[]) => Promise<RunResult>,
|
|
243
|
+
): Promise<OrchestrateOk | OrchestrateError> {
|
|
244
|
+
const vaultRegistered = findService("parachute-vault", manifestPath) !== undefined;
|
|
245
|
+
const cmd = vaultRegistered
|
|
246
|
+
? ["parachute-vault", "create", name, "--json"]
|
|
247
|
+
: ["parachute", "install", "vault"];
|
|
248
|
+
let result: RunResult;
|
|
249
|
+
try {
|
|
250
|
+
result = await runCommand(cmd);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
return { ok: false, status: 500, message: `orchestration failed: ${msg}` };
|
|
254
|
+
}
|
|
255
|
+
if (result.exitCode !== 0) {
|
|
256
|
+
// Tail stderr (capped) so the error message names the actual failure
|
|
257
|
+
// mode — "exited 1" alone is useless when the CLI prints why it failed
|
|
258
|
+
// to stderr.
|
|
259
|
+
const stderrTail = result.stderr.trim();
|
|
260
|
+
const tailSuffix = stderrTail ? `: ${stderrTail.slice(-500)}` : "";
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
status: 500,
|
|
264
|
+
message: `${cmd.join(" ")} exited with code ${result.exitCode}${tailSuffix}`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (!vaultRegistered) {
|
|
268
|
+
return { ok: true, createJson: null };
|
|
269
|
+
}
|
|
270
|
+
let createJson: VaultCreateJson;
|
|
271
|
+
try {
|
|
272
|
+
createJson = JSON.parse(result.stdout.trim()) as VaultCreateJson;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
status: 500,
|
|
278
|
+
message: `parachute-vault create --json returned unparseable stdout: ${msg}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
typeof createJson.name !== "string" ||
|
|
283
|
+
typeof createJson.token !== "string" ||
|
|
284
|
+
!createJson.paths
|
|
285
|
+
) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
status: 500,
|
|
289
|
+
message: "parachute-vault create --json output missing required fields (name/token/paths)",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { ok: true, createJson };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Promise<Response> {
|
|
296
|
+
if (req.method !== "POST") {
|
|
297
|
+
return new Response("method not allowed", { status: 405 });
|
|
298
|
+
}
|
|
299
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
300
|
+
const runCommand = deps.runCommand ?? defaultRunCommand;
|
|
301
|
+
|
|
302
|
+
// Auth gate: parachute:host:admin scope. Maps an AdminAuthError straight
|
|
303
|
+
// to an RFC 6750 401/403 — the route handler doesn't care which.
|
|
304
|
+
try {
|
|
305
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const parsed = await parseBody(req);
|
|
311
|
+
if (!parsed.ok) {
|
|
312
|
+
return jsonError(parsed.status, "invalid_request", parsed.message);
|
|
313
|
+
}
|
|
314
|
+
const { name } = parsed.body;
|
|
315
|
+
|
|
316
|
+
// Idempotency: if the vault already exists, return 200 + existing entry.
|
|
317
|
+
// Skip the CLI shell-out — re-POST is usually a UI retry.
|
|
318
|
+
const existing = findExistingVault(manifestPath, name);
|
|
319
|
+
if (existing) {
|
|
320
|
+
return new Response(
|
|
321
|
+
JSON.stringify(buildEntry(name, existing.path, existing.version, deps.issuer)),
|
|
322
|
+
{
|
|
323
|
+
status: 200,
|
|
324
|
+
headers: { "content-type": "application/json" },
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const result = await orchestrate(manifestPath, name, runCommand);
|
|
330
|
+
if (!result.ok) {
|
|
331
|
+
return jsonError(result.status, "server_error", result.message);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Re-read services.json: the CLI just wrote it.
|
|
335
|
+
const created = findExistingVault(manifestPath, name);
|
|
336
|
+
if (!created) {
|
|
337
|
+
return jsonError(
|
|
338
|
+
500,
|
|
339
|
+
"server_error",
|
|
340
|
+
`vault "${name}" was provisioned but is not in services.json — manual recovery required`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const entry = buildEntry(name, created.path, created.version, deps.issuer);
|
|
345
|
+
// Token + filesystem paths are single-emit at create time. We surface them
|
|
346
|
+
// here so the caller can immediately bootstrap a connection to the new
|
|
347
|
+
// vault. Idempotent re-POSTs intentionally never include them.
|
|
348
|
+
const body: WellKnownVaultEntry & {
|
|
349
|
+
token?: string;
|
|
350
|
+
paths?: VaultCreateJson["paths"];
|
|
351
|
+
} = result.createJson
|
|
352
|
+
? { ...entry, token: result.createJson.token, paths: result.createJson.paths }
|
|
353
|
+
: entry;
|
|
354
|
+
|
|
355
|
+
return new Response(JSON.stringify(body), {
|
|
356
|
+
status: 201,
|
|
357
|
+
headers: { "content-type": "application/json" },
|
|
358
|
+
});
|
|
359
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived authorization codes for the OAuth `code` grant. The hub mints
|
|
3
|
+
* one when the user approves a consent screen; the client redeems it at
|
|
4
|
+
* `/oauth/token` for an access + refresh token.
|
|
5
|
+
*
|
|
6
|
+
* Single-use is enforced by stamping `used_at` on redemption — a replay
|
|
7
|
+
* attempt sees the row but with `used_at` set and returns `AuthCodeUsedError`.
|
|
8
|
+
* RFC 6749 §10.5 wants single-use plus revocation of any tokens already
|
|
9
|
+
* issued from a replayed code; revocation is a follow-up.
|
|
10
|
+
*
|
|
11
|
+
* PKCE S256 is mandatory here. The `plain` method is rejected at the
|
|
12
|
+
* authorize step (`/oauth/authorize` enforces `code_challenge_method=S256`).
|
|
13
|
+
* Storing `code_challenge` on the row lets the token endpoint verify the
|
|
14
|
+
* client's `code_verifier` without having to keep state across the redirect.
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from "bun:sqlite";
|
|
17
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
export const AUTH_CODE_TTL_SECONDS = 60;
|
|
20
|
+
|
|
21
|
+
export interface AuthCode {
|
|
22
|
+
code: string;
|
|
23
|
+
clientId: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
redirectUri: string;
|
|
26
|
+
scopes: string[];
|
|
27
|
+
codeChallenge: string;
|
|
28
|
+
codeChallengeMethod: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
usedAt: string | null;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class AuthCodeNotFoundError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super("authorization code not found");
|
|
37
|
+
this.name = "AuthCodeNotFoundError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AuthCodeExpiredError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super("authorization code has expired");
|
|
44
|
+
this.name = "AuthCodeExpiredError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AuthCodeUsedError extends Error {
|
|
49
|
+
constructor() {
|
|
50
|
+
super("authorization code has already been redeemed");
|
|
51
|
+
this.name = "AuthCodeUsedError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class AuthCodePkceMismatchError extends Error {
|
|
56
|
+
constructor() {
|
|
57
|
+
super("code_verifier does not match the stored code_challenge");
|
|
58
|
+
this.name = "AuthCodePkceMismatchError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class AuthCodeRedirectMismatchError extends Error {
|
|
63
|
+
constructor() {
|
|
64
|
+
super("redirect_uri does not match the one bound to this code");
|
|
65
|
+
this.name = "AuthCodeRedirectMismatchError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Row {
|
|
70
|
+
code: string;
|
|
71
|
+
client_id: string;
|
|
72
|
+
user_id: string;
|
|
73
|
+
redirect_uri: string;
|
|
74
|
+
scopes: string;
|
|
75
|
+
code_challenge: string;
|
|
76
|
+
code_challenge_method: string;
|
|
77
|
+
expires_at: string;
|
|
78
|
+
used_at: string | null;
|
|
79
|
+
created_at: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rowToAuthCode(r: Row): AuthCode {
|
|
83
|
+
return {
|
|
84
|
+
code: r.code,
|
|
85
|
+
clientId: r.client_id,
|
|
86
|
+
userId: r.user_id,
|
|
87
|
+
redirectUri: r.redirect_uri,
|
|
88
|
+
scopes: r.scopes.split(" ").filter((s) => s.length > 0),
|
|
89
|
+
codeChallenge: r.code_challenge,
|
|
90
|
+
codeChallengeMethod: r.code_challenge_method,
|
|
91
|
+
expiresAt: r.expires_at,
|
|
92
|
+
usedAt: r.used_at,
|
|
93
|
+
createdAt: r.created_at,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface IssueAuthCodeOpts {
|
|
98
|
+
clientId: string;
|
|
99
|
+
userId: string;
|
|
100
|
+
redirectUri: string;
|
|
101
|
+
scopes: string[];
|
|
102
|
+
codeChallenge: string;
|
|
103
|
+
codeChallengeMethod: string;
|
|
104
|
+
now?: () => Date;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function issueAuthCode(db: Database, opts: IssueAuthCodeOpts): AuthCode {
|
|
108
|
+
const code = randomBytes(32).toString("base64url");
|
|
109
|
+
const now = opts.now?.() ?? new Date();
|
|
110
|
+
const createdAt = now.toISOString();
|
|
111
|
+
const expiresAt = new Date(now.getTime() + AUTH_CODE_TTL_SECONDS * 1000).toISOString();
|
|
112
|
+
db.prepare(
|
|
113
|
+
`INSERT INTO auth_codes
|
|
114
|
+
(code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at, created_at)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
|
|
116
|
+
).run(
|
|
117
|
+
code,
|
|
118
|
+
opts.clientId,
|
|
119
|
+
opts.userId,
|
|
120
|
+
opts.redirectUri,
|
|
121
|
+
opts.scopes.join(" "),
|
|
122
|
+
opts.codeChallenge,
|
|
123
|
+
opts.codeChallengeMethod,
|
|
124
|
+
expiresAt,
|
|
125
|
+
createdAt,
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
code,
|
|
129
|
+
clientId: opts.clientId,
|
|
130
|
+
userId: opts.userId,
|
|
131
|
+
redirectUri: opts.redirectUri,
|
|
132
|
+
scopes: opts.scopes,
|
|
133
|
+
codeChallenge: opts.codeChallenge,
|
|
134
|
+
codeChallengeMethod: opts.codeChallengeMethod,
|
|
135
|
+
expiresAt,
|
|
136
|
+
usedAt: null,
|
|
137
|
+
createdAt,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RedeemAuthCodeOpts {
|
|
142
|
+
code: string;
|
|
143
|
+
clientId: string;
|
|
144
|
+
redirectUri: string;
|
|
145
|
+
codeVerifier: string;
|
|
146
|
+
now?: () => Date;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Atomically validates and consumes an auth code. Throws on every error
|
|
151
|
+
* branch; the caller maps these to OAuth error codes (`invalid_grant` etc).
|
|
152
|
+
*/
|
|
153
|
+
export function redeemAuthCode(db: Database, opts: RedeemAuthCodeOpts): AuthCode {
|
|
154
|
+
const row = db.query<Row, [string]>("SELECT * FROM auth_codes WHERE code = ?").get(opts.code);
|
|
155
|
+
if (!row) throw new AuthCodeNotFoundError();
|
|
156
|
+
const code = rowToAuthCode(row);
|
|
157
|
+
if (code.clientId !== opts.clientId) throw new AuthCodeNotFoundError();
|
|
158
|
+
if (code.redirectUri !== opts.redirectUri) throw new AuthCodeRedirectMismatchError();
|
|
159
|
+
const now = opts.now?.() ?? new Date();
|
|
160
|
+
if (now.getTime() > new Date(code.expiresAt).getTime()) {
|
|
161
|
+
throw new AuthCodeExpiredError();
|
|
162
|
+
}
|
|
163
|
+
if (code.usedAt) throw new AuthCodeUsedError();
|
|
164
|
+
if (!verifyPkce(code.codeChallenge, code.codeChallengeMethod, opts.codeVerifier)) {
|
|
165
|
+
throw new AuthCodePkceMismatchError();
|
|
166
|
+
}
|
|
167
|
+
// Single-use: stamp used_at. Race-free because sqlite serializes writes.
|
|
168
|
+
db.prepare("UPDATE auth_codes SET used_at = ? WHERE code = ?").run(now.toISOString(), opts.code);
|
|
169
|
+
return { ...code, usedAt: now.toISOString() };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function verifyPkce(challenge: string, method: string, verifier: string): boolean {
|
|
173
|
+
if (method === "S256") {
|
|
174
|
+
const computed = createHash("sha256").update(verifier).digest("base64url");
|
|
175
|
+
return timingSafeEqualString(computed, challenge);
|
|
176
|
+
}
|
|
177
|
+
// We don't accept "plain" — authorize-time validation rejects it before
|
|
178
|
+
// any code is issued. Defensive: reject unknown methods here too.
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function timingSafeEqualString(a: string, b: string): boolean {
|
|
183
|
+
if (a.length !== b.length) return false;
|
|
184
|
+
let diff = 0;
|
|
185
|
+
for (let i = 0; i < a.length; i++) {
|
|
186
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
187
|
+
}
|
|
188
|
+
return diff === 0;
|
|
189
|
+
}
|