Ctrl K
ComunidadGuías…

scripts/setup-auth.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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.<you.com>                       # print the plan
 *   pnpm setup:auth --env dev --url https://inbox-dev.<you.com> --apply # set the secret
 *   pnpm setup:auth --env dev --url https://inbox-dev.<you.com> --apply --rotate-secret
 */
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";

function parseArgs(argv: string[]): Record<string, string> {
	const args: Record<string, string> = {};
	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<T> = {
	success?: boolean;
	errors?: Array<{ code?: number; message?: string }>;
	messages?: Array<{ code?: number; message?: string }>;
	result?: T;
};

function formatApiErrors(payload: CloudflareEnvelope<unknown>): 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<T>(token: string, path: string, init?: RequestInit): Promise<T> {
	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<T>;
	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<string | null> {
	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<Zone[]>(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 : "<your-zone>"} → 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=<your-zone-id>  # Security → WAF, or GET /zones?name=<domain>` +
			`\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<void> {
	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<unknown>(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<string, { name?: string }>;
};
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://<your-host> (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 <name>` is how this worker is addressed; passing `--name` alongside it
 * makes wrangler look for `<name>-<env>` 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 <index-db> --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 <index-db> --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 <index-db> --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 <index-db> --remote --command \\\n" +
		"         \"INSERT INTO owner_pairing_codes (code, created_at, expires_at, issued_by) VALUES ('<code>', 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 <custom-host> to configure it` +
			` (needs a zone-scoped CLOUDFLARE_API_TOKEN for the automated path).`,
	);
}

// ▸ Verification.
const verifyUrl = baseUrl ?? "https://<deployed-url>";
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`,
);