@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.
Files changed (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. 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
+ }