Просмотр исходного кода

Harden OTP runtime configuration

Tom McKenzie 1 месяц назад
Родитель
Сommit
0a56e255a7
5 измененных файлов с 79 добавлено и 23 удалено
  1. 21 9
      README.md
  2. 7 3
      client/app.js
  3. 44 8
      src/auth/index.ts
  4. 6 2
      src/env.d.ts
  5. 1 1
      wrangler.toml

+ 21 - 9
README.md

@@ -7,21 +7,33 @@ Hono + Better Auth (phone OTP + anonymous) + D1/Drizzle + one Durable Object per
 ## Dev loop
 
 ```bash
-npm install
+pnpm install
 cp .dev.vars.example .dev.vars        # then paste TYPESAFE_API_KEY + BETTER_AUTH_SECRET
-npm run db:migrate:dev                 # local D1 (miniflare)
-npm run dev                            # http://localhost:8787  — OTP codes print to this console
+pnpm db:migrate:dev                     # local D1 (miniflare)
+pnpm dev                                # http://localhost:8787  — OTP codes print to this console
 ```
 
 Open `/` on two phones on the same LAN (or `wrangler dev --ip 0.0.0.0`), "Jump in" with a name, create a hand, open `/h/<id>` on the other phone.
 
 ### Scripts
-- `npm run dev` — wrangler dev, local D1 + DO, hot reload
-- `npm run cf-typegen` — regenerate `worker-configuration.d.ts` after editing wrangler.toml
-- `npm run auth:update` then `npm run db:generate` — after changing Better Auth plugins
-- `npm run db:migrate:dev` / `:prod`
-- `npm run deploy` — `wrangler deploy` (first time: `wrangler d1 create pm2000-db`, paste id into wrangler.toml, `wrangler secret put TYPESAFE_API_KEY`, `wrangler secret put BETTER_AUTH_SECRET`)
-- Preview deploys: `npx wrangler versions upload` gives a preview URL without touching prod
+- `pnpm dev` — wrangler dev, local D1 + DO, hot reload
+- `pnpm cf-typegen` — regenerate `worker-configuration.d.ts` after editing wrangler.toml
+- `pnpm auth:update` then `pnpm db:generate` — after changing Better Auth plugins
+- `pnpm db:migrate:dev` / `pnpm db:migrate:prod`
+- `pnpm deploy` — `wrangler deploy` (first time: create D1, paste its id into `wrangler.toml`, and configure the production secrets listed below)
+- Preview deploys: `pnpm exec wrangler versions upload` gives a preview URL without touching prod
+
+Production uses Twilio because `wrangler.toml` sets `OTP_MODE=twilio`. Configure secrets without adding them to a file or Git:
+
+```bash
+pnpm exec wrangler secret put TYPESAFE_API_KEY
+pnpm exec wrangler secret put BETTER_AUTH_SECRET
+pnpm exec wrangler secret put TWILIO_ACCOUNT_SID
+pnpm exec wrangler secret put TWILIO_AUTH_TOKEN
+pnpm exec wrangler secret put TWILIO_FROM
+```
+
+`BETTER_AUTH_URL` is optional. Set it as a Wrangler variable or secret when production needs a fixed canonical origin; otherwise auth uses the origin of each incoming Cloudflare request. Local development explicitly uses `OTP_MODE=console` from `.dev.vars`.
 
 ### Where things are
 - `src/hand.ts` — the Hand DO: state, WebSocket fan-out, chips/veto/lock, debounced Jev odds, the lever (seeded sample), rounds

+ 7 - 3
client/app.js

@@ -23,7 +23,11 @@ async function api(path, body) {
     body: body ? JSON.stringify(body) : undefined,
   });
   if (!response.ok) {
-    const message = await response.text();
+    const text = await response.text();
+    let message = text;
+    try {
+      message = JSON.parse(text).error || text;
+    } catch {}
     toast(message);
     throw new Error(message);
   }
@@ -101,8 +105,8 @@ async function verifyOtp() {
     phoneNumber: $('#phone').value.trim(),
     code: $('#code').value.trim(),
   });
-  const name = $('#name').value.trim();
-  if (name) await api('/api/me/name', { name });
+  const name = $('#name').value.trim() || 'anon';
+  await api('/api/me/name', { name });
   me = await session();
   boot();
 }

+ 44 - 8
src/auth/index.ts

@@ -9,24 +9,60 @@ import type { CloudflareBindings } from "../env";
 
 // Identity = phone number. OTP over SMS in prod; logged to the console in dev.
 // Anonymous stays enabled as the zero-friction path for loop prototyping.
-async function sendOTP(env: CloudflareBindings | undefined, phone: string, code: string) {
-    if (!env || env.OTP_MODE !== "twilio") {
+type TwilioConfig = {
+    accountSid: string;
+    authToken: string;
+    from: string;
+};
+
+function getTwilioConfig(env: CloudflareBindings): TwilioConfig | undefined {
+    const mode: string = env.OTP_MODE;
+    if (mode === "console") return undefined;
+    if (mode !== "twilio") throw new Error(`Unsupported OTP_MODE: ${mode}`);
+
+    const missing = [
+        ["TWILIO_ACCOUNT_SID", env.TWILIO_ACCOUNT_SID],
+        ["TWILIO_AUTH_TOKEN", env.TWILIO_AUTH_TOKEN],
+        ["TWILIO_FROM", env.TWILIO_FROM],
+    ].filter(([, value]) => !value?.trim());
+
+    if (missing.length > 0) {
+        throw new Error(`OTP_MODE=twilio requires ${missing.map(([name]) => name).join(", ")}`);
+    }
+
+    return {
+        accountSid: env.TWILIO_ACCOUNT_SID!.trim(),
+        authToken: env.TWILIO_AUTH_TOKEN!.trim(),
+        from: env.TWILIO_FROM!.trim(),
+    };
+}
+
+async function sendOTP(config: TwilioConfig | undefined, phone: string, code: string) {
+    if (!config) {
         console.log(`[OTP] ${phone} -> ${code}`);
         return;
     }
-    const body = new URLSearchParams({ To: phone, From: env.TWILIO_FROM!, Body: `Your Plan Maker code is ${code}` });
-    await fetch(`https://api.twilio.com/2010-04-01/Accounts/${env.TWILIO_ACCOUNT_SID}/Messages.json`, {
+
+    const body = new URLSearchParams({ To: phone, From: config.from, Body: `Your Plan Maker code is ${code}` });
+    const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`, {
         method: "POST",
-        headers: { Authorization: "Basic " + btoa(`${env.TWILIO_ACCOUNT_SID}:${env.TWILIO_AUTH_TOKEN}`) },
+        headers: { Authorization: "Basic " + btoa(`${config.accountSid}:${config.authToken}`) },
         body,
     });
+
+    if (!response.ok) {
+        throw new Error(`Twilio rejected the OTP message (HTTP ${response.status})`);
+    }
 }
 
 function createAuth(env?: CloudflareBindings, cf?: IncomingRequestCfProperties, baseURL?: string) {
     const db = env ? drizzle(env.DATABASE, { schema }) : ({} as any);
+    const authBaseURL = new URL(env?.BETTER_AUTH_URL?.trim() || baseURL || "http://localhost:8787").origin;
+    const twilio = env ? getTwilioConfig(env) : undefined;
 
     return betterAuth({
-        baseURL,
+        baseURL: authBaseURL,
+        trustedOrigins: [authBaseURL],
         secret: env?.BETTER_AUTH_SECRET,
         ...withCloudflare(
             {
@@ -40,7 +76,7 @@ function createAuth(env?: CloudflareBindings, cf?: IncomingRequestCfProperties,
                 plugins: [
                     anonymous(),
                     phoneNumber({
-                        sendOTP: ({ phoneNumber, code }) => sendOTP(env, phoneNumber, code),
+                        sendOTP: ({ phoneNumber, code }) => sendOTP(twilio, phoneNumber, code),
                         otpLength: 6,
                         expiresIn: 300,
                         signUpOnVerification: {
@@ -52,7 +88,7 @@ function createAuth(env?: CloudflareBindings, cf?: IncomingRequestCfProperties,
                 rateLimit: { enabled: true, window: 60, max: 100 },
             }
         ),
-        ...(env ? {} : { database: drizzleAdapter({} as any, { provider: "sqlite", usePlural: true }) }),
+        ...(env ? {} : { database: drizzleAdapter({} as any, { provider: "sqlite", schema, usePlural: true }) }),
     });
 }
 

+ 6 - 2
src/env.d.ts

@@ -1,7 +1,11 @@
-// Bindings come from worker-configuration.d.ts (run `npm run cf-typegen`). Secrets from .dev.vars / wrangler secret.
-export type CloudflareBindings = Env & {
+// Bindings come from worker-configuration.d.ts (run `pnpm cf-typegen`). Secrets from .dev.vars / wrangler secret.
+// Omit OTP_MODE because Wrangler narrows configured variables to their current
+// literal value, while this application supports a different local value.
+export type CloudflareBindings = Omit<Env, "OTP_MODE"> & {
+    OTP_MODE: "console" | "twilio";
     TYPESAFE_API_KEY: string;
     BETTER_AUTH_SECRET: string;
+    BETTER_AUTH_URL?: string;
     TWILIO_ACCOUNT_SID?: string;
     TWILIO_AUTH_TOKEN?: string;
     TWILIO_FROM?: string;

+ 1 - 1
wrangler.toml

@@ -26,5 +26,5 @@ tag = "v1"
 new_sqlite_classes = ["Hand"]
 
 [vars]
-OTP_MODE = "console"
+OTP_MODE = "twilio"
 PICK_INSTRUCTIONS = "Which option should this table go with? Yes chips are votes and weigh heavily. Respect stated constraints."