From 30c1fba318706cfeb6fd97938c1ae396094f226a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 13:01:17 -0400 Subject: [PATCH 01/17] feat(migrate): add export betterauth and Better Auth imports `clerk migrate export betterauth` reads a Better Auth database over --db-url (or BETTERAUTH_DB_URL, or a prompt): Postgres, MySQL, libsql/Turso or SQLite. It reads the schema before it queries, so it finds the tables (`user`/`account`, or the plural ones under usePlural), the column spelling (camelCase, or the snake_case Drizzle's generator writes by default) and the plugin columns in use. Passwords come from a LEFT JOIN onto the credential account, so OAuth-only users are still exported. `clerk migrate import` reads Better Auth exports, detecting the hasher per user (Better Auth's scrypt as scrypt_werkzeug, bcrypt, argon2), dropping a password Clerk cannot verify rather than rejecting the user, skipping anonymous users, and carrying a ban only while it has not expired. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 127 ++++++--- .../src/commands/migrate/export/betterauth.ts | 242 ++++++++++++++++++ .../migrate/export/db-exports.test.ts | 179 ++++++++++++- .../src/commands/migrate/export/index.ts | 9 + .../commands/migrate/export/registry.test.ts | 9 +- .../src/commands/migrate/export/registry.ts | 8 + .../src/commands/migrate/index.test.ts | 4 +- .../cli-core/src/commands/migrate/lib/db.ts | 2 +- .../cli-core/src/commands/migrate/run.test.ts | 7 +- .../commands/migrate/sources/betterauth.ts | 133 ++++++++++ .../src/commands/migrate/sources/registry.ts | 2 + .../commands/migrate/sources/sources.test.ts | 119 ++++++++- .../src/commands/migrate/wizard.test.ts | 3 +- .../src/test/integration/completion.test.ts | 2 +- 14 files changed, 793 insertions(+), 53 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/export/betterauth.ts create mode 100644 packages/cli-core/src/commands/migrate/sources/betterauth.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index df844d2398..6d17d1ddd6 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -217,6 +217,7 @@ clerk migrate export clerk --output users.json clerk migrate export auth0 --domain my-tenant.us.auth0.com \ --client-id … # asks for the secret, masked clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" +clerk migrate export betterauth --db-url "./db.sqlite" clerk migrate export firebase --service-account ./service-account.json clerk migrate export workos --api-key sk_… ``` @@ -238,13 +239,14 @@ export continues against whichever credential worked. Agent mode and a non-TTY fail outright instead, having nobody to ask, and `-y` fails too, having been told not to. -| Platform | Source | Feeds | -| ---------- | -------------------------------- | ------------------- | -| `clerk` | Clerk Backend API | `--source clerk` | -| `auth0` | Auth0 Management API | `--source auth0` | -| `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | -| `firebase` | Firebase Identity Toolkit | `--source firebase` | -| `workos` | WorkOS User Management API | `--source workos` | +| Platform | Source | Feeds | +| ------------ | -------------------------------- | --------------------- | +| `clerk` | Clerk Backend API | `--source clerk` | +| `auth0` | Auth0 Management API | `--source auth0` | +| `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | +| `betterauth` | Better Auth database | `--source betterauth` | +| `firebase` | Firebase Identity Toolkit | `--source firebase` | +| `workos` | WorkOS User Management API | `--source workos` | Every export is a [run](#the-run-store), and the file lands in the run folder as `export.json`. `--output` writes it somewhere else instead, @@ -274,20 +276,20 @@ skipped, coverage, next }` — and never prompts, so a missing credential exits naming the flag to pass. `next`, and the commands printed after a human run, keep `--runs-dir`: the import looks the run ID up there. -| Flag | Platforms | Description | -| -------------------------- | ---------- | ----------------------------------------------------------------- | -| `-o, --output ` | all | Write the export here instead of the run folder | -| `-y, --yes` | all | Do not prompt: fail on a bad credential | -| `--json` | all | Print the result as JSON; never prompts | -| `--runs-dir ` | all | Where runs are kept (see [the run store](#the-run-store)) | -| `--db-url ` | `supabase` | Postgres connection string | -| `--service-account ` | `firebase` | Path to a service account key JSON file | -| `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain | -| `--client-id ` | `auth0` | Machine-to-machine application client ID | -| `--client-secret ` | `auth0` | Machine-to-machine application client secret | -| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | -| `--with-identities` | `workos` | Also record each user's OAuth providers | -| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | +| Flag | Platforms | Description | +| -------------------------- | ------------------------ | ----------------------------------------------------------------- | +| `-o, --output ` | all | Write the export here instead of the run folder | +| `-y, --yes` | all | Do not prompt: fail on a bad credential | +| `--json` | all | Print the result as JSON; never prompts | +| `--runs-dir ` | all | Where runs are kept (see [the run store](#the-run-store)) | +| `--db-url ` | `supabase`, `betterauth` | Connection string (Postgres only for `supabase`) | +| `--service-account ` | `firebase` | Path to a service account key JSON file | +| `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain | +| `--client-id ` | `auth0` | Machine-to-machine application client ID | +| `--client-secret ` | `auth0` | Machine-to-machine application client secret | +| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | +| `--with-identities` | `workos` | Also record each user's OAuth providers | +| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | `export clerk` also takes the targeting flags — it reads from a Clerk instance, so it resolves a key the same way `clerk migrate import` does, with one extra @@ -380,16 +382,23 @@ coverage also counts users who _have_ a password, so the size of the gap is visible up front — `workos` prints that row at zero unconditionally, because zero is the only value it can take. -#### `supabase` reads the database +#### Database-backed exports (`supabase`, `betterauth`) + +These two read the database directly, over **`--db-url`**: ```sh -clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" +clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" +clerk migrate export betterauth --db-url "./db.sqlite" +clerk migrate export betterauth --db-url "libsql://app-org.turso.io?authToken=..." # or set TURSO_AUTH_TOKEN ``` -Supabase's database is Postgres, read through `Bun.sql`: any URL but -`postgres://` or `postgresql://` is refused before connecting. Nothing native -ships in the binary — that is the whole reason the `engines.bun` floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL`, then a masked -prompt, since a connection string carries the password inline. A password +`export supabase` takes Postgres only, and refuses any other URL before +connecting. Postgres and MySQL go through `Bun.sql`; SQLite through `bun:sqlite`; +`libsql://` (Turso) over the server's HTTP pipeline endpoint, since `bun:sqlite` +only opens local files and `@libsql/client` ships native optional dependencies. +Nothing native ships in the binary — that is the whole reason the `engines.bun` +floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL` / +`BETTERAUTH_DB_URL`, then a masked prompt, since a connection string carries the password inline. A password pasted unencoded (`#`, `@`, `/` and the like) is percent-encoded for you. **Connection strings are redacted everywhere.** Errors show @@ -407,12 +416,22 @@ unreachable host and a closed port as "Connection closed", so: | Table missing | Check the database name and SELECT permission. On Supabase: enable Auth, connect as `postgres` | | SQLite file missing | Check the path and that the file is readable | -**It reads the database rather than the Admin API** because +**`supabase` reads the database rather than the Admin API** because `encrypted_password` exists only there. An API-based export would force every user to reset their password; this one carries the bcrypt digests across. It also keeps `raw_app_meta_data`, which is what the import's [checks](#checks) read for each user's providers. +**`betterauth` reads its schema before it queries.** It finds the tables +(`user` and `account`, or `users` and `accounts` under `usePlural: true`), how +the columns are spelled (camelCase, or the snake_case Better Auth's Drizzle +generator writes by default), and which plugin columns exist. The username +plugin adds `username`, admin adds `banned` (carried only while `banExpires` is +unset or in the future), phone-number adds `phoneNumber`, and so on; selecting a column that is not there fails the whole query, and the +database answers the question better than the user can. Passwords come from a +`LEFT JOIN` onto the credential `account` row — left, not inner, so a user who +only ever signed in with OAuth is still exported. + #### `firebase` ```sh @@ -739,8 +758,9 @@ after them. They sort the users three ways: - **Rejected** — users Clerk would refuse. Each gets the first reason that applies: - it failed schema validation - - its source requested a skip (Supabase: a soft-deleted or anonymous user; - Firebase: a user with no email or phone, such as an anonymous one) + - its source requested a skip (Better Auth: an anonymous guest; Firebase: a + user with no email or phone, such as an anonymous one; Supabase: a + soft-deleted or anonymous user) - its only emails are ones Clerk refuses: malformed, or a domain that can't receive mail (`.local`, `.invalid`, `.test`, `.example`, `.arpa`, `.internal`, `.lan`, `.corp` and the like). Such an email is dropped from @@ -798,8 +818,9 @@ after them. They sort the users three ways: instance is not set up to store, fields Clerk has no place for (`Clerk won't store: …`), passwords a source had to drop, emails Clerk refuses (malformed, or a domain that can't receive mail), and names Clerk refuses (a phone - number, a URL, HTML, blank, or over 256 bytes). A password, username or name - whose setting is off is stored, and works or shows once it is turned on. + number, a URL, HTML, blank, or over 256 bytes: Better Auth's phone sign-up + stores the number as the name). A password, username or name whose setting + is off is stored, and works or shows once it is turned on. - **Imported** — everyone else. Any reject stops the import, and it exits 2 with the command that adds @@ -1043,13 +1064,14 @@ A source maps one platform's export onto Clerk's user schema, and says what it brings across. Adding a platform is one file in `sources/` plus one line in `sources/registry.ts`; `--source`'s tab-completion reads from that array. -| Key | Reads | Passwords | MFA | Metadata | -| ---------- | ---------------------------- | --------- | ------- | -------- | -| `clerk` | Clerk Dashboard export | partial | partial | partial | -| `auth0` | Auth0 Management API | partial | no | yes | -| `firebase` | `firebase auth:export` | yes | no | no | -| `supabase` | Supabase `auth.users` export | yes | no | partial | -| `workos` | WorkOS User Management API | no | no | yes | +| Key | Reads | Passwords | MFA | Metadata | +| ------------ | ---------------------------- | --------- | ------- | -------- | +| `clerk` | Clerk Dashboard export | partial | partial | partial | +| `auth0` | Auth0 Management API | partial | no | yes | +| `betterauth` | Better Auth export | yes | no | no | +| `firebase` | `firebase auth:export` | yes | no | no | +| `supabase` | Supabase `auth.users` export | yes | no | partial | +| `workos` | WorkOS User Management API | no | no | yes | An unknown `--source` exits 2 with the list of valid keys. @@ -1076,6 +1098,28 @@ key an app put in its tokens (an Auth0 Action claim, a WorkOS JWT template) belongs in `public_metadata`, and in the Clerk session token: move it after the import. A Clerk Dashboard CSV carries no metadata. +### Better Auth passwords + +Better Auth hashes with its own scrypt by default and lets an app swap in bcrypt +or argon2, so one database can hold more than one kind. The hasher is detected +per user: + +| Stored value | Sent as | +| --------------------------- | ---------------------------------------------------------- | +| `<32 hex>:<128 hex>` | `scrypt:16384:16:1$$`, hasher `scrypt_werkzeug` | +| `$2a$`, `$2b$` or `$2y$` | `bcrypt` | +| `$argon2id$` or `$argon2i$` | `argon2id` or `argon2i` | +| anything else | dropped | + +Better Auth's scrypt uses the hex salt string as the salt and a 64-byte key, +which is what `scrypt_werkzeug` verifies once N, r and p are written inline. It +also normalizes a password to NFKC before hashing, and Clerk does not, so a +password whose NFKC form differs will not verify and that user resets it. + +A password Clerk cannot verify is **dropped, not rejected**: the user imports +without it and can sign in another way or reset it. Their run line carries +`passwordDropped: true`. + ### Verified vs unverified identifiers Every platform records verification differently, and each source declares @@ -1084,7 +1128,7 @@ which style it uses. An identifier the source never confirmed is routed to field, because Clerk creates primary identifiers **verified** — sending an unconfirmed address there would silently promote it. -- **Boolean** (`auth0`, `firebase`, `workos`): `true`/`false`. A CSV export stringifies these, so `"false"` is +- **Boolean** (`auth0`, `betterauth`, `firebase`, `workos`): `true`/`false`. A CSV export stringifies these, so `"false"` is read as false, not as a non-empty string. `TRUE`, `FALSE`, `t` and `f` read too, as a spreadsheet or psql writes them. - **Timestamp** (`supabase`): a nullable confirmation time. Any real value @@ -1236,6 +1280,9 @@ The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on the export skips that request, and it may not carry the hash parameters: pass them to the import. The two WorkOS paths are on `api.workos.com`. +The database exports (`supabase`, `betterauth`) make no HTTP calls at all — +they connect over `--db-url`. + ## Notes - `userId` in the source file becomes the Clerk user's `external_id`. That is diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts new file mode 100644 index 0000000000..791f95462e --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -0,0 +1,242 @@ +/** + * `clerk migrate export betterauth` — read users out of a Better Auth database. + * + * Ported from the standalone migration-tool's `src/export/betterauth.ts`, on + * the `Bun.sql`/`bun:sqlite` client. + * + * Better Auth's schema depends on which plugins are enabled, so the columns + * are **detected from the schema** rather than asked for: the username plugin + * adds `username`, admin adds `banned`, phone-number adds `phoneNumber`, + * anonymous adds `isAnonymous`, and + * so on. Selecting a column that is not there fails the whole query, and + * asking the user which plugins they run is a question their database can + * already answer. + * + * Passwords live on the `account` row for the credential provider, not on the + * user, which is why the export joins. + */ + +import { log } from "../../../lib/log.ts"; +import { withGutter, withSpinner } from "../../../lib/spinner.ts"; +import type { UserLine } from "../lib/run-store.ts"; +import { printTarget } from "../lib/target.ts"; +import { withDbClient, type DbClient } from "../lib/db.ts"; +import { finishExport, startExportRun } from "./shared.ts"; +import { + promptDbUrl, + resolveDbUrl, + type DbExportOptions, + type ResolveConfig, +} from "./db-options.ts"; +import { withInputRetry } from "../lib/input-retry.ts"; + +/** Columns a Better Auth plugin adds to the user table. */ +export const PLUGIN_COLUMNS = [ + "username", + "displayUsername", + "phoneNumber", + "phoneNumberVerified", + "role", + "banned", + "banReason", + "banExpires", + "twoFactorEnabled", + "isAnonymous", +] as const; + +export type PluginColumn = (typeof PLUGIN_COLUMNS)[number]; + +/** Columns every Better Auth install has. */ +const CORE_COLUMNS = ["id", "email", "emailVerified", "name", "createdAt", "updatedAt"] as const; + +/** How one Better Auth database names its tables and columns. */ +export type BetterAuthSchema = { + userTable: string; + accountTable: string; + /** + * The column for a field. Better Auth's Drizzle generator writes snake_case + * (`email_verified`) unless the project sets `camelCase: true`; its Kysely + * and Prisma setups keep camelCase. + */ + column: (field: string) => string; + /** The plugin columns this database has. */ + plugins: Set; +}; + +/** `emailVerified` → `email_verified`, as Better Auth's Drizzle generator does it. */ +function toSnakeCase(field: string): string { + return field + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z\d])([A-Z])/g, "$1_$2") + .toLowerCase(); +} + +/** + * Every column of a table, or an empty set when there is no such table. + * + * SQLite has no `information_schema`, so it goes through `PRAGMA` — and the + * PRAGMA takes the table name inline rather than as a bind parameter. + */ +async function tableColumns(client: DbClient, table: string): Promise> { + if (client.dbType === "sqlite") { + const rows = await client.query<{ name: string }>(`PRAGMA table_info(${client.quote(table)})`); + return new Set(rows.map((row) => row.name)); + } + const scope = client.dbType === "mysql" ? "DATABASE()" : "current_schema()"; + const rows = await client.query<{ column_name?: string; COLUMN_NAME?: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = ${client.placeholder(1)} AND table_schema = ${scope}`, + [table], + ); + // MySQL 8 answers with an upper-case column label. + return new Set(rows.map((row) => row.column_name ?? row.COLUMN_NAME ?? "")); +} + +/** Table names to try, in order: Better Auth's default, then `usePlural: true`. */ +const TABLE_CANDIDATES = [ + ["user", "account"], + ["users", "accounts"], +] as const; + +/** + * Asks the database how it names Better Auth's tables and columns, and which + * plugin columns it has. Selecting a column that is not there fails the whole + * query, so nothing is assumed. + */ +export async function detectSchema(client: DbClient): Promise { + for (const [userTable, accountTable] of TABLE_CANDIDATES) { + const columns = await tableColumns(client, userTable); + if (columns.size === 0) continue; + const snake = !columns.has("emailVerified") && columns.has("email_verified"); + const column = (field: string) => (snake ? toSnakeCase(field) : field); + const plugins = new Set(PLUGIN_COLUMNS.filter((field) => columns.has(column(field)))); + return { userTable, accountTable, column, plugins }; + } + // No table found: the query names the default, and its "no such table" + // error carries the hint. + return { + userTable: "user", + accountTable: "account", + column: (field) => field, + plugins: new Set(), + }; +} + +/** + * Builds the SELECT, including only the plugin columns that exist. Each column + * comes back under its camelCase name, however the database spells it. + * + * @param schema - From {@link detectSchema}. + */ +export function buildBetterAuthQuery(client: DbClient, schema: BetterAuthSchema): string { + const q = (identifier: string) => client.quote(identifier); + const { column } = schema; + const select = (field: string) => + column(field) === field ? `u.${q(field)}` : `u.${q(column(field))} AS ${q(field)}`; + const selected = [ + ...CORE_COLUMNS.map(select), + ...PLUGIN_COLUMNS.filter((field) => schema.plugins.has(field)).map(select), + ]; + + // LEFT JOIN, not INNER: a user who only ever signed in with OAuth has no + // credential account, and dropping them would silently shrink the export. + return ( + `SELECT ${selected.join(", ")}, a.${q("password")} AS ${q("password_hash")} ` + + `FROM ${q(schema.userTable)} u ` + + `LEFT JOIN ${q(schema.accountTable)} a ON a.${q(column("userId"))} = u.${q("id")} ` + + `AND a.${q(column("providerId"))} = 'credential' ` + + `ORDER BY u.${q("id")} ASC` + ); +} + +type BetterAuthRow = Record & { id?: unknown }; + +/** Renames the schema's camelCase onto what the betterauth transformer reads. */ +const FIELD_ALIASES: Record = { + id: "user_id", + emailVerified: "email_verified", + phoneNumber: "phone_number", + phoneNumberVerified: "phone_number_verified", + displayUsername: "display_username", + createdAt: "created_at", + updatedAt: "updated_at", +}; + +export function buildBetterAuthExport( + rows: BetterAuthRow[], + record: (line: UserLine) => void = () => {}, +) { + const users: Record[] = []; + const counts = { email: 0, emailVerified: 0, password: 0, name: 0, username: 0, phone: 0 }; + + for (const row of rows) { + const userId = String(row.id ?? ""); + const user: Record = {}; + + for (const [key, value] of Object.entries(row)) { + if (value === null || value === undefined) continue; + user[FIELD_ALIASES[key] ?? key] = value instanceof Date ? value.toISOString() : value; + } + + if (row.email) counts.email++; + if (row.emailVerified) counts.emailVerified++; + if (row.password_hash) counts.password++; + if (row.name) counts.name++; + if (row.username) counts.username++; + if (row.phoneNumber) counts.phone++; + + users.push(user); + record({ sourceId: userId, status: "exported" }); + } + + return { + users, + coverage: [ + { label: "have an email address", count: counts.email }, + { label: "have a verified email", count: counts.emailVerified }, + { label: "have a password hash", count: counts.password }, + { label: "have a name", count: counts.name }, + { label: "have a username", count: counts.username }, + { label: "have a phone number", count: counts.phone }, + ], + }; +} + +const BETTERAUTH_DB = { + platform: "betterauth", + envVar: "BETTERAUTH_DB_URL", + prompt: "Better Auth database connection string", + hint: "Postgres, MySQL, libsql://… or a SQLite file — whichever your Better Auth install uses.", +} as const satisfies ResolveConfig; + +export async function exportBetterAuth(options: DbExportOptions): Promise { + const dbUrl = await resolveDbUrl(options, BETTERAUTH_DB); + + await withGutter("Exporting users from Better Auth", async () => { + if (!options.json) printTarget({ platform: "betterauth" }); + const { + value: { rows, plugins }, + } = await withInputRetry( + dbUrl, + async () => promptDbUrl(BETTERAUTH_DB), + async (connectionString) => + withSpinner("Reading the user table...", async () => + withDbClient(connectionString, "betterauth", async (client) => { + const schema = await detectSchema(client); + const rows = await client.query(buildBetterAuthQuery(client, schema)); + return { rows, plugins: schema.plugins }; + }), + ), + ); + + log.info( + plugins.size > 0 + ? `Detected plugin columns: ${[...plugins].join(", ")}.` + : "No plugin columns detected; exporting the core user fields.", + ); + + const run = await startExportRun(options, { platform: "betterauth" }); + const { users, coverage } = buildBetterAuthExport(rows, run.append); + finishExport({ run, options, users, coverage }); + }); +} diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index eb08624ffc..1908ac52f4 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -1,19 +1,29 @@ /** - * The database-backed exports: resolving the connection string, the Supabase - * export's row mapping, and connection failures against a real SQLite file. + * The database-backed exports, driven against a real SQLite database: + * resolving the connection string, the Supabase export's row mapping, the + * Better Auth export's schema detection, and connection failures. * * SQLite because it is the one engine that needs no container, and it - * exercises the same client. Postgres and MySQL are covered by the manual - * matrix run recorded in the ticket. + * exercises the same client, the same query building and the same plugin + * detection path (via `PRAGMA` rather than `information_schema`). Postgres and + * MySQL are covered by the manual matrix run recorded in the ticket. */ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { DbClient } from "../lib/db.ts"; import type { UserLine } from "../lib/run-store.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { createDbClient, type DbClient } from "../lib/db.ts"; +import { + buildBetterAuthExport, + buildBetterAuthQuery, + detectSchema, + exportBetterAuth, + PLUGIN_COLUMNS, +} from "./betterauth.ts"; import { buildSupabaseExport, exportSupabase, @@ -33,6 +43,7 @@ const captured = useCaptureLog(); let workDir: string; let originalCwd: string; +let counter = 0; beforeAll(() => { originalCwd = process.cwd(); @@ -49,6 +60,42 @@ beforeEach(() => { fs.rmSync(path.join(workDir, ".clerk"), { recursive: true, force: true }); }); +/** Builds a fresh SQLite file so each test starts from a known schema. */ +function makeDb(build: (db: Database) => void): string { + const file = path.join(workDir, `db-${counter++}.sqlite`); + const db = new Database(file, { create: true }); + build(db); + db.close(); + return file; +} + +function betterAuthDb(pluginColumns: string[], rows: Record[] = []): string { + return makeDb((db) => { + const extra = pluginColumns.map((column) => `, "${column}" TEXT`).join(""); + db.run( + `CREATE TABLE "user" (id TEXT PRIMARY KEY, email TEXT, "emailVerified" INTEGER, name TEXT, + "createdAt" TEXT, "updatedAt" TEXT${extra})`, + ); + db.run(`CREATE TABLE "account" (id TEXT, "userId" TEXT, "providerId" TEXT, password TEXT)`); + for (const row of rows) { + const keys = Object.keys(row); + db.run( + `INSERT INTO "user" (${keys.map((k) => `"${k}"`).join(",")}) VALUES (${keys.map(() => "?").join(",")})`, + keys.map((k) => row[k]) as never[], + ); + } + }); +} + +async function withClient(file: string, work: (client: DbClient) => Promise): Promise { + const client = await createDbClient(file); + try { + return await work(client); + } finally { + await client.close(); + } +} + describe("looksLikeConnectionString", () => { test.each([ ["postgres://u:p@h:5432/db", true], @@ -143,6 +190,128 @@ describe("resolveDbUrl", () => { }); }); +describe("betterauth export", () => { + test("detects only the plugin columns that exist", async () => { + await withClient(betterAuthDb(["username", "banned"]), async (client) => { + expect([...(await detectSchema(client)).plugins].sort()).toEqual(["banned", "username"]); + }); + }); + + test("detects nothing on a core-only schema", async () => { + await withClient(betterAuthDb([]), async (client) => { + expect((await detectSchema(client)).plugins.size).toBe(0); + }); + }); + + test("detects every plugin column when all are present", async () => { + await withClient(betterAuthDb([...PLUGIN_COLUMNS]), async (client) => { + expect((await detectSchema(client)).plugins.size).toBe(PLUGIN_COLUMNS.length); + }); + }); + + // Selecting a column that is not there fails the whole query, which is why + // the columns are detected rather than assumed. + test("selects only detected columns", async () => { + await withClient(betterAuthDb(["username"]), async (client) => { + const query = buildBetterAuthQuery(client, await detectSchema(client)); + expect(query).toContain('"username"'); + expect(query).not.toContain('"twoFactorEnabled"'); + }); + }); + + test("the built query actually runs against the schema it was built for", async () => { + const file = betterAuthDb( + ["username", "role"], + [{ id: "u1", email: "a@x.dev", username: "a" }], + ); + const rows = await withClient(file, async (client) => + client.query(buildBetterAuthQuery(client, await detectSchema(client))), + ); + expect(rows).toHaveLength(1); + }); + + // A user who only ever signed in with OAuth has no credential account; + // an INNER JOIN would drop them and silently shrink the export. + test("keeps a user with no credential account", async () => { + const file = betterAuthDb( + [], + [ + { id: "u1", email: "a@x.dev" }, + { id: "u2", email: "b@x.dev" }, + ], + ); + const rows = await withClient(file, async (client) => + client.query(buildBetterAuthQuery(client, await detectSchema(client))), + ); + expect(rows).toHaveLength(2); + }); + + // Better Auth's Drizzle generator writes snake_case unless `camelCase: true`, + // and `usePlural: true` pluralizes the tables. + test.each([ + ["snake_case columns", "user", "account"], + ["snake_case columns and plural tables", "users", "accounts"], + ])("reads a Drizzle schema with %s", async (_label, userTable, accountTable) => { + const file = makeDb((db) => { + db.run( + `CREATE TABLE "${userTable}" (id TEXT PRIMARY KEY, email TEXT, email_verified INTEGER, name TEXT, + created_at TEXT, updated_at TEXT, ban_expires TEXT, banned INTEGER)`, + ); + db.run( + `CREATE TABLE "${accountTable}" (id TEXT, user_id TEXT, provider_id TEXT, password TEXT)`, + ); + db.run( + `INSERT INTO "${userTable}" (id, email, email_verified, banned) VALUES ('u1', 'a@x.dev', 1, 1)`, + ); + db.run(`INSERT INTO "${accountTable}" VALUES ('a1', 'u1', 'credential', 'salt:hash')`); + }); + + const { rows, schema } = await withClient(file, async (client) => { + const detected = await detectSchema(client); + return { rows: await client.query(buildBetterAuthQuery(client, detected)), schema: detected }; + }); + + expect([...schema.plugins].sort()).toEqual(["banExpires", "banned"]); + // Read back under the camelCase names the rest of the export expects. + expect(rows).toEqual([ + expect.objectContaining({ + id: "u1", + emailVerified: 1, + banned: 1, + password_hash: "salt:hash", + }), + ]); + }); + + test("renames camelCase columns onto what the transformer reads", () => { + const { users } = buildBetterAuthExport([ + { id: "u1", emailVerified: 1, phoneNumber: "+1555", createdAt: "2025-01-01" }, + ]); + expect(users[0]).toMatchObject({ + user_id: "u1", + email_verified: 1, + phone_number: "+1555", + created_at: "2025-01-01", + }); + }); + + test("exports end to end and reports the detected plugins", async () => { + const file = betterAuthDb(["username"], [{ id: "u1", email: "a@x.dev", username: "ada" }]); + + await exportBetterAuth({ dbUrl: file, output: "ba.json" }); + + expect(captured.err).toContain("Detected plugin columns: username"); + expect(JSON.parse(fs.readFileSync(path.join(workDir, "ba.json"), "utf-8")).users).toHaveLength( + 1, + ); + }); + + test("says so plainly when no plugins are in use", async () => { + await exportBetterAuth({ dbUrl: betterAuthDb([]), output: "ba2.json" }); + expect(captured.err).toContain("No plugin columns detected"); + }); +}); + describe("supabase export", () => { // The source splits a display or OAuth name, but only when first_name is // empty; coalescing them into first_name here would stop it. diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index b6c2a2f8fa..47813e5e30 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -3,6 +3,7 @@ import { throwUsageError } from "../../../lib/errors.ts"; import { select } from "../../../lib/listage.ts"; import { isAgent, isHuman } from "../../../mode.ts"; import { exportAuth0 } from "./auth0.ts"; +import { exportBetterAuth } from "./betterauth.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; import { exportSupabase } from "./supabase.ts"; @@ -53,6 +54,7 @@ const handlers = { clerk: exportClerk, auth0: exportAuth0, supabase: exportSupabase, + betterauth: exportBetterAuth, firebase: exportFirebase, workos: exportWorkOs, }; @@ -66,6 +68,13 @@ const DB_PLATFORMS = [ dbUrl: "Postgres connection string", example: "postgres://postgres:password@db.xxx.supabase.co:5432/postgres", }, + { + key: "betterauth", + summary: "Export users from a Better Auth database", + envVar: "BETTERAUTH_DB_URL", + dbUrl: "Postgres, MySQL, libsql/Turso or SQLite connection string", + example: "./db.sqlite", + }, ] as const; /** Registers `export [platform]` under the `migrate` group. */ diff --git a/packages/cli-core/src/commands/migrate/export/registry.test.ts b/packages/cli-core/src/commands/migrate/export/registry.test.ts index 54a0ec2d5e..e53f070853 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.test.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.test.ts @@ -4,7 +4,14 @@ import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./regist describe("export registry", () => { test("registers every source platform", () => { - expect(exportPlatformKeys()).toEqual(["clerk", "auth0", "supabase", "firebase", "workos"]); + expect(exportPlatformKeys()).toEqual([ + "clerk", + "auth0", + "supabase", + "firebase", + "betterauth", + "workos", + ]); }); test.each([...exportPlatforms])("$key carries a label and description", (entry) => { diff --git a/packages/cli-core/src/commands/migrate/export/registry.ts b/packages/cli-core/src/commands/migrate/export/registry.ts index 2103941e93..bfef51ac24 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.ts @@ -11,6 +11,7 @@ */ import { exportAuth0 } from "./auth0.ts"; +import { exportBetterAuth } from "./betterauth.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; import { exportSupabase } from "./supabase.ts"; @@ -54,6 +55,13 @@ export const exportPlatforms: ExportRegistryEntry[] = [ sourceKey: "firebase", run: async (options) => exportFirebase(options), }, + { + key: "betterauth", + label: "Better Auth", + description: "A Better Auth database — plugin columns detected automatically", + sourceKey: "betterauth", + run: async (options) => exportBetterAuth(options), + }, { key: "workos", label: "WorkOS", diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index f8803b6314..bd794973af 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -112,9 +112,9 @@ describe("registerMigrate", () => { ]); }); - test("migrate export supabase takes exactly its flags", () => { + test.each(["supabase", "betterauth"])("migrate export %s takes exactly its flags", (platform) => { expect( - findCommand(["migrate", "export", "supabase"])?.options.map((option) => option.long), + findCommand(["migrate", "export", platform])?.options.map((option) => option.long), ).toEqual(["--db-url", "--output", "--yes", "--runs-dir", "--json"]); }); diff --git a/packages/cli-core/src/commands/migrate/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index ab362ab5a1..ef3a46e62f 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.ts @@ -323,7 +323,7 @@ export async function createDbClient( } } -export type DbPlatform = "supabase"; +export type DbPlatform = "supabase" | "betterauth"; /** * Turns a driver error into something a user can act on. diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index d0924628cf..94218a8b3f 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2033,6 +2033,11 @@ describe("run", () => { ], "auth0|1", ], + [ + "betterauth", + [{ user_id: "ba1", email: "a@x.dev", email_verified: true, password_hash: BCRYPT }], + "ba1", + ], [ "supabase", [ @@ -2182,7 +2187,7 @@ describe("run", () => { )) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain( - 'Unknown source "nope". Valid sources: clerk, auth0, firebase, supabase, workos.', + 'Unknown source "nope". Valid sources: clerk, auth0, betterauth, firebase, supabase, workos.', ); expect(requests).toHaveLength(0); }); diff --git a/packages/cli-core/src/commands/migrate/sources/betterauth.ts b/packages/cli-core/src/commands/migrate/sources/betterauth.ts new file mode 100644 index 0000000000..33071dbf46 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/sources/betterauth.ts @@ -0,0 +1,133 @@ +import type { SourceEntry } from "../types.ts"; +import { + detectStandardHasher, + isVerified, + routeByVerification, + splitName, + toIsoDate, +} from "./shared.ts"; + +/** + * Better Auth → Clerk source. + * + * Works with `clerk migrate export betterauth`, which joins the user table + * with the credential account row to pick up the stored `password_hash`. + * + * Better Auth plugins add columns Clerk has no equivalent for + * (`display_username`, `role`, `ban_reason`, `two_factor_enabled`). They need + * no handling: the schema strips anything it does not declare. `banned` is the + * exception, because that one *is* a Clerk field. + */ + +/** Better Auth's own scrypt: `<32 hex salt>:<128 hex key>`, N=16384, r=16, p=1. */ +const BETTER_AUTH_SCRYPT = /^([0-9a-f]{32}):([0-9a-f]{128})$/i; + +/** + * Works out which hasher produced a stored Better Auth password, one user at + * a time. + * + * Better Auth hashes with its own scrypt by default and lets an app swap in + * bcrypt or argon2, so one database can hold more than one kind. Better Auth's + * scrypt uses the hex salt string itself as the salt and a 64-byte key, which + * is exactly what `scrypt_werkzeug` verifies once the parameters are written + * inline. + * + * @returns The digest and hasher to send, or `undefined` when the hash is not + * one Clerk can verify. + */ +export function detectBetterAuthHash( + hash: string, +): + | { password: string; passwordHasher: "scrypt_werkzeug" | "bcrypt" | "argon2id" | "argon2i" } + | undefined { + const scrypt = BETTER_AUTH_SCRYPT.exec(hash); + if (scrypt) { + return { + password: `scrypt:16384:16:1$${scrypt[1]}$${scrypt[2]}`, + passwordHasher: "scrypt_werkzeug", + }; + } + const passwordHasher = detectStandardHasher(hash); + return passwordHasher ? { password: hash, passwordHasher } : undefined; +} + +const betterAuthSource = { + key: "betterauth", + label: "Better Auth", + description: + "Works with the Better Auth export. Detects scrypt, bcrypt and argon2 passwords per user, and carries the admin plugin's banned flag while its ban has not expired.", + carries: { + passwords: { + level: "yes", + note: "Better Auth's scrypt, bcrypt and argon2 hashes come across, detected per user. Any other hash is dropped, and that user resets their password.", + }, + mfa: { + level: "no", + note: "The two-factor plugin's secrets are not exported. Users enrol again in Clerk.", + }, + metadata: { + level: "no", + note: "Plugin columns such as `role` have no Clerk equivalent and are left out.", + }, + }, + caveats: [ + "Better Auth's scrypt normalizes a password to NFKC before hashing it; Clerk hashes it as typed. A password whose NFKC form differs — full-width characters, some ligatures — will not verify, and that user resets it.", + ], + transformer: { + user_id: "userId", + email: "email", + email_verified: "emailVerified", + name: "name", + password_hash: "password", + username: "username", + phone_number: "phone", + phone_number_verified: "phoneVerified", + created_at: "createdAt", + }, + postTransform: (user) => { + // Every Better Auth row has one, and Clerk sets its own. + delete user.updated_at; + + routeByVerification(user, "email", "emailVerified", "boolean"); + routeByVerification(user, "phone", "phoneVerified", "boolean"); + splitName(user); + + if (typeof user.password === "string" && user.password) { + const detected = detectBetterAuthHash(user.password); + if (detected) { + user.password = detected.password; + user.passwordHasher = detected.passwordHasher; + } else { + // Imported without it rather than rejected: the user can still sign + // in another way, or reset it. + delete user.password; + user.passwordDropped = true; + } + } + + // Only carry `banned` when it is actually true — Better Auth writes false + // for every user that was never banned, and sending that to Clerk is noise. + // SQLite, libSQL and MySQL hand back 1/0 and CSV hands back "true", so this + // runs before normalizeUserData and must accept those too. + // A ban whose `banExpires` has passed is over: Better Auth lifts it only + // at the user's next sign-in, so the column still says banned. An expiry + // that can't be read keeps the ban. + const rawExpiry = user.banExpires ?? user.ban_expires; + const expiry = typeof rawExpiry === "number" && rawExpiry < 1e11 ? rawExpiry * 1000 : rawExpiry; + const expired = Date.parse(String(toIsoDate(expiry, true))) <= Date.now(); + if (isVerified(user.banned, "boolean") && !expired) user.banned = true; + else delete user.banned; + delete user.banExpires; + delete user.ban_expires; + + // The anonymous plugin's guests are throwaway accounts with placeholder + // emails (anon-…@…), not people to migrate. + if (isVerified(user.isAnonymous ?? user.is_anonymous, "boolean")) { + user.skipReason = "anonymous Better Auth user"; + } + delete user.isAnonymous; + delete user.is_anonymous; + }, +} satisfies SourceEntry; + +export default betterAuthSource; diff --git a/packages/cli-core/src/commands/migrate/sources/registry.ts b/packages/cli-core/src/commands/migrate/sources/registry.ts index 4bbd0b4378..0b37827a71 100644 --- a/packages/cli-core/src/commands/migrate/sources/registry.ts +++ b/packages/cli-core/src/commands/migrate/sources/registry.ts @@ -10,6 +10,7 @@ import { throwUsageError } from "../../../lib/errors.ts"; import type { SourceEntry } from "../types.ts"; import auth0Source from "./auth0.ts"; +import betterAuthSource from "./betterauth.ts"; import clerkSource from "./clerk.ts"; import firebaseSource from "./firebase.ts"; import supabaseSource from "./supabase.ts"; @@ -18,6 +19,7 @@ import workosSource from "./workos.ts"; export const sources: SourceEntry[] = [ clerkSource, auth0Source, + betterAuthSource, firebaseSource, supabaseSource, workosSource, diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index afa8ceeefb..b6358252a2 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -46,7 +46,14 @@ const one = (key: string, record: Record, context = {}) => describe("registry", () => { test("registers the built-in platforms", () => { - expect(sourceKeys()).toEqual(["clerk", "auth0", "firebase", "supabase", "workos"]); + expect(sourceKeys()).toEqual([ + "clerk", + "auth0", + "betterauth", + "firebase", + "supabase", + "workos", + ]); }); test.each([...sources])("$key maps a source field to userId", (source) => { @@ -255,6 +262,110 @@ describe("workos", () => { }); }); +describe("betterauth", () => { + const base = { user_id: "ba1", email: "a@x.dev", email_verified: true }; + + test.each([ + [1, "anonymous Better Auth user"], + [true, "anonymous Better Auth user"], + [0, undefined], + [undefined, undefined], + ])("isAnonymous=%p sets skipReason %p", (isAnonymous, expected) => { + const user = one("betterauth", { ...base, isAnonymous }); + expect(user?.skipReason).toBe(expected); + expect("isAnonymous" in (user ?? {})).toBe(false); + }); + + // Better Auth's own scrypt: a 16-byte hex salt, a colon, a 64-byte hex key. + const SALT = "a".repeat(32); + const KEY = "b".repeat(128); + + test.each([ + [`${SALT}:${KEY}`, `scrypt:16384:16:1$${SALT}$${KEY}`, "scrypt_werkzeug"], + ["$2a$10$hash", "$2a$10$hash", "bcrypt"], + ["$2b$10$hash", "$2b$10$hash", "bcrypt"], + ["$2y$10$hash", "$2y$10$hash", "bcrypt"], + [ + "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA", + "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA", + "argon2id", + ], + [ + "$argon2i$v=19$m=4096,t=3,p=1$c2FsdA$aGFzaA", + "$argon2i$v=19$m=4096,t=3,p=1$c2FsdA$aGFzaA", + "argon2i", + ], + ])("detects the hasher per user: %s", async (stored, password, passwordHasher) => { + const { users } = await load("betterauth", [{ ...base, password_hash: stored }]); + expect(users[0]).toMatchObject({ password, passwordHasher }); + expect(users[0]?.passwordDropped).toBeUndefined(); + }); + + // Imported without the password rather than rejected: the user can still + // sign in another way, or reset it. + test.each([["plaintext"], ["$pbkdf2$abc"], [`${SALT}:short`]])( + "drops a password it cannot verify (%s) and imports the user", + async (stored) => { + const { users, validationFailed } = await load("betterauth", [ + { ...base, password_hash: stored }, + ]); + expect(validationFailed).toBe(0); + expect(users[0]?.password).toBeUndefined(); + expect(users[0]?.passwordHasher).toBeUndefined(); + expect(users[0]?.passwordDropped).toBe(true); + }, + ); + + test("names no hasher for a user without a password", async () => { + const { users } = await load("betterauth", [base]); + expect(users[0]?.passwordHasher).toBeUndefined(); + expect(users[0]?.passwordDropped).toBeUndefined(); + }); + + test("routes an unverified phone", () => { + const user = one("betterauth", { + ...base, + phone_number: "+15555550100", + phone_number_verified: false, + }); + expect(user?.unverifiedPhoneNumbers).toBe("+15555550100"); + }); + + test.each([ + [true, true], + [1, true], + ["true", true], + [false, undefined], + [0, undefined], + [undefined, undefined], + ])("banned=%p is carried through as %p", (banned, expected) => { + expect(one("betterauth", { ...base, banned })?.banned).toBe(expected as boolean | undefined); + }); + + // Better Auth lifts an expired ban only at the next sign-in, so the column + // can still say banned long after the ban ended. + test.each([ + ["an expiry in the past", "2020-01-01T00:00:00.000Z", undefined], + ["an expiry in the future", "2999-01-01T00:00:00.000Z", true], + ["epoch seconds in the past", 1_577_836_800, undefined], + ["epoch milliseconds in the future", 32_472_144_000_000, true], + ["an unreadable expiry", "soon", true], + ])("a ban with %s -> banned %p", (_label, banExpires, expected) => { + const user = one("betterauth", { ...base, banned: true, banExpires }); + expect(user?.banned).toBe(expected as boolean | undefined); + }); + + test("drops plugin-only columns during validation", async () => { + const { users } = await load("betterauth", [ + { ...base, role: "admin", display_username: "ADA", two_factor_enabled: true }, + ]); + const user = users[0] as Record; + expect("role" in user).toBe(false); + expect("display_username" in user).toBe(false); + expect("two_factor_enabled" in user).toBe(false); + }); +}); + describe("firebase", () => { const base = { localId: "fb1", email: "a@x.dev", emailVerified: true }; @@ -682,6 +793,10 @@ describe("fields the CLI's own export adds", () => { raw_app_meta_data: { providers: ["email"] }, }, ], + [ + "betterauth", + { user_id: "b1", email: "a@x.dev", email_verified: true, updated_at: "2024-01-01" }, + ], ])("%s reports no unknown fields", async (key, record) => { const { unknownFields } = await load(key, [record]); expect(unknownFields).toEqual({}); @@ -708,6 +823,7 @@ describe("invalid records", () => { // Firebase skips such a user instead; see "skips a user with no email or phone". const INVALID: [string, Record][] = [ ["auth0", { user_id: "a1" }], + ["betterauth", { user_id: "a3" }], ["supabase", { id: "a5" }], ]; @@ -730,6 +846,7 @@ describe("invalid records", () => { /** The per-platform source field that becomes a Clerk identifier. */ function identifierFor(key: string, email = "ok@x.dev"): Record { if (key === "auth0") return { email, email_verified: true }; + if (key === "betterauth") return { email, email_verified: true }; if (key === "firebase") return { email, emailVerified: true }; return { email, email_confirmed_at: "2024-01-01 00:00:00+00" }; } diff --git a/packages/cli-core/src/commands/migrate/wizard.test.ts b/packages/cli-core/src/commands/migrate/wizard.test.ts index 77b24deb14..394742c6c7 100644 --- a/packages/cli-core/src/commands/migrate/wizard.test.ts +++ b/packages/cli-core/src/commands/migrate/wizard.test.ts @@ -64,6 +64,7 @@ describe("promptForSource", () => { expect(selectCall(0)?.choices.map((choice) => choice.value)).toEqual([ "clerk", "auth0", + "betterauth", "firebase", "supabase", "workos", @@ -73,7 +74,7 @@ describe("promptForSource", () => { test("labels each choice with the source's display name", async () => { mockSelect.mockResolvedValue("clerk"); await promptForSource(); - expect(selectCall(0)?.choices.map((choice) => choice.name)).toContain("Supabase"); + expect(selectCall(0)?.choices.map((choice) => choice.name)).toContain("Better Auth"); }); }); diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 764b3f3e5e..66bb697cfa 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -230,7 +230,7 @@ describe("generateCompletions", () => { "--source", "", ]).completions.map((c) => c.name); - expect(names).toEqual(["clerk", "auth0", "firebase", "supabase", "workos"]); + expect(names).toEqual(["clerk", "auth0", "betterauth", "firebase", "supabase", "workos"]); } finally { if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; else process.env.CLERK_EXPERIMENTAL = saved; From 9ccc713193a0188da90ff2335df3227d270657d1 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 13:01:17 -0400 Subject: [PATCH 02/17] test(migrate): e2e Better Auth scrypt hash verifies after import Co-Authored-By: Claude Opus 5.5 --- test/e2e/migrate.test.ts | 54 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 9b7c15ddbc..1fc5f97e7e 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -6,6 +6,8 @@ * import without `--yes` writes nothing, the import creates every user and * records the run, each bcrypt hash verifies against the password it was * made from, and `undo` deletes them again. + * - A Better Auth scrypt hash, sent as `scrypt_werkzeug`, verifies against the + * password it was made from. A unit test can only check the string shape. * - A user whose only email is unverified, imported into an instance that * requires an email, is refused by Clerk. The import's checks reject that * user up front on the strength of this test, so it checks both halves. @@ -15,7 +17,7 @@ */ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { randomBytes } from "node:crypto"; +import { randomBytes, scryptSync } from "node:crypto"; import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -88,7 +90,7 @@ function latestLines(runId: string): Record[] { } function writeExport(users: Record[]): string { - const file = join(workDir, `supabase-${randomBytes(4).toString("hex")}.json`); + const file = join(workDir, `export-${randomBytes(4).toString("hex")}.json`); writeFileSync(file, JSON.stringify(users)); return file; } @@ -191,6 +193,54 @@ test("a Supabase export dry-runs, imports, its passwords verify, and undo delete } }, 60_000); +/** A password hashed exactly the way Better Auth's default hasher does it. */ +function betterAuthHash(password: string): string { + const salt = randomBytes(16).toString("hex"); + const key = scryptSync(password.normalize("NFKC"), salt, 64, { + N: 16384, + r: 16, + p: 1, + maxmem: 128 * 16384 * 16 * 2, + }); + return `${salt}:${key.toString("hex")}`; +} + +test("a Better Auth scrypt hash imports and verifies against its password", async () => { + const hex = randomBytes(6).toString("hex"); + const password = `Migrate${hex}!1`; + const file = writeExport([ + { + user_id: `ba_${hex}`, + email: `e2e-${hex}+clerk_test@clerkcookie.com`, + email_verified: true, + password_hash: betterAuthHash(password), + }, + ]); + + const imported = await cli([ + "migrate", + "import", + file, + "--source", + "betterauth", + "--yes", + "--json", + ]); + const { run } = JSON.parse(imported.stdout.toString()) as { run: { id: string } }; + const [line] = latestLines(run.id); + expect(line).toMatchObject({ status: "created" }); + + const verify = await cli([ + "api", + `/users/${line?.clerkId as string}/verify_password`, + "-X", + "POST", + "-d", + JSON.stringify({ password }), + ]); + expect(JSON.parse(verify.stdout.toString())).toMatchObject({ verified: true }); +}, 60_000); + test("a user whose only email is unverified is refused where email is required", async () => { if (!emailRequired) { // The test app decides this, not the test; say so rather than pass silently. From 46437bcdc03e316d807ac544515c25c482ff8601 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 13:01:18 -0400 Subject: [PATCH 03/17] docs(changeset): Add clerk migrate export betterauth and Better Auth imports Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-betterauth.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-betterauth.md diff --git a/.changeset/migrate-betterauth.md b/.changeset/migrate-betterauth.md new file mode 100644 index 0000000000..bec7f63f5e --- /dev/null +++ b/.changeset/migrate-betterauth.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate export betterauth`, and let `clerk migrate import` read Better Auth exports. The export reads a Better Auth database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), including schemas Drizzle generated with snake_case columns or plural tables, and detects plugin columns. Better Auth's scrypt, bcrypt and argon2 passwords carry across, and anonymous users are skipped. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. From 4d39f966e8d4751f9d39b4dbc04bbbc8ac1b2c8d Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 14:48:19 -0400 Subject: [PATCH 04/17] fix(migrate): never re-prompt for a Better Auth connection under --json Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/export/betterauth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index 791f95462e..2e73e41e1f 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -227,6 +227,7 @@ export async function exportBetterAuth(options: DbExportOptions): Promise return { rows, plugins: schema.plugins }; }), ), + options, ); log.info( From f7722d2cf3649095e7dda35317a57d888544745a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 15:45:49 -0400 Subject: [PATCH 05/17] fix(migrate): export each Better Auth user once The credential-account join returns a row per account, so a user with two was exported twice and the import refused the duplicate source ID. Rows that agree are now one user. A user whose accounts hold different hashes is skipped and named, rather than given whichever hash came first. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/betterauth.ts | 33 +++++++++++++++ .../migrate/export/db-exports.test.ts | 41 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index 2e73e41e1f..a0c7fcec59 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -169,8 +169,34 @@ export function buildBetterAuthExport( const users: Record[] = []; const counts = { email: 0, emailVerified: 0, password: 0, name: 0, username: 0, phone: 0 }; + // The join returns a row per credential account, and nothing in Better + // Auth's schema stops a user having two. Rows that agree are one user. Rows + // with different hashes cannot be settled here, so that user is skipped + // rather than given whichever hash came first. + const byId = new Map }>(); for (const row of rows) { const userId = String(row.id ?? ""); + const entry = byId.get(userId) ?? { row, accounts: 0, hashes: new Set() }; + entry.accounts++; + if (row.password_hash !== null && row.password_hash !== undefined) { + entry.hashes.add(row.password_hash); + entry.row = row; + } + byId.set(userId, entry); + } + + let conflicts = 0; + for (const [userId, { row, accounts, hashes }] of byId) { + if (hashes.size > 1) { + conflicts++; + record({ + sourceId: userId, + status: "skipped", + error: `has ${accounts} credential accounts with different password hashes; keep one in Better Auth and export again`, + }); + continue; + } + const user: Record = {}; for (const [key, value] of Object.entries(row)) { @@ -189,6 +215,13 @@ export function buildBetterAuthExport( record({ sourceId: userId, status: "exported" }); } + if (conflicts > 0) { + log.warn( + `Skipped ${conflicts} user${conflicts === 1 ? "" : "s"} with more than one credential account and ` + + "different password hashes. Keep one account each in Better Auth, then export again.", + ); + } + return { users, coverage: [ diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 1908ac52f4..524c8ed8c5 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -283,6 +283,47 @@ describe("betterauth export", () => { ]); }); + test("exports a user with two matching credential accounts once", () => { + const lines: UserLine[] = []; + const { users, coverage } = buildBetterAuthExport( + [ + { id: "u1", email: "a@x.dev", password_hash: "salt:hash" }, + { id: "u1", email: "a@x.dev", password_hash: "salt:hash" }, + { id: "u2", email: "b@x.dev", password_hash: null }, + ], + (line) => lines.push(line), + ); + + expect(users.map((user) => user.user_id)).toEqual(["u1", "u2"]); + expect(coverage.find((row) => row.label === "have a password hash")?.count).toBe(1); + expect(lines).toEqual([ + { sourceId: "u1", status: "exported" }, + { sourceId: "u2", status: "exported" }, + ]); + }); + + // Picking one hash would leave the user unable to sign in with the other. + test("skips a user whose credential accounts hold different hashes, and says so", () => { + const lines: UserLine[] = []; + const { users } = buildBetterAuthExport( + [ + { id: "u1", email: "a@x.dev", password_hash: "salt:one" }, + { id: "u1", email: "a@x.dev", password_hash: "salt:two" }, + ], + (line) => lines.push(line), + ); + + expect(users).toEqual([]); + expect(lines).toEqual([ + expect.objectContaining({ + sourceId: "u1", + status: "skipped", + error: expect.stringContaining("2 credential accounts with different password hashes"), + }), + ]); + expect(captured.err).toContain("Skipped 1 user with more than one credential account"); + }); + test("renames camelCase columns onto what the transformer reads", () => { const { users } = buildBetterAuthExport([ { id: "u1", emailVerified: 1, phoneNumber: "+1555", createdAt: "2025-01-01" }, From e0c2c44c4396a33c46d9577e745ff19cc5673678 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 15:45:50 -0400 Subject: [PATCH 06/17] docs(migrate): say libsql:// exports go over HTTP Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 6d17d1ddd6..6fe59e561b 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1280,8 +1280,9 @@ The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on the export skips that request, and it may not carry the hash parameters: pass them to the import. The two WorkOS paths are on `api.workos.com`. -The database exports (`supabase`, `betterauth`) make no HTTP calls at all — -they connect over `--db-url`. +The database exports (`supabase`, `betterauth`) connect over `--db-url`. A +`libsql://` URL is the one exception that goes over HTTP: it posts to the +server's pipeline endpoint. ## Notes From da96fd601667a56032fce6554903158ea1058048 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 17:11:43 -0400 Subject: [PATCH 07/17] fix(migrate): read the Better Auth account table's column casing on its own Better Auth maps each model's fields separately, so a snake_case user table can sit next to a camelCase account table. The casing came from the user table alone, and the join then named account columns that do not exist. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/betterauth.ts | 22 ++++++++++--- .../migrate/export/db-exports.test.ts | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index a0c7fcec59..e6ee76e969 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -59,6 +59,11 @@ export type BetterAuthSchema = { * and Prisma setups keep camelCase. */ column: (field: string) => string; + /** + * The same for the account table. Better Auth maps each model's fields on + * its own, so its casing is read from that table rather than assumed. + */ + accountColumn: (field: string) => string; /** The plugin columns this database has. */ plugins: Set; }; @@ -110,7 +115,15 @@ export async function detectSchema(client: DbClient): Promise const snake = !columns.has("emailVerified") && columns.has("email_verified"); const column = (field: string) => (snake ? toSnakeCase(field) : field); const plugins = new Set(PLUGIN_COLUMNS.filter((field) => columns.has(column(field)))); - return { userTable, accountTable, column, plugins }; + // No account table: keep the user table's casing, and let the query's + // "no such table" error say what is missing. + const accountColumns = await tableColumns(client, accountTable); + const accountSnake = + accountColumns.size === 0 + ? snake + : !accountColumns.has("userId") && accountColumns.has("user_id"); + const accountColumn = (field: string) => (accountSnake ? toSnakeCase(field) : field); + return { userTable, accountTable, column, accountColumn, plugins }; } // No table found: the query names the default, and its "no such table" // error carries the hint. @@ -118,6 +131,7 @@ export async function detectSchema(client: DbClient): Promise userTable: "user", accountTable: "account", column: (field) => field, + accountColumn: (field) => field, plugins: new Set(), }; } @@ -130,7 +144,7 @@ export async function detectSchema(client: DbClient): Promise */ export function buildBetterAuthQuery(client: DbClient, schema: BetterAuthSchema): string { const q = (identifier: string) => client.quote(identifier); - const { column } = schema; + const { column, accountColumn } = schema; const select = (field: string) => column(field) === field ? `u.${q(field)}` : `u.${q(column(field))} AS ${q(field)}`; const selected = [ @@ -143,8 +157,8 @@ export function buildBetterAuthQuery(client: DbClient, schema: BetterAuthSchema) return ( `SELECT ${selected.join(", ")}, a.${q("password")} AS ${q("password_hash")} ` + `FROM ${q(schema.userTable)} u ` + - `LEFT JOIN ${q(schema.accountTable)} a ON a.${q(column("userId"))} = u.${q("id")} ` + - `AND a.${q(column("providerId"))} = 'credential' ` + + `LEFT JOIN ${q(schema.accountTable)} a ON a.${q(accountColumn("userId"))} = u.${q("id")} ` + + `AND a.${q(accountColumn("providerId"))} = 'credential' ` + `ORDER BY u.${q("id")} ASC` ); } diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 524c8ed8c5..f152382e68 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -283,6 +283,37 @@ describe("betterauth export", () => { ]); }); + // Better Auth maps each model's fields on its own, so the two tables can + // disagree on casing. + test.each([ + ["snake_case user table, camelCase account table", "snake", "userId", "providerId"], + ["camelCase user table, snake_case account table", "camel", "user_id", "provider_id"], + ])("joins a %s", async (_label, userCase, userId, providerId) => { + const [verified, created, updated] = + userCase === "snake" + ? ["email_verified", "created_at", "updated_at"] + : ["emailVerified", "createdAt", "updatedAt"]; + const file = makeDb((db) => { + db.run( + `CREATE TABLE "user" (id TEXT PRIMARY KEY, email TEXT, "${verified}" INTEGER, name TEXT, + "${created}" TEXT, "${updated}" TEXT)`, + ); + db.run( + `CREATE TABLE "account" (id TEXT, "${userId}" TEXT, "${providerId}" TEXT, password TEXT)`, + ); + db.run(`INSERT INTO "user" (id, email, "${verified}") VALUES ('u1', 'a@x.dev', 1)`); + db.run(`INSERT INTO "account" VALUES ('a1', 'u1', 'credential', 'salt:hash')`); + }); + + const rows = await withClient(file, async (client) => + client.query(buildBetterAuthQuery(client, await detectSchema(client))), + ); + + expect(rows).toEqual([ + expect.objectContaining({ id: "u1", emailVerified: 1, password_hash: "salt:hash" }), + ]); + }); + test("exports a user with two matching credential accounts once", () => { const lines: UserLine[] = []; const { users, coverage } = buildBetterAuthExport( From 90373b8772a929f0ff9f5f12e9fc0f7053fe1243 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 17:31:24 -0400 Subject: [PATCH 08/17] fix(migrate): resolve each Better Auth column on its own Better Auth maps each field separately, so one table can hold user_id next to providerId. Each column now resolves against the table's own columns, as named or in snake_case, rather than from one guess per table. A `user` table without Better Auth's verified column is also passed over for the plural one. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/betterauth.ts | 24 ++++++++++-- .../migrate/export/db-exports.test.ts | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index e6ee76e969..b275ca995f 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -103,6 +103,20 @@ const TABLE_CANDIDATES = [ ["users", "accounts"], ] as const; +/** + * A field's column: as named when the table has it, else its snake_case form + * when the table has that, else the table's general guess. Better Auth maps + * each field on its own, so one table can mix the two; a column that is truly + * missing still fails the query, which names it. + */ +function resolveColumn(columns: Set, snake: boolean): (field: string) => string { + return (field) => { + if (columns.has(field)) return field; + const snaked = toSnakeCase(field); + return columns.has(snaked) || snake ? snaked : field; + }; +} + /** * Asks the database how it names Better Auth's tables and columns, and which * plugin columns it has. Selecting a column that is not there fails the whole @@ -111,9 +125,11 @@ const TABLE_CANDIDATES = [ export async function detectSchema(client: DbClient): Promise { for (const [userTable, accountTable] of TABLE_CANDIDATES) { const columns = await tableColumns(client, userTable); - if (columns.size === 0) continue; - const snake = !columns.has("emailVerified") && columns.has("email_verified"); - const column = (field: string) => (snake ? toSnakeCase(field) : field); + // A table without Better Auth's verified column is some other `user` + // table, so the plural one is tried next. + if (!columns.has("emailVerified") && !columns.has("email_verified")) continue; + const snake = !columns.has("emailVerified"); + const column = resolveColumn(columns, snake); const plugins = new Set(PLUGIN_COLUMNS.filter((field) => columns.has(column(field)))); // No account table: keep the user table's casing, and let the query's // "no such table" error say what is missing. @@ -122,7 +138,7 @@ export async function detectSchema(client: DbClient): Promise accountColumns.size === 0 ? snake : !accountColumns.has("userId") && accountColumns.has("user_id"); - const accountColumn = (field: string) => (accountSnake ? toSnakeCase(field) : field); + const accountColumn = resolveColumn(accountColumns, accountSnake); return { userTable, accountTable, column, accountColumn, plugins }; } // No table found: the query names the default, and its "no such table" diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index f152382e68..d4adea58e4 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -314,6 +314,43 @@ describe("betterauth export", () => { ]); }); + // Each field is mapped on its own, so one table can mix the two casings. + test("resolves each column on its own when a table mixes casings", async () => { + const file = makeDb((db) => { + db.run( + `CREATE TABLE "user" (id TEXT PRIMARY KEY, email TEXT, email_verified INTEGER, name TEXT, + "createdAt" TEXT, updated_at TEXT)`, + ); + db.run(`CREATE TABLE "account" (id TEXT, user_id TEXT, "providerId" TEXT, password TEXT)`); + db.run(`INSERT INTO "user" (id, email, email_verified) VALUES ('u1', 'a@x.dev', 1)`); + db.run(`INSERT INTO "account" VALUES ('a1', 'u1', 'credential', 'salt:hash')`); + }); + + const rows = await withClient(file, async (client) => + client.query(buildBetterAuthQuery(client, await detectSchema(client))), + ); + + expect(rows).toEqual([ + expect.objectContaining({ id: "u1", emailVerified: 1, password_hash: "salt:hash" }), + ]); + }); + + test("passes over a `user` table that is not Better Auth's for the plural one", async () => { + const file = makeDb((db) => { + db.run(`CREATE TABLE "user" (id TEXT PRIMARY KEY, handle TEXT)`); + db.run( + `CREATE TABLE "users" (id TEXT PRIMARY KEY, email TEXT, "emailVerified" INTEGER, name TEXT, + "createdAt" TEXT, "updatedAt" TEXT)`, + ); + db.run(`CREATE TABLE "accounts" (id TEXT, "userId" TEXT, "providerId" TEXT, password TEXT)`); + db.run(`INSERT INTO "users" (id, email) VALUES ('u1', 'a@x.dev')`); + }); + + const schema = await withClient(file, async (client) => detectSchema(client)); + + expect(schema).toMatchObject({ userTable: "users", accountTable: "accounts" }); + }); + test("exports a user with two matching credential accounts once", () => { const lines: UserLine[] = []; const { users, coverage } = buildBetterAuthExport( From afdc880f85a96d16ffbe6c92ac6d7fc526f7c32e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 13:54:18 -0400 Subject: [PATCH 09/17] fix(migrate): read a Better Auth ban expiry in epoch seconds given as a string A CSV of a Drizzle SQLite timestamp column holds epoch seconds as text, which was read as milliseconds: a ban running into next month dated from 1970, counted as expired, and the user came across unbanned. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/sources/betterauth.ts | 10 +++++++++- .../src/commands/migrate/sources/sources.test.ts | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/sources/betterauth.ts b/packages/cli-core/src/commands/migrate/sources/betterauth.ts index 33071dbf46..8a7427580e 100644 --- a/packages/cli-core/src/commands/migrate/sources/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/sources/betterauth.ts @@ -112,8 +112,16 @@ const betterAuthSource = { // A ban whose `banExpires` has passed is over: Better Auth lifts it only // at the user's next sign-in, so the column still says banned. An expiry // that can't be read keeps the ban. + // Epoch seconds arrive as a number from the database, and as a string of + // digits from a CSV of the same column; both need the seconds read as such. const rawExpiry = user.banExpires ?? user.ban_expires; - const expiry = typeof rawExpiry === "number" && rawExpiry < 1e11 ? rawExpiry * 1000 : rawExpiry; + const epoch = + typeof rawExpiry === "number" + ? rawExpiry + : typeof rawExpiry === "string" && /^\d+$/.test(rawExpiry.trim()) + ? Number(rawExpiry) + : undefined; + const expiry = epoch !== undefined && epoch < 1e11 ? epoch * 1000 : (epoch ?? rawExpiry); const expired = Date.parse(String(toIsoDate(expiry, true))) <= Date.now(); if (isVerified(user.banned, "boolean") && !expired) user.banned = true; else delete user.banned; diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index b6358252a2..7c39a61e94 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -349,6 +349,10 @@ describe("betterauth", () => { ["an expiry in the future", "2999-01-01T00:00:00.000Z", true], ["epoch seconds in the past", 1_577_836_800, undefined], ["epoch milliseconds in the future", 32_472_144_000_000, true], + // A CSV of a Drizzle SQLite `integer({ mode: "timestamp" })` column. + ["epoch seconds in the future, as a string", "32472144000", true], + ["epoch seconds in the past, as a string", "1577836800", undefined], + ["epoch milliseconds in the future, as a string", "32472144000000", true], ["an unreadable expiry", "soon", true], ])("a ban with %s -> banned %p", (_label, banExpires, expected) => { const user = one("betterauth", { ...base, banned: true, banExpires }); From 49f7e6bb7c6a976abd12c6d84482db2eeabbddd4 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 13:55:50 -0400 Subject: [PATCH 10/17] fix(migrate): detect Better Auth's Postgres schema through the search_path Columns were read from information_schema for current_schema() only, the first schema on the search_path that exists. The export's unqualified SELECT resolves through the whole path, so with the tables in public behind a schema named for the role, detection found nothing: a camelCase schema lost its plugin columns and a snake_case one failed. Columns now come from the table to_regclass resolves, the same one the SELECT reads. Checked against Postgres 16 with "$user", public. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/betterauth.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index b275ca995f..d06022626c 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -87,10 +87,22 @@ async function tableColumns(client: DbClient, table: string): Promise(`PRAGMA table_info(${client.quote(table)})`); return new Set(rows.map((row) => row.name)); } - const scope = client.dbType === "mysql" ? "DATABASE()" : "current_schema()"; + if (client.dbType === "postgres") { + // `to_regclass` resolves the name through the whole search_path, as the + // export's unqualified SELECT will. `current_schema()` is only the first + // schema there that exists, which misses tables in `public` whenever a + // schema named after the role comes first. + const rows = await client.query<{ column_name: string }>( + `SELECT attname AS column_name FROM pg_attribute + WHERE attrelid = to_regclass(quote_ident(${client.placeholder(1)}::text)) + AND attnum > 0 AND NOT attisdropped`, + [table], + ); + return new Set(rows.map((row) => row.column_name)); + } const rows = await client.query<{ column_name?: string; COLUMN_NAME?: string }>( `SELECT column_name FROM information_schema.columns - WHERE table_name = ${client.placeholder(1)} AND table_schema = ${scope}`, + WHERE table_name = ${client.placeholder(1)} AND table_schema = DATABASE()`, [table], ); // MySQL 8 answers with an upper-case column label. From 38d04f6ac2812b9a07d5a35d2f6fdf3c16b85fe7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 13:29:10 -0400 Subject: [PATCH 11/17] fix(migrate): keep a Better Auth ban's end date, and drop a malformed hash A Better Auth ban with a future `banExpires` carried as `banned` and the expiry was deleted; Clerk's ban has no end, so the user stayed banned after the ban should have ended, with nothing saying so. The end date now goes to `banEndsAt`, for the checks' warning. A ban with no expiry is Better Auth's permanent one and keeps none. The bcrypt fixtures were placeholders the stricter hasher detection now drops; they use real-shaped digests, and a malformed one is tested as dropped with the user imported. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/sources/betterauth.ts | 11 ++++-- .../commands/migrate/sources/sources.test.ts | 35 +++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/sources/betterauth.ts b/packages/cli-core/src/commands/migrate/sources/betterauth.ts index 8a7427580e..f6ec33b3ce 100644 --- a/packages/cli-core/src/commands/migrate/sources/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/sources/betterauth.ts @@ -122,9 +122,14 @@ const betterAuthSource = { ? Number(rawExpiry) : undefined; const expiry = epoch !== undefined && epoch < 1e11 ? epoch * 1000 : (epoch ?? rawExpiry); - const expired = Date.parse(String(toIsoDate(expiry, true))) <= Date.now(); - if (isVerified(user.banned, "boolean") && !expired) user.banned = true; - else delete user.banned; + const endsAt = Date.parse(String(toIsoDate(expiry, true))); + const expired = endsAt <= Date.now(); + if (isVerified(user.banned, "boolean") && !expired) { + user.banned = true; + // Clerk's ban has no end, so this one's is kept for the checks to warn + // about. No expiry is Better Auth's permanent ban. + if (Number.isFinite(endsAt)) user.banEndsAt = new Date(endsAt).toISOString(); + } else delete user.banned; delete user.banExpires; delete user.ban_expires; diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index 7c39a61e94..12cf121a0a 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -282,9 +282,21 @@ describe("betterauth", () => { test.each([ [`${SALT}:${KEY}`, `scrypt:16384:16:1$${SALT}$${KEY}`, "scrypt_werkzeug"], - ["$2a$10$hash", "$2a$10$hash", "bcrypt"], - ["$2b$10$hash", "$2b$10$hash", "bcrypt"], - ["$2y$10$hash", "$2y$10$hash", "bcrypt"], + [ + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "bcrypt", + ], + [ + "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "bcrypt", + ], + [ + "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "bcrypt", + ], [ "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA", "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA", @@ -342,6 +354,23 @@ describe("betterauth", () => { expect(one("betterauth", { ...base, banned })?.banned).toBe(expected as boolean | undefined); }); + // The prefix alone said bcrypt, so the user was rejected instead of dropped. + test("drops a malformed bcrypt hash, and imports the user", async () => { + const { users } = await load("betterauth", [{ ...base, password_hash: "$2a$10$hash" }]); + expect(users[0]?.password).toBeUndefined(); + expect(users[0]?.passwordDropped).toBe(true); + }); + + // Clerk's ban has no end: the checks warn using the one Better Auth set. + test("a ban with an expiry keeps its end date; one without is permanent", () => { + expect( + one("betterauth", { ...base, banned: true, banExpires: "2999-01-01T00:00:00.000Z" }), + ).toMatchObject({ banned: true, banEndsAt: "2999-01-01T00:00:00.000Z" }); + const permanent = one("betterauth", { ...base, banned: true, banExpires: null }); + expect(permanent?.banned).toBe(true); + expect(permanent).not.toHaveProperty("banEndsAt"); + }); + // Better Auth lifts an expired ban only at the next sign-in, so the column // can still say banned long after the ban ended. test.each([ From 677fb53d50147ed3f01e93347d850be726b11f53 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 13:57:40 -0400 Subject: [PATCH 12/17] fix(migrate): export a Better Auth user with conflicting hashes without a password, not at all A user whose credential accounts held different hashes was left out of the export, so never migrated, the opposite of the import dropping a hash it cannot use. They are now exported without one, flagged `password_dropped`, which the source maps to passwordDropped so the checks count them. The README also says that Better Auth leaves most emails unverified unless requireEmailVerification is on, and what the checks do about it. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 12 ++++++- .../src/commands/migrate/export/betterauth.ts | 35 +++++++++++-------- .../migrate/export/db-exports.test.ts | 18 ++++++---- .../commands/migrate/sources/betterauth.ts | 2 ++ .../commands/migrate/sources/sources.test.ts | 7 ++++ 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 6fe59e561b..bfbe4d9377 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -430,7 +430,10 @@ plugin adds `username`, admin adds `banned` (carried only while `banExpires` is unset or in the future), phone-number adds `phoneNumber`, and so on; selecting a column that is not there fails the whole query, and the database answers the question better than the user can. Passwords come from a `LEFT JOIN` onto the credential `account` row — left, not inner, so a user who -only ever signed in with OAuth is still exported. +only ever signed in with OAuth is still exported. A user with two credential rows +whose hashes differ is exported without a password (`password_dropped: true`, +and a reason on the run line), since neither can be picked: keep one account +each in Better Auth and export again to bring the password across. #### `firebase` @@ -1120,6 +1123,13 @@ A password Clerk cannot verify is **dropped, not rejected**: the user imports without it and can sign in another way or reset it. Their run line carries `passwordDropped: true`. +**Unverified emails are common.** Better Auth doesn't require email +verification unless the app turned on `requireEmailVerification`, so many users +have `emailVerified` false, and their emails come across as unverified. On an +instance that requires an email at sign-up, the checks reject those users, and +the fix they print is to make email optional at sign-up. Imported that way, the +address stays on the user, unverified. + ### Verified vs unverified identifiers Every platform records verification differently, and each source declares diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index d06022626c..bc1e314524 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -213,8 +213,9 @@ export function buildBetterAuthExport( // The join returns a row per credential account, and nothing in Better // Auth's schema stops a user having two. Rows that agree are one user. Rows - // with different hashes cannot be settled here, so that user is skipped - // rather than given whichever hash came first. + // with different hashes cannot be settled here, so that user is exported + // without a password rather than given whichever hash came first: dropped, + // as the import drops a hash it cannot use, not left behind. const byId = new Map }>(); for (const row of rows) { const userId = String(row.id ?? ""); @@ -229,38 +230,42 @@ export function buildBetterAuthExport( let conflicts = 0; for (const [userId, { row, accounts, hashes }] of byId) { - if (hashes.size > 1) { - conflicts++; - record({ - sourceId: userId, - status: "skipped", - error: `has ${accounts} credential accounts with different password hashes; keep one in Better Auth and export again`, - }); - continue; - } + const conflicting = hashes.size > 1; + if (conflicting) conflicts++; const user: Record = {}; for (const [key, value] of Object.entries(row)) { if (value === null || value === undefined) continue; + if (conflicting && key === "password_hash") continue; user[FIELD_ALIASES[key] ?? key] = value instanceof Date ? value.toISOString() : value; } + // The source maps this to `passwordDropped`, which the checks count. + if (conflicting) user.password_dropped = true; if (row.email) counts.email++; if (row.emailVerified) counts.emailVerified++; - if (row.password_hash) counts.password++; + if (row.password_hash && !conflicting) counts.password++; if (row.name) counts.name++; if (row.username) counts.username++; if (row.phoneNumber) counts.phone++; users.push(user); - record({ sourceId: userId, status: "exported" }); + record({ + sourceId: userId, + status: "exported", + ...(conflicting + ? { + reason: `${accounts} credential accounts with different password hashes; exported without a password`, + } + : {}), + }); } if (conflicts > 0) { log.warn( - `Skipped ${conflicts} user${conflicts === 1 ? "" : "s"} with more than one credential account and ` + - "different password hashes. Keep one account each in Better Auth, then export again.", + `Exported ${conflicts} user${conflicts === 1 ? "" : "s"} without a password: more than one credential ` + + "account with different password hashes. They reset it, or sign in another way; keep one account each in Better Auth and export again to bring the password.", ); } diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index d4adea58e4..effcc242d2 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -370,10 +370,11 @@ describe("betterauth export", () => { ]); }); - // Picking one hash would leave the user unable to sign in with the other. - test("skips a user whose credential accounts hold different hashes, and says so", () => { + // Picking one hash would leave the user unable to sign in with the other; + // leaving the user out would not migrate them at all. + test("exports a user whose credential accounts hold different hashes, without a password", () => { const lines: UserLine[] = []; - const { users } = buildBetterAuthExport( + const { users, coverage } = buildBetterAuthExport( [ { id: "u1", email: "a@x.dev", password_hash: "salt:one" }, { id: "u1", email: "a@x.dev", password_hash: "salt:two" }, @@ -381,15 +382,18 @@ describe("betterauth export", () => { (line) => lines.push(line), ); - expect(users).toEqual([]); + expect(users).toHaveLength(1); + expect(users[0]).not.toHaveProperty("password_hash"); + expect(users[0]?.password_dropped).toBe(true); + expect(coverage.find((row) => row.label === "have a password hash")?.count).toBe(0); expect(lines).toEqual([ expect.objectContaining({ sourceId: "u1", - status: "skipped", - error: expect.stringContaining("2 credential accounts with different password hashes"), + status: "exported", + reason: expect.stringContaining("2 credential accounts with different password hashes"), }), ]); - expect(captured.err).toContain("Skipped 1 user with more than one credential account"); + expect(captured.err).toContain("Exported 1 user without a password"); }); test("renames camelCase columns onto what the transformer reads", () => { diff --git a/packages/cli-core/src/commands/migrate/sources/betterauth.ts b/packages/cli-core/src/commands/migrate/sources/betterauth.ts index f6ec33b3ce..3043734a41 100644 --- a/packages/cli-core/src/commands/migrate/sources/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/sources/betterauth.ts @@ -79,6 +79,8 @@ const betterAuthSource = { email_verified: "emailVerified", name: "name", password_hash: "password", + // Set by the export for a user whose credential accounts disagree. + password_dropped: "passwordDropped", username: "username", phone_number: "phone", phone_number_verified: "phoneVerified", diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index 12cf121a0a..460f955fa0 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -361,6 +361,13 @@ describe("betterauth", () => { expect(users[0]?.passwordDropped).toBe(true); }); + // The export drops a password it could not settle, and says so here. + test("carries the export's dropped password as passwordDropped", async () => { + const { users } = await load("betterauth", [{ ...base, password_dropped: true }]); + expect(users[0]?.passwordDropped).toBe(true); + expect(users[0]?.password).toBeUndefined(); + }); + // Clerk's ban has no end: the checks warn using the one Better Auth set. test("a ban with an expiry keeps its end date; one without is permanent", () => { expect( From 39275053389abf658f5c0b5358cfa1835b13b284 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 02:01:41 -0400 Subject: [PATCH 13/17] docs(migrate): say a dropped Better Auth password does not reject the user, but the checks still can Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index bfbe4d9377..5d2620f478 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1119,9 +1119,11 @@ which is what `scrypt_werkzeug` verifies once N, r and p are written inline. It also normalizes a password to NFKC before hashing, and Clerk does not, so a password whose NFKC form differs will not verify and that user resets it. -A password Clerk cannot verify is **dropped, not rejected**: the user imports -without it and can sign in another way or reset it. Their run line carries -`passwordDropped: true`. +A password Clerk cannot verify is **dropped, not rejected**: dropping it doesn't +reject the user, who imports without it and can sign in another way or reset +it. Their run line carries `passwordDropped: true`. The usual [checks](#checks) +still apply to what is left, so where a password is the instance's only way to +sign in, a user left without one is rejected there. **Unverified emails are common.** Better Auth doesn't require email verification unless the app turned on `requireEmailVerification`, so many users From afbb413497f39a60d92befc7d7d8d5146a635a7d Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 17:53:08 -0400 Subject: [PATCH 14/17] docs(migrate): say a Better Auth ban whose expiry can't be read is kept Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 5d2620f478..34838ed97e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -426,8 +426,8 @@ also keeps `raw_app_meta_data`, which is what the import's (`user` and `account`, or `users` and `accounts` under `usePlural: true`), how the columns are spelled (camelCase, or the snake_case Better Auth's Drizzle generator writes by default), and which plugin columns exist. The username -plugin adds `username`, admin adds `banned` (carried only while `banExpires` is -unset or in the future), phone-number adds `phoneNumber`, and so on; selecting a column that is not there fails the whole query, and the +plugin adds `username`, admin adds `banned` (carried while `banExpires` is +unset, in the future, or can't be read), phone-number adds `phoneNumber`, and so on; selecting a column that is not there fails the whole query, and the database answers the question better than the user can. Passwords come from a `LEFT JOIN` onto the credential `account` row — left, not inner, so a user who only ever signed in with OAuth is still exported. A user with two credential rows From 87933612e02e08a0e2b8861632a5030f9e08f311 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 19:32:37 -0400 Subject: [PATCH 15/17] docs(migrate): scope the libsql:// exception to Better Auth Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 34838ed97e..035402e477 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1292,9 +1292,9 @@ The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on the export skips that request, and it may not carry the hash parameters: pass them to the import. The two WorkOS paths are on `api.workos.com`. -The database exports (`supabase`, `betterauth`) connect over `--db-url`. A -`libsql://` URL is the one exception that goes over HTTP: it posts to the -server's pipeline endpoint. +The database exports (`supabase`, `betterauth`) connect over `--db-url`. +Better Auth also accepts a `libsql://` URL, which goes over HTTP: it posts to +the server's pipeline endpoint. Supabase takes Postgres only. ## Notes From 4cb4e9c23f9f204b67c70631d1738284c8e41c90 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:28:28 -0400 Subject: [PATCH 16/17] fix(migrate): skip a user its source asks to skip instead of rejecting it A source's `skipReason` (Better Auth's anonymous guests, Supabase's soft-deleted and anonymous users, Firebase users with no email or phone) was a reject, so any such file stopped the import until `--allow-partial`, which then let real rejects through too. The checks now return these users as `skipped`: the import records each as a skipped line with the source's reason and never refuses because of them. `--json` lists them as `checks.skipped`, and the README gives them their own bucket. This changes code that merged with slices 1 and 3. It lands here, where review raised it, rather than in a PR off main: `clerk migrate` stays behind CLERK_EXPERIMENTAL until slice 6, so nothing user-facing ships with the old behavior in between. Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-betterauth.md | 2 +- .../cli-core/src/commands/migrate/README.md | 10 +++--- .../src/commands/migrate/lib/checks.test.ts | 8 +++-- .../src/commands/migrate/lib/checks.ts | 17 ++++++++-- .../cli-core/src/commands/migrate/run.test.ts | 33 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 23 +++++++++---- 6 files changed, 77 insertions(+), 16 deletions(-) diff --git a/.changeset/migrate-betterauth.md b/.changeset/migrate-betterauth.md index bec7f63f5e..dc242bb401 100644 --- a/.changeset/migrate-betterauth.md +++ b/.changeset/migrate-betterauth.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk migrate export betterauth`, and let `clerk migrate import` read Better Auth exports. The export reads a Better Auth database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), including schemas Drizzle generated with snake_case columns or plural tables, and detects plugin columns. Better Auth's scrypt, bcrypt and argon2 passwords carry across, and anonymous users are skipped. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. +Add `clerk migrate export betterauth`, and let `clerk migrate import` read Better Auth exports. The export reads a Better Auth database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), including schemas Drizzle generated with snake_case columns or plural tables, and detects plugin columns. Better Auth's scrypt, bcrypt and argon2 passwords carry across, and anonymous users are skipped. A user any source skips, such as Supabase's soft-deleted users, no longer stops the import: it's recorded as skipped, and `--allow-partial` is only for real rejects. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 035402e477..5c56bb12e9 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -756,14 +756,16 @@ plain `rm` for an export written with `--output` outside one. #### Checks Every import runs the checks before writing anything, and `--dry-run` stops -after them. They sort the users three ways: +after them. They sort the users four ways: +- **Skipped** — users their source asked to leave out: Better Auth's anonymous + guests, Firebase users with no email or phone (such as anonymous ones), and + Supabase's soft-deleted and anonymous users. Nobody means to bring these + across, so they never stop the import: each is recorded as `skipped` with its + source's reason, and `--json` lists them as `checks.skipped`. - **Rejected** — users Clerk would refuse. Each gets the first reason that applies: - it failed schema validation - - its source requested a skip (Better Auth: an anonymous guest; Firebase: a - user with no email or phone, such as an anonymous one; Supabase: a - soft-deleted or anonymous user) - its only emails are ones Clerk refuses: malformed, or a domain that can't receive mail (`.local`, `.invalid`, `.test`, `.example`, `.arpa`, `.internal`, `.lan`, `.corp` and the like). Such an email is dropped from diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 690fd756aa..8a3df3cb8a 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -611,13 +611,17 @@ describe("rejects", () => { } }); - test("a user its source asked to skip, with the source's reason", async () => { + // Skipped, not rejected: a reject stops the import without --allow-partial, + // and nobody is meant to bring these users across. + test("a user its source asked to skip is skipped, with the source's reason", async () => { const checks = await checkImport( input({ users: [user("a", { skipReason: "anonymous Better Auth user" }), user("b")] }), ); - expect(checks.rejects).toEqual([ + expect(checks.rejects).toEqual([]); + expect(checks.skipped).toEqual([ { sourceId: "a", reason: "anonymous Better Auth user", inFile: true }, ]); + expect(checks.skippedReasons).toEqual([{ reason: "anonymous Better Auth user", count: 1 }]); expect(checks.importable.map((entry) => entry.userId)).toEqual(["b"]); }); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index f910cffa4a..6d63274de8 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -85,6 +85,13 @@ export type ImportChecks = { /** Users that do not, each with the first reason that applies to them. */ rejects: Reject[]; rejectReasons: ReasonCount[]; + /** + * Users their source asked to skip (a soft-deleted or anonymous account), + * with its reason. Left out like a reject, but never a reason to stop the + * import: nobody is meant to bring them across. + */ + skipped: Reject[]; + skippedReasons: ReasonCount[]; warnings: string[]; fixes: Fix[]; quota?: Quota; @@ -1003,6 +1010,7 @@ function placeholderWarning(count: number): string[] { export const ALREADY_IMPORTED = "already in the instance, with this source ID"; export async function checkImport(input: CheckInput): Promise { + const skipped: Reject[] = []; const rejects: Reject[] = input.failures.map((failure) => ({ sourceId: failure.userId, reason: `invalid: ${failure.error}`, @@ -1078,9 +1086,10 @@ export async function checkImport(input: CheckInput): Promise { usernameProblem(user, input.settings, loosePhones) ?? hashProblem ?? disabledProviders.get(user.userId)); - if (reason) { - const inFile = - reason === original.skipReason || reason === refusedOnly || reason === hashProblem; + if (reason && reason === original.skipReason) { + skipped.push({ sourceId: user.userId, reason, inFile: true }); + } else if (reason) { + const inFile = reason === refusedOnly || reason === hashProblem; rejects.push({ sourceId: user.userId, reason, ...(inFile ? { inFile: true as const } : {}) }); } else { passed.push(sent); @@ -1174,6 +1183,8 @@ export async function checkImport(input: CheckInput): Promise { ), rejects, rejectReasons: countReasons(rejects), + skipped, + skippedReasons: countReasons(skipped), warnings: [ ...buildWarnings( input, diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 94218a8b3f..83858014f0 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1320,6 +1320,39 @@ describe("run", () => { ); }); + // A user the source skips is nobody to bring across: without + // --allow-partial the rest still import, and the run says who was left out. + test("imports the rest past a user the source skips, and records the skip", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "sb1", email: "a@x.dev", email_confirmed_at: "2024-01-01 00:00:00+00" }, + { + id: "sb2", + email: "b@x.dev", + email_confirmed_at: "2024-01-01 00:00:00+00", + deleted_at: "2026-01-01 00:00:00+00", + }, + ]), + ); + + await run({ ...baseOptions, source: "supabase", json: true }); + + expect(created()).toEqual(["sb1"]); + const out = JSON.parse(captured.out); + expect(out.checks.rejects).toEqual([]); + expect(out.checks.skipped).toEqual([ + { sourceId: "sb2", reason: "deleted in Supabase", inFile: true }, + ]); + expect(out.result.skipped).toBe(1); + const [record] = listRuns(runsDir()); + expect(latestUserLines(runsDir(), record!.id).get("sb2")).toMatchObject({ + status: "skipped", + reason: "deleted in Supabase", + }); + expect(process.exitCode).toBe(0); + }); + test("names the fields Clerk won't store", async () => { fs.writeFileSync( path.join(workDir, "export.json"), diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 05c333607b..515ee3a7ce 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -521,6 +521,13 @@ function printChecks(checks: ImportChecks): void { } } + if (checks.skipped.length > 0) { + log.info(` ${dim("-")} ${plural(checks.skipped.length, "user")} skipped, as the source asked`); + for (const { reason, count } of checks.skippedReasons) { + log.info(` ${count}: ${dim(reason)}`); + } + } + if (checks.warnings.length > 0) { log.info(` ${yellow("⚠")} ${yellow("Imported, but not everything comes across")}`); for (const warning of checks.warnings) log.info(` ${dim(warning)}`); @@ -550,6 +557,8 @@ function checksJson(checks: ImportChecks) { importable: checks.importable.length, rejects: checks.rejects, rejectReasons: checks.rejectReasons, + skipped: checks.skipped, + skippedReasons: checks.skippedReasons, warnings: checks.warnings, fixes: checks.fixes, ...(checks.quota ? { quota: checks.quota } : {}), @@ -758,11 +767,12 @@ function continueWithExport(run: Run, fromExport: string | undefined): Run { } /** - * Records the checks' rejects as skipped users, so the run says who they were. - * The checks never reject an adopted user: it is created already. + * Records the checks' rejects, and the users their source skipped, as skipped + * users, so the run says who they were. The checks never reject an adopted + * user: it is created already. */ function recordRejects(run: Run, checks: ImportChecks): void { - for (const { sourceId, reason, keptSourceId, inFile } of checks.rejects) { + for (const { sourceId, reason, keptSourceId, inFile } of [...checks.rejects, ...checks.skipped]) { run.append({ sourceId, status: "skipped", @@ -1151,6 +1161,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if ( checks.importable.length === 0 && checks.rejects.length === 0 && + checks.skipped.length === 0 && withoutPassword.length === 0 && attachOnly.length === 0 ) { @@ -1187,7 +1198,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const toCreate = plural(checks.importable.length, "user"); const attaches = attachOnly.length > 0 ? `, attach identifiers to ${plural(attachOnly.length, "user")}` : ""; - const skipping = checks.rejects.length + withoutPassword.length; + const skipping = checks.rejects.length + checks.skipped.length + withoutPassword.length; const skips = skipping > 0 ? ` and skip ${plural(skipping, "user")}` : ""; if (!options.yes) { if (!canPrompt(options)) { @@ -1296,7 +1307,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { notSent: summary.notSent, pending: record.counts.pending ?? 0, ...(summary.stopReason ? { stopReason: summary.stopReason } : {}), - skipped: checks.rejects.length + withoutPassword.length, + skipped: checks.rejects.length + checks.skipped.length + withoutPassword.length, errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), warnings: [...summary.droppedPhones].map(([reason, count]) => ({ warning: `imported without their phone: ${reason}`, @@ -1314,7 +1325,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { log.blank(); for (const line of formatSummary( summary, - checks.rejects.length + withoutPassword.length, + checks.rejects.length + checks.skipped.length + withoutPassword.length, record, run.dir, limits.instanceType, From b5252c6c9d6ca3aa1cd35664bf71b1b09a59fca8 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:31:12 -0400 Subject: [PATCH 17/17] fix(migrate): check an argon2 digest's whole shape, not its prefix Hasher detection and the hash shape check looked only for `$argon2`, so a digest Clerk can't decode passed the dry run and failed mid-import. Both now mirror clerk_go's argon2.DecodeHash: the v=19 version, m/t/p within Clerk's import caps, and a salt and key in canonical unpadded base64. Better Auth and Supabase drop a password that fails it, as they do a malformed bcrypt hash, and the checks reject a file that names argon2 for one. Like the source-skip fix, this changes merged code (Supabase's hasher detection) here rather than in a PR off main, behind the same CLERK_EXPERIMENTAL gate. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 12 +++++++ .../src/commands/migrate/lib/checks.ts | 5 ++- .../src/commands/migrate/sources/shared.ts | 32 +++++++++++++++++-- .../commands/migrate/sources/sources.test.ts | 27 +++++++++------- 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 8a3df3cb8a..261627e175 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -1202,6 +1202,7 @@ describe("hashShapeProblem", () => { // A project with no salt separator. ["hash$salt$signer$$8$14", "scrypt_firebase"], ["$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA", "argon2id"], + ["$argon2i$v=19$m=1048576,t=20,p=16$c2FsdA$aGFzaA", "argon2i"], [`scrypt:16384:16:1$${"a".repeat(32)}$${"b".repeat(128)}`, "scrypt_werkzeug"], // Not checked: a bad digest there fails only at sign-in. ["anything", "pbkdf2_sha256"], @@ -1220,6 +1221,17 @@ describe("hashShapeProblem", () => { ["not base64!$salt$signer$sep$8$14", "scrypt_firebase"], ["hash$sa=lt$signer$sep$8$14", "scrypt_firebase"], ["argon2id$...", "argon2id"], + // Shapes clerk_go's argon2.DecodeHash refuses, or argon2 cannot run. + ["$argon2id$v=19$m=65536,t=3,p=4$notreal", "argon2id"], + ["$argon2id$v=16$m=65536,t=3,p=4$c2FsdA$aGFzaA", "argon2id"], + ["$argon2id$v=19$m=65536,t=3,p=4$c2FsdA==$aGFzaA", "argon2id"], + ["$argon2id$v=19$m=65536,t=3,p=4$c2FsdB$aGFzaA", "argon2id"], + ["$argon2id$v=19$m=2097152,t=3,p=4$c2FsdA$aGFzaA", "argon2id"], + ["$argon2id$v=19$m=65536,t=21,p=4$c2FsdA$aGFzaA", "argon2id"], + ["$argon2id$v=19$m=65536,t=3,p=17$c2FsdA$aGFzaA", "argon2id"], + ["$argon2id$v=19$m=65536,t=0,p=4$c2FsdA$aGFzaA", "argon2id"], + // The variant has to match the hasher. + ["$argon2i$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA", "argon2id"], ["scrypt:16384:16:1$salt$not-hex!", "scrypt_werkzeug"], ])("rejects %s as %s", (password, hasher) => { expect(hashShapeProblem(password, hasher)).toBeDefined(); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 6d63274de8..17b434b570 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -25,6 +25,7 @@ import { isEnabled, isRequired, type AttributeName } from "../../users/interacti import { splitIdentifiers } from "../import-users.ts"; import type { User } from "../types.ts"; import { analyzeFields, hasValue } from "./analysis.ts"; +import { argon2Variant } from "../sources/shared.ts"; import { decodesLikeClerk } from "./firebase-hash.ts"; import { clerkOffersProvider, @@ -166,7 +167,9 @@ export function hashShapeProblem(password: string, hasher: string): string | und } case "argon2i": case "argon2id": - return password.startsWith("$argon2") ? undefined : "password is not an argon2 hash"; + return argon2Variant(password) === hasher + ? undefined + : `password is not an ${hasher} hash Clerk can verify ($${hasher}$v=19$m=…,t=…,p=…$salt$hash)`; case "scrypt_werkzeug": return /^scrypt:\d+:\d+:\d+\$[^$]+\$[0-9a-f]+$/i.test(password) ? undefined diff --git a/packages/cli-core/src/commands/migrate/sources/shared.ts b/packages/cli-core/src/commands/migrate/sources/shared.ts index 64405b2794..e34a301975 100644 --- a/packages/cli-core/src/commands/migrate/sources/shared.ts +++ b/packages/cli-core/src/commands/migrate/sources/shared.ts @@ -125,13 +125,39 @@ export function toIsoDate(value: unknown, epochMillis = false): unknown { * Platforms that accept imported hashes (Supabase, Better Auth) can hold * either, so the hasher is read per user rather than assumed per source. */ +/** + * The argon2 variant of a digest Clerk can decode, or undefined. + * + * Mirrors clerk_go's `argon2.DecodeHash`: `$argon2id$v=19$m=…,t=…,p=…$salt$key`, + * salt and key in unpadded standard base64 that re-encodes to itself, and the + * costs Clerk caps on import (1 GiB of memory, 20 passes, 16 lanes). A zero + * cost decodes, but argon2 cannot run with it. + */ +export function argon2Variant(hash: string): "argon2id" | "argon2i" | undefined { + const match = + /^\$(argon2id|argon2i)\$v=19\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/.exec( + hash, + ); + if (!match) return undefined; + const [, variant, memory, passes, lanes, salt, key] = match; + const within = (value: string | undefined, max: number) => + Number(value) >= 1 && Number(value) <= max; + const canonical = (value: string | undefined) => + !!value && Buffer.from(value, "base64").toString("base64").replace(/=+$/, "") === value; + return within(memory, 1 << 20) && + within(passes, 20) && + within(lanes, 16) && + canonical(salt) && + canonical(key) + ? (variant as "argon2id" | "argon2i") + : undefined; +} + export function detectStandardHasher(hash: string): "bcrypt" | "argon2id" | "argon2i" | undefined { // The whole digest, not the prefix: a malformed one is dropped like any // other unusable hash, where a prefix match would see the user rejected. // clerk_go caps the cost at 15 (pkg/hash/bcrypt.go), and Go's bcrypt // refuses any cost under 4 at sign-in, so such a digest could never be used. if (/^\$2[aby]\$(0[4-9]|1[0-5])\$[./A-Za-z0-9]{53}$/.test(hash)) return "bcrypt"; - if (hash.startsWith("$argon2id$")) return "argon2id"; - if (hash.startsWith("$argon2i$")) return "argon2i"; - return undefined; + return argon2Variant(hash); } diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index 460f955fa0..6660603613 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -315,18 +315,21 @@ describe("betterauth", () => { // Imported without the password rather than rejected: the user can still // sign in another way, or reset it. - test.each([["plaintext"], ["$pbkdf2$abc"], [`${SALT}:short`]])( - "drops a password it cannot verify (%s) and imports the user", - async (stored) => { - const { users, validationFailed } = await load("betterauth", [ - { ...base, password_hash: stored }, - ]); - expect(validationFailed).toBe(0); - expect(users[0]?.password).toBeUndefined(); - expect(users[0]?.passwordHasher).toBeUndefined(); - expect(users[0]?.passwordDropped).toBe(true); - }, - ); + test.each([ + ["plaintext"], + ["$pbkdf2$abc"], + [`${SALT}:short`], + // The argon2 prefix alone: Clerk decodes the whole digest on create. + ["$argon2id$v=19$m=65536,t=3,p=4$notreal"], + ])("drops a password it cannot verify (%s) and imports the user", async (stored) => { + const { users, validationFailed } = await load("betterauth", [ + { ...base, password_hash: stored }, + ]); + expect(validationFailed).toBe(0); + expect(users[0]?.password).toBeUndefined(); + expect(users[0]?.passwordHasher).toBeUndefined(); + expect(users[0]?.passwordDropped).toBe(true); + }); test("names no hasher for a user without a password", async () => { const { users } = await load("betterauth", [base]);