#!/usr/bin/env tsx /** * `pnpm setup:auth` — the guided path for the Better Auth web perimeter * (docs/plans/sending-streams-and-auth.md, Phase 2 item 7). * * Better Auth in the worker is the issuer: it signs sessions at /login and * answers /api/auth/*. The only secret it needs is BETTER_AUTH_SECRET, which * this script machine-generates (256-bit, node crypto — no human transcribes * entropy) and uploads with `wrangler secret put`. * * The optional part is the Cloudflare WAF rate-limiting rule for /api/auth/*: * Better Auth ships its own database-backed rate limiter, but a zone-level WAF * rule keeps brute-force traffic from ever reaching the worker. Creating it * needs a zone-scoped CLOUDFLARE_API_TOKEN; without one the script prints the * exact dashboard steps and curl shape instead — it never fails on a missing * token. * * SAFETY: dry-run by default. Pass `--apply` to upload the secret (and create * the WAF rule when a token is present). * * Rotating a live BETTER_AUTH_SECRET does more than sign out every session, and * the extra part is not self-announcing: the secret also ENCRYPTS DATA AT REST. * The jwt() plugin's private key in `jwks` and the TOTP secrets in `twoFactor` * are written encrypted with it, and a rotation orphans them -- the whole web * perimeter then answers 503 with "Failed to decrypt private key", which nobody * sees until the next sign-in. Recovering means deleting the orphaned `jwks` * rows (the plugin mints a fresh keypair) and re-enrolling any authenticator. * So rotation is never a side effect of asking for something else: `--apply` * writes the secret only when the worker does not already have one. Re-running it on a configured * deployment -- which is how you add the WAF rule after the fact -- leaves the * secret alone. Replacing one is a separate, named request: `--rotate-secret`. * * Usage: * pnpm setup:auth --url https://inbox. # print the plan * pnpm setup:auth --env dev --url https://inbox-dev. --apply # set the secret * pnpm setup:auth --env dev --url https://inbox-dev. --apply --rotate-secret */ import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; function parseArgs(argv: string[]): Record { const args: Record = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith("--")) continue; const [rawKey, inlineValue] = arg.slice(2).split("=", 2); if (!rawKey) continue; if (inlineValue !== undefined) { args[rawKey] = inlineValue; continue; } const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[rawKey] = "true"; continue; } args[rawKey] = next; i += 1; } return args; } function stripJsonc(input: string): string { return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } type CloudflareEnvelope = { success?: boolean; errors?: Array<{ code?: number; message?: string }>; messages?: Array<{ code?: number; message?: string }>; result?: T; }; function formatApiErrors(payload: CloudflareEnvelope): string { const items = [...(payload.errors ?? []), ...(payload.messages ?? [])] .map((item) => { const code = item.code ? ` [${item.code}]` : ""; return `${item.message ?? "unknown Cloudflare API error"}${code}`; }) .filter(Boolean); return items.length > 0 ? items.join("; ") : "unknown Cloudflare API error"; } async function cfApi(token: string, path: string, init?: RequestInit): Promise { const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers ?? {}), }, }); const payload = (await response.json()) as CloudflareEnvelope; if (!response.ok || payload.success !== true || payload.result === undefined) { throw new Error(formatApiErrors(payload)); } return payload.result; } type Zone = { id?: string; name?: string }; /** The zone id for a hostname, walking up through parent domains. */ async function findZoneId(token: string, hostname: string): Promise { const parts = hostname.split(".").filter(Boolean); for (let i = 0; i < parts.length - 1; i += 1) { const candidate = parts.slice(i).join("."); const zones = await cfApi(token, `/zones?name=${encodeURIComponent(candidate)}`); const zone = zones.find((z) => z.id && z.name === candidate); if (zone?.id) return zone.id; } return null; } const WAF_RULE_DESCRIPTION = "reccado: rate limit /api/auth/*"; type WafRule = { description?: string; expression?: string; action?: string; enabled?: boolean; ratelimit?: { characteristics?: string[]; period?: number; requests_per_period?: number; mitigation_timeout?: number; requests_to_origin?: boolean; }; }; function wafRule(): WafRule { return { description: WAF_RULE_DESCRIPTION, expression: '(http.request.uri.path matches "^/api/auth/")', action: "block", enabled: true, ratelimit: { characteristics: ["ip.src"], // A Free zone cannot take these: it is fixed at a 10s window and a 10s // mitigation timeout (and one rule for the whole zone). The API rejects // the request there, which falls through to printWafManualSteps, where // the Free-plan shape is spelled out. period: 60, requests_per_period: 30, mitigation_timeout: 600, requests_to_origin: true, }, }; } const wafRuleJson = JSON.stringify({ rules: [wafRule()] }, null, 2); function printWafManualSteps(baseUrl: string): void { console.log( `\n▸ WAF rate-limit rule for /api/auth/* (manual — no zone-scoped CLOUDFLARE_API_TOKEN found)` + `\n Better Auth's database-backed rate limiter still applies; this WAF rule is the` + `\n outer layer that drops brute-force traffic before it reaches the worker.` + `\n\n Dashboard: the zone of ${baseUrl ? new URL(baseUrl).hostname : ""} → Security rules →` + `\n Create rule → Rate limiting rules:` + `\n - Rule name: ${WAF_RULE_DESCRIPTION}` + `\n - Field: URI Path | Operator: starts with | Value: /api/auth/` + `\n (the equivalent regex form, "matches ^/api/auth/", needs a plan with regex` + `\n matching; "starts with" is the same rule on any plan)` + `\n - With the same characteristics: IP` + `\n - When rate exceeds: 30 requests / 1 minute` + `\n - Then take action: Block, Duration 10 minutes` + `\n` + `\n On a Free zone the period and the duration are both fixed at 10s and you` + `\n get one rule for the entire zone. Use 20 requests / 10 seconds there: it` + `\n caps a hammering caller at 2 req/s while leaving a real sign-in (a handful` + `\n of requests) far below the line. The short block makes it a throttle rather` + `\n than a ban, which is the right shape anyway -- this rule exists to bound` + `\n cost, not to keep anyone out.` + `\n\n Or via the API (phase http_ratelimit entrypoint ruleset for the zone):` + `\n ZONE_ID= # Security → WAF, or GET /zones?name=` + `\n curl -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_ratelimit/entrypoint" \\` + `\n -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \\` + `\n --data '${wafRuleJson.replace(/'/g, "'\\''")}'` + `\n\n (The PUT replaces the phase entrypoint ruleset's rules — include any existing` + `\n rate-limiting rules you already have in the same request.)`, ); } async function applyWafRule(token: string, hostname: string, dryRun: boolean): Promise { console.log(`\n▸ WAF rate-limit rule for /api/auth/* (zone of ${hostname})`); const zoneId = await findZoneId(token, hostname); if (!zoneId) { console.log(" Could not resolve a zone for this hostname; use the manual steps below."); printWafManualSteps(hostname ? `https://${hostname}` : ""); return; } const path = `/zones/${zoneId}/rulesets/phases/http_ratelimit/entrypoint`; if (dryRun) { console.log(" Dry run — the rule this script would PUT to the phase entrypoint ruleset:"); console.log( wafRuleJson .split("\n") .map((line) => ` ${line}`) .join("\n"), ); console.log("\n Re-run with --apply (and CLOUDFLARE_API_TOKEN) to create it."); return; } const existing = await cfApi<{ rules?: WafRule[] }>(token, path).catch(() => null); const rules = existing?.rules ?? []; if (rules.some((rule) => rule.description === WAF_RULE_DESCRIPTION)) { console.log(` Rule "${WAF_RULE_DESCRIPTION}" already exists — nothing to do.`); return; } // Merge, never replace: the PUT writes the whole rules list. await cfApi(token, path, { method: "PUT", body: JSON.stringify({ rules: [...rules, wafRule()] }), }); console.log(` Created rule "${WAF_RULE_DESCRIPTION}" (30 req/min per IP, block 600s).`); } const args = parseArgs(process.argv.slice(2)); const apply = args.apply === "true"; const targetEnv = args.env; const baseUrl = args.url?.trim().replace(/\/+$/, ""); const skipWaf = args["skip-waf"] === "true"; const rotateSecret = args["rotate-secret"] === "true"; const wafAutomated = apply && !skipWaf && Boolean( args.url && !new URL(args.url.trim()).hostname.endsWith(".workers.dev") && process.env.CLOUDFLARE_API_TOKEN?.trim(), ); const config = JSON.parse(stripJsonc(readFileSync("wrangler.jsonc", "utf8"))) as { name?: string; env?: Record; }; const worker = (targetEnv ? config.env?.[targetEnv]?.name : config.name) ?? config.name; const envFlag = targetEnv ? ["--env", targetEnv] : []; const token = process.env.CLOUDFLARE_API_TOKEN?.trim(); const hostname = baseUrl ? new URL(baseUrl).hostname : undefined; if (token) { console.log( "CLOUDFLARE_API_TOKEN found — WAF rule creation will be offered (dry-run prints the rule, --apply creates it).\n", ); } if (apply && !hostname) { console.error( "setup:auth: --apply with no --url cannot verify what it is configuring.\n" + "Pass --url https:// (the origin the worker serves the UI on).", ); process.exit(1); } /** * The secrets already set on the target worker, or null when that cannot be * determined (worker not deployed yet, no credentials, API unreachable). * * `--env ` is how this worker is addressed; passing `--name` alongside it * makes wrangler look for `-` and fail. */ function listSecretNames(): string[] | null { try { const out = execFileSync("pnpm", ["wrangler", "secret", "list", ...envFlag], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); const start = out.indexOf("["); if (start < 0) return null; return (JSON.parse(out.slice(start)) as Array<{ name?: string }>).map((s) => s.name ?? ""); } catch { return null; } } // Whether the secret gets written is decided before anything is printed, so the // mode line states what will actually happen. Rotating a live BETTER_AUTH_SECRET // signs out every session; the WAF rule lives behind the same --apply, so without // this check "add the rate-limit rule" would silently mean "and log everyone out". const existingSecrets = apply ? listSecretNames() : null; const secretAlreadySet = existingSecrets?.includes("BETTER_AUTH_SECRET") ?? false; const secretUndeterminable = apply && existingSecrets === null; const willWriteSecret = apply && (rotateSecret || (!secretAlreadySet && !secretUndeterminable)); const secretModePhrase = willWriteSecret ? secretAlreadySet ? "ROTATING BETTER_AUTH_SECRET (every current session is signed out)" : "uploading BETTER_AUTH_SECRET" : secretUndeterminable ? "not writing BETTER_AUTH_SECRET (could not verify what is already set)" : "keeping the existing BETTER_AUTH_SECRET"; const modeLine = apply ? [secretModePhrase, wafAutomated ? "creating the WAF rate-limit rule" : null] .filter(Boolean) .join(" + ") : "dry run / guide"; console.log(`\nReccado setup:auth — worker: ${worker}\nmode: ${modeLine}\n`); // ▸ The secret: machine-generated, uploaded as a Worker secret. There is no // BETTER_AUTH_URL — the issuer trusts the origin it was requested on. const rotateCommand = `pnpm setup:auth${targetEnv ? ` --env ${targetEnv}` : ""}${baseUrl ? ` --url ${baseUrl}` : ""} --apply --rotate-secret`; if (secretAlreadySet && !rotateSecret) { console.log(`▸ BETTER_AUTH_SECRET is already set on ${worker} — left untouched.`); console.log( ` Replacing it signs out every session AND orphans what it encrypts at rest --\n` + ` the jwks signing key and any enrolled TOTP secret -- so it is not done as a\n` + ` side effect. Recovery is deleting the jwks rows and re-enrolling.\n` + ` To replace it deliberately:\n` + ` $ ${rotateCommand}`, ); } else if (secretUndeterminable) { console.log(`▸ BETTER_AUTH_SECRET — NOT written: could not read the secrets on ${worker}.`); console.log( ` \`wrangler secret list${targetEnv ? ` --env ${targetEnv}` : ""}\` failed, so this script cannot tell whether it would be\n` + ` creating a secret or replacing a live one. Check the worker exists and you are\n` + ` authenticated, then re-run. To write it regardless:\n` + ` $ ${rotateCommand}`, ); } else { console.log(`▸ Generate BETTER_AUTH_SECRET (256-bit) and upload it to ${worker}:`); console.log( ` $ pnpm setup:auth${targetEnv ? ` --env ${targetEnv}` : ""}${baseUrl ? ` --url ${baseUrl}` : ""} --apply`, ); console.log( ` equivalent by hand:\n` + ` node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64"))' |\n` + ` pnpm wrangler secret put BETTER_AUTH_SECRET${targetEnv ? ` --env ${targetEnv}` : ""}`, ); if (!apply) { console.log("\n Dry run only — re-run with --apply to upload the secret."); } else { const secret = randomBytes(32).toString("base64"); execFileSync("pnpm", ["wrangler", "secret", "put", "BETTER_AUTH_SECRET", ...envFlag], { input: secret, stdio: ["pipe", "inherit", "inherit"], }); console.log( secretAlreadySet ? "\n ✓ BETTER_AUTH_SECRET rotated — every session that existed before now is signed\n" + " out, and anything encrypted with the old secret is now unreadable. Delete the\n" + " jwks rows so a fresh keypair is minted, and re-enrol any authenticator:\n" + ' pnpm wrangler d1 execute --remote --command "DELETE FROM jwks"' : "\n ✓ BETTER_AUTH_SECRET uploaded.", ); } } // ▸ The base URL and the bootstrap ladder. Registration is closed at the // issuer: /login only emails an OTP to an address the owner registry // (owner_identities ∪ OWNER_BOOTSTRAP_EMAILS) vouches for. console.log( `\n▸ Base URL${baseUrl ? `: ${baseUrl}` : " (not derived — pass --url, or read it from the worker's own record)"}:`, ); if (!baseUrl) { console.log( " The worker records its own public origin after the first authenticated UI load:\n" + " pnpm wrangler d1 execute --remote --command \\\n" + " \"SELECT value FROM runtime_config WHERE key = 'deployment.origin'\"", ); } console.log( `\n▸ First owner login (registration is closed — the registry vouches for the address):`, ); console.log( " 1. Set the bootstrap or register the owner in D1 (both are read together):\n" + ` pnpm wrangler secret put OWNER_BOOTSTRAP_EMAILS${targetEnv ? ` --env ${targetEnv}` : ""}\n` + " or:\n" + " pnpm wrangler d1 execute --remote --command \\\n" + " \"INSERT INTO owner_identities (kind, identity, linked_at, linked_via) VALUES ('email', 'you@example.com', strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'manual')\"\n" + " 2. Open /login, request the email OTP, sign in.\n" + " 3. Rescue path when mail sending is not configured yet (or the registry is empty):\n" + " mint a pairing code and spend it at /login:\n" + " pnpm wrangler d1 execute --remote --command \\\n" + " \"INSERT INTO owner_pairing_codes (code, created_at, expires_at, issued_by) VALUES ('', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now','+2 hours'), 'manual')\"", ); // ▸ The WAF layer — optional, never fails the script. if (!skipWaf && hostname && !hostname.endsWith(".workers.dev")) { if (token) { try { await applyWafRule(token, hostname, !apply); } catch (error) { console.log( ` WAF rule creation failed: ${error instanceof Error ? error.message : String(error)}`, ); printWafManualSteps(baseUrl ?? ""); console.log(" This does not affect the auth setup — the perimeter still works without it."); } } else { printWafManualSteps(baseUrl ?? ""); } } else if (!skipWaf) { console.log( `\n▸ WAF rate-limit rule for /api/auth/*: pass --url to configure it` + ` (needs a zone-scoped CLOUDFLARE_API_TOKEN for the automated path).`, ); } // ▸ Verification. const verifyUrl = baseUrl ?? "https://"; console.log( `\nThen verify:` + `\n pnpm doctor${targetEnv ? ` --env ${targetEnv}` : ""} --cloud --url ${verifyUrl}` + `\n curl -sS -o /dev/null -w '%{http_code}\\n' ${verifyUrl}/login # 200, not a redirect` + `\n curl -sS ${verifyUrl}/api/health # 200 with dependencies healthy` + `\n and a real login: open ${verifyUrl}/login in a browser and sign in with an OTP.\n`, );