diff --git a/.changeset/migrate-betterauth.md b/.changeset/migrate-betterauth.md new file mode 100644 index 000000000..dc242bb40 --- /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. 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 df844d239..5c56bb12e 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,25 @@ 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 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 +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` ```sh @@ -734,13 +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 (Supabase: a soft-deleted or anonymous user; - Firebase: a user with no email or phone, such as an anonymous one) - 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 +823,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 +1069,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 +1103,37 @@ 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**: 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 +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 @@ -1084,7 +1142,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 +1294,10 @@ 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`. +Better Auth also accepts a `libsql://` URL, which goes over HTTP: it posts to +the server's pipeline endpoint. Supabase takes Postgres only. + ## 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 000000000..bc1e31452 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -0,0 +1,323 @@ +/** + * `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 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; +}; + +/** `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)); + } + 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 = DATABASE()`, + [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; + +/** + * 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 + * 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); + // 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. + const accountColumns = await tableColumns(client, accountTable); + const accountSnake = + accountColumns.size === 0 + ? snake + : !accountColumns.has("userId") && accountColumns.has("user_id"); + const accountColumn = resolveColumn(accountColumns, accountSnake); + return { userTable, accountTable, column, accountColumn, 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, + accountColumn: (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, accountColumn } = 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(accountColumn("userId"))} = u.${q("id")} ` + + `AND a.${q(accountColumn("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 }; + + // 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 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 ?? ""); + 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) { + 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 && !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", + ...(conflicting + ? { + reason: `${accounts} credential accounts with different password hashes; exported without a password`, + } + : {}), + }); + } + + if (conflicts > 0) { + log.warn( + `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.", + ); + } + + 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 }; + }), + ), + options, + ); + + 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 eb08624ff..effcc242d 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,241 @@ 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", + }), + ]); + }); + + // 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" }), + ]); + }); + + // 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( + [ + { 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; + // 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, coverage } = 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).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: "exported", + reason: expect.stringContaining("2 credential accounts with different password hashes"), + }), + ]); + expect(captured.err).toContain("Exported 1 user without a password"); + }); + + 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 b6c2a2f8f..47813e5e3 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 54a0ec2d5..e53f07085 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 2103941e9..bfef51ac2 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 f8803b631..bd794973a 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/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 690fd756a..261627e17 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"]); }); @@ -1198,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"], @@ -1216,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 f910cffa4..17b434b57 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, @@ -85,6 +86,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; @@ -159,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 @@ -1003,6 +1013,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 +1089,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 +1186,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/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index ab362ab5a..ef3a46e62 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 d0924628c..83858014f 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"), @@ -2033,6 +2066,11 @@ describe("run", () => { ], "auth0|1", ], + [ + "betterauth", + [{ user_id: "ba1", email: "a@x.dev", email_verified: true, password_hash: BCRYPT }], + "ba1", + ], [ "supabase", [ @@ -2182,7 +2220,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/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 05c333607..515ee3a7c 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, 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 000000000..3043734a4 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/sources/betterauth.ts @@ -0,0 +1,148 @@ +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", + // Set by the export for a user whose credential accounts disagree. + password_dropped: "passwordDropped", + 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. + // 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 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 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; + + // 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 4bbd0b437..0b37827a7 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/shared.ts b/packages/cli-core/src/commands/migrate/sources/shared.ts index 64405b279..e34a30197 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 afa8ceeef..666060361 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,153 @@ 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$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", + "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`], + // 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]); + 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); + }); + + // 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); + }); + + // 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( + 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([ + ["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], + // 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 }); + 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 +836,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 +866,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 +889,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 77b24deb1..394742c6c 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 764b3f3e5..66bb697cf 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; diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 9b7c15ddb..1fc5f97e7 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.