feat: add auth database migration
This commit is contained in:
94
migrations/0001_better_auth_account_center.sql
Normal file
94
migrations/0001_better_auth_account_center.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- Generated from Better Auth schema. Do not edit by hand.
|
||||||
|
|
||||||
|
-- Run `pnpm db:generate` after changing auth plugins that affect persistence.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
"id" text NOT NULL PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL UNIQUE,
|
||||||
|
"emailVerified" integer NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"createdAt" date NOT NULL,
|
||||||
|
"updatedAt" date NOT NULL,
|
||||||
|
"twoFactorEnabled" integer,
|
||||||
|
"lastLoginMethod" text,
|
||||||
|
"role" text,
|
||||||
|
"banned" integer,
|
||||||
|
"banReason" text,
|
||||||
|
"banExpires" date
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "session" (
|
||||||
|
"id" text NOT NULL PRIMARY KEY,
|
||||||
|
"expiresAt" date NOT NULL,
|
||||||
|
"token" text NOT NULL UNIQUE,
|
||||||
|
"createdAt" date NOT NULL,
|
||||||
|
"updatedAt" date NOT NULL,
|
||||||
|
"ipAddress" text,
|
||||||
|
"userAgent" text,
|
||||||
|
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade,
|
||||||
|
"impersonatedBy" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "account" (
|
||||||
|
"id" text NOT NULL PRIMARY KEY,
|
||||||
|
"accountId" text NOT NULL,
|
||||||
|
"providerId" text NOT NULL,
|
||||||
|
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade,
|
||||||
|
"accessToken" text,
|
||||||
|
"refreshToken" text,
|
||||||
|
"idToken" text,
|
||||||
|
"accessTokenExpiresAt" date,
|
||||||
|
"refreshTokenExpiresAt" date,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"createdAt" date NOT NULL,
|
||||||
|
"updatedAt" date NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "verification" (
|
||||||
|
"id" text NOT NULL PRIMARY KEY,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expiresAt" date NOT NULL,
|
||||||
|
"createdAt" date NOT NULL,
|
||||||
|
"updatedAt" date NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "passkey" (
|
||||||
|
"id" text NOT NULL PRIMARY KEY,
|
||||||
|
"name" text,
|
||||||
|
"publicKey" text NOT NULL,
|
||||||
|
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade,
|
||||||
|
"credentialID" text NOT NULL,
|
||||||
|
"counter" integer NOT NULL,
|
||||||
|
"deviceType" text NOT NULL,
|
||||||
|
"backedUp" integer NOT NULL,
|
||||||
|
"transports" text,
|
||||||
|
"createdAt" date,
|
||||||
|
"aaguid" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "twoFactor" (
|
||||||
|
"id" text NOT NULL PRIMARY KEY,
|
||||||
|
"secret" text NOT NULL,
|
||||||
|
"backupCodes" text NOT NULL,
|
||||||
|
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade,
|
||||||
|
"verified" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "passkey_userId_idx" ON "passkey" ("userId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "passkey_credentialID_idx" ON "passkey" ("credentialID");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "twoFactor_secret_idx" ON "twoFactor" ("secret");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "twoFactor_userId_idx" ON "twoFactor" ("userId");
|
||||||
@@ -6,9 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
|
"db:apply:local": "wrangler d1 migrations apply cfw-auth --local",
|
||||||
|
"db:check": "node scripts/generate-better-auth-migration.mjs --check",
|
||||||
|
"db:generate": "node scripts/generate-better-auth-migration.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"ready": "pnpm typecheck && pnpm test"
|
"ready": "pnpm db:check && pnpm typecheck && pnpm test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.6.16",
|
"@better-auth/passkey": "^1.6.16",
|
||||||
|
|||||||
138
scripts/generate-better-auth-migration.mjs
Normal file
138
scripts/generate-better-auth-migration.mjs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { passkey } from "@better-auth/passkey";
|
||||||
|
import { getSchema } from "better-auth/db";
|
||||||
|
import {
|
||||||
|
admin,
|
||||||
|
emailOTP,
|
||||||
|
haveIBeenPwned,
|
||||||
|
lastLoginMethod,
|
||||||
|
multiSession,
|
||||||
|
openAPI,
|
||||||
|
twoFactor,
|
||||||
|
} from "better-auth/plugins";
|
||||||
|
|
||||||
|
const outFile = fileURLToPath(
|
||||||
|
new URL("../migrations/0001_better_auth_account_center.sql", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = getSchema({
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
openAPI(),
|
||||||
|
haveIBeenPwned(),
|
||||||
|
emailOTP({
|
||||||
|
sendVerificationOTP: async () => {},
|
||||||
|
}),
|
||||||
|
twoFactor(),
|
||||||
|
multiSession({ maximumSessions: 10 }),
|
||||||
|
lastLoginMethod({ storeInDatabase: true }),
|
||||||
|
admin(),
|
||||||
|
passkey({
|
||||||
|
rpID: "localhost",
|
||||||
|
rpName: "cfw-auth",
|
||||||
|
origin: "http://localhost:8788",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
function quoteIdent(value) {
|
||||||
|
return `"${value.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteType(field) {
|
||||||
|
if (field.type === "boolean" || field.type === "number") {
|
||||||
|
return "integer";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "date") {
|
||||||
|
return "date";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnSql(name, field) {
|
||||||
|
const parts = [quoteIdent(name), sqliteType(field)];
|
||||||
|
|
||||||
|
if (field.required !== false) {
|
||||||
|
parts.push("NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.unique) {
|
||||||
|
parts.push("UNIQUE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.references) {
|
||||||
|
const onDelete = field.references.onDelete ?? "cascade";
|
||||||
|
parts.push(
|
||||||
|
`REFERENCES ${quoteIdent(field.references.model)}(${quoteIdent(field.references.field)}) ON DELETE ${onDelete}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTables(entries) {
|
||||||
|
return entries.sort((left, right) => {
|
||||||
|
const leftOrder = Number.isFinite(left[1].order) ? left[1].order : Number.MAX_SAFE_INTEGER;
|
||||||
|
const rightOrder = Number.isFinite(right[1].order) ? right[1].order : Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
if (leftOrder !== rightOrder) {
|
||||||
|
return leftOrder - rightOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left[0].localeCompare(right[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSql() {
|
||||||
|
const chunks = [
|
||||||
|
"-- Generated from Better Auth schema. Do not edit by hand.",
|
||||||
|
"-- Run `pnpm db:generate` after changing auth plugins that affect persistence.",
|
||||||
|
"PRAGMA foreign_keys = ON;",
|
||||||
|
];
|
||||||
|
const indexes = [];
|
||||||
|
|
||||||
|
for (const [tableName, table] of sortTables(Object.entries(schema))) {
|
||||||
|
const columns = [`${quoteIdent("id")} text NOT NULL PRIMARY KEY`];
|
||||||
|
|
||||||
|
for (const [fieldName, field] of Object.entries(table.fields)) {
|
||||||
|
columns.push(columnSql(fieldName, field));
|
||||||
|
|
||||||
|
if (field.index) {
|
||||||
|
const suffix = field.unique ? "uidx" : "idx";
|
||||||
|
const indexName = `${tableName}_${fieldName}_${suffix}`;
|
||||||
|
const indexSql = field.unique ? "CREATE UNIQUE INDEX" : "CREATE INDEX";
|
||||||
|
indexes.push(
|
||||||
|
`${indexSql} IF NOT EXISTS ${quoteIdent(indexName)} ON ${quoteIdent(tableName)} (${quoteIdent(fieldName)});`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(
|
||||||
|
`CREATE TABLE IF NOT EXISTS ${quoteIdent(tableName)} (\n ${columns.join(",\n ")}\n);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(...indexes);
|
||||||
|
|
||||||
|
return `${chunks.join("\n\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = buildSql();
|
||||||
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
|
if (checkOnly) {
|
||||||
|
const current = await readFile(outFile, "utf8");
|
||||||
|
if (current !== sql) {
|
||||||
|
throw new Error(`${outFile} is out of date. Run pnpm db:generate.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await mkdir(dirname(outFile), { recursive: true });
|
||||||
|
await writeFile(outFile, sql, "utf8");
|
||||||
|
console.log(`Wrote ${outFile}`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest";
|
|||||||
import worker from "../src/index";
|
import worker from "../src/index";
|
||||||
import type { Env } from "../src/env";
|
import type { Env } from "../src/env";
|
||||||
|
|
||||||
|
interface OpenAPISchemaResponse {
|
||||||
|
openapi: string;
|
||||||
|
info: {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
paths: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const env: Env = {
|
const env: Env = {
|
||||||
BETTER_AUTH_URL: "http://localhost:8788",
|
BETTER_AUTH_URL: "http://localhost:8788",
|
||||||
TRUSTED_ORIGINS: "http://localhost:8787",
|
TRUSTED_ORIGINS: "http://localhost:8787",
|
||||||
@@ -24,6 +32,24 @@ describe("cfw-auth worker", () => {
|
|||||||
expect(response.headers.get("content-type") ?? "").toContain("text/html");
|
expect(response.headers.get("content-type") ?? "").toContain("text/html");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes Better Auth OpenAPI schema", async () => {
|
||||||
|
const response = await worker.fetch(
|
||||||
|
new Request("http://auth.local/api/auth/open-api/generate-schema"),
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
const schema = (await response.json()) as OpenAPISchemaResponse;
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type") ?? "").toContain("application/json");
|
||||||
|
expect(schema).toMatchObject({
|
||||||
|
openapi: expect.any(String),
|
||||||
|
info: {
|
||||||
|
title: expect.any(String),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(Object.keys(schema.paths)).toEqual(expect.arrayContaining(["/sign-in/email"]));
|
||||||
|
});
|
||||||
|
|
||||||
it("applies configured CORS origin for auth routes", async () => {
|
it("applies configured CORS origin for auth routes", async () => {
|
||||||
const response = await worker.fetch(
|
const response = await worker.fetch(
|
||||||
new Request("http://auth.local/api/auth/reference", {
|
new Request("http://auth.local/api/auth/reference", {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"TRUSTED_ORIGINS": "http://localhost:8787",
|
"TRUSTED_ORIGINS": "http://localhost:8787",
|
||||||
"MAIL_PROVIDER": "resend",
|
"MAIL_PROVIDER": "resend",
|
||||||
"MAIL_FROM": "noreply@example.com",
|
"MAIL_FROM": "noreply@example.com",
|
||||||
"CAPTCHA_PROVIDER": "cloudflare-turnstile"
|
"CAPTCHA_PROVIDER": "cloudflare-turnstile",
|
||||||
|
"RESEND_API_KEY": "set-with-wrangler-secret-for-production"
|
||||||
},
|
},
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user