diff --git a/.changeset/migrate-authjs.md b/.changeset/migrate-authjs.md new file mode 100644 index 000000000..98f40174e --- /dev/null +++ b/.changeset/migrate-authjs.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate export authjs`, and let `clerk migrate import` read Auth.js exports. The export reads an Auth.js database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), finding the user table whether Prisma or Drizzle named it. Auth.js stores no passwords, so imported users sign in with OAuth or an email link, or set a password. `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 d12731cd6..255e35d2e 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 authjs --db-url "mysql://user:...@127.0.0.1:3306/authjs" clerk migrate export betterauth --db-url "./db.sqlite" clerk migrate export firebase --service-account ./service-account.json clerk migrate export workos --api-key sk_… @@ -244,6 +245,7 @@ told not to. | `clerk` | Clerk Backend API | `--source clerk` | | `auth0` | Auth0 Management API | `--source auth0` | | `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | +| `authjs` | Auth.js database | `--source authjs` | | `betterauth` | Better Auth database | `--source betterauth` | | `firebase` | Firebase Identity Toolkit | `--source firebase` | | `workos` | WorkOS User Management API | `--source workos` | @@ -276,20 +278,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`, `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 | +| 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`, `authjs`, `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 @@ -363,7 +365,7 @@ profile in that order. The export run has one line per exported user, so `clerk migrate runs` lists it alongside imports. -#### Three platforms export no passwords +#### Platforms that export no passwords - **Clerk** never returns password digests, TOTP secrets or backup codes over the API — only the `*_enabled` booleans. Migrated users must reset their @@ -374,18 +376,23 @@ alongside imports. - **WorkOS** returns neither password hashes nor TOTP secrets, and has no support-request escape hatch: hashes go in on import and never come back, and `totp.secret` is returned on enrol only. There is nothing to add to the file. +- **Auth.js** core stores no passwords. An app that also uses the Credentials + provider stores them wherever it chose to, which the export does not read, + even a password column on the user table: migrate those separately, or have + those users reset their password. -All three print a warning whenever they export anyone. `clerk`'s and `workos`'s +Each prints a warning whenever it exports anyone. `clerk`'s and `workos`'s 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. -#### Database-backed exports (`supabase`, `betterauth`) +#### Database-backed exports (`supabase`, `authjs`, `betterauth`) -These two read the database directly, over **`--db-url`**: +These 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 authjs --db-url "mysql://user:...@127.0.0.1:3306/authjs" 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 ``` @@ -395,8 +402,8 @@ 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 +floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL` / `AUTHJS_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 @@ -420,6 +427,20 @@ 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. +**`authjs` tries `User`, then `user`, then `users`.** Auth.js has no single +schema — Prisma capitalizes the table, Drizzle does not, and Postgres treats +the difference as significant once quoted. On Postgres it tries +`next_auth.users` first: `@auth/supabase-adapter` keeps its tables in the +`next_auth` schema, off the default `search_path`, and an app's own +`public.users` must not answer for it. The run reports which one it found. +The verified column is read as `emailVerified`, or `email_verified` on a legacy +NextAuth table, whose `created_at` is kept too when it has one. Auth.js's own +schemas record no creation time. Auth.js core stores no passwords, so its users +arrive without credentials. That is all an OAuth or email-link app has. An app +that also uses the Credentials provider stores passwords wherever it chose to, +which the export does not read, even a password column on the user table: +migrate those separately, or have those users reset their password. + **`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 @@ -1058,14 +1079,15 @@ 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 | -| `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 | +| Key | Reads | Passwords | MFA | Metadata | +| ------------ | ----------------------------- | --------- | ------- | -------- | +| `clerk` | Clerk Dashboard export | partial | partial | partial | +| `auth0` | Auth0 Management API | partial | no | yes | +| `authjs` | Auth.js / NextAuth user table | no | no | no | +| `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. @@ -1131,8 +1153,9 @@ unconfirmed address there would silently promote it. - **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 - means verified; `""`, `null` and `\N` do not. +- **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. Any real value + means verified; `""`, `null` and `\N` do not. For `authjs`, neither does `0` + (the epoch) or a value that isn't a date. A Clerk export keeps an unverified primary email or phone unverified. @@ -1280,9 +1303,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. +The three database exports (`supabase`, `authjs`, `betterauth`) connect over +`--db-url`. Auth.js and Better Auth also accept a `libsql://` URL, which goes +over HTTP: it posts to the server's pipeline endpoint. Supabase takes Postgres +only. ## Notes diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts new file mode 100644 index 000000000..922b0759d --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -0,0 +1,222 @@ +/** + * `clerk migrate export authjs` — read users out of an Auth.js database. + * + * Ported from the standalone migration-tool's `src/export/authjs.ts`, on the + * `Bun.sql`/`bun:sqlite` client. + * + * Auth.js has no export tool and no single schema: the adapter decides the + * table name, and Prisma's `User` differs from Drizzle's `user` only in + * casing — which Postgres and SQLite treat as significant once quoted. The + * export tries the documented casing first and falls back rather than making + * the user find out from a driver error. + */ + +import { withGutter, withSpinner } from "../../../lib/spinner.ts"; +import { log } from "../../../lib/log.ts"; +import type { UserLine } from "../lib/run-store.ts"; +import { printTarget } from "../lib/target.ts"; +import { exportValue, 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"; + +/** Table names to try, in order. Prisma capitalizes; Drizzle does not. */ +const TABLE_CANDIDATES = ["User", "user", "users"] as const; + +/** + * The tables to try on this database. On Postgres, `@auth/supabase-adapter` + * keeps its own in the `next_auth` schema, off the default `search_path`: + * tried first, since an app's own `public.users` could otherwise answer for it. + */ +function tableCandidates(client: DbClient): string[] { + return client.dbType === "postgres" + ? ["next_auth.users", ...TABLE_CANDIDATES] + : [...TABLE_CANDIDATES]; +} + +type AuthJsRow = Record & { + id?: unknown; + name?: string | null; + email?: string | null; + email_verified?: unknown; + created_at?: unknown; +}; + +/** + * The verified-email column, in the order tried: the current adapters' name, + * then the legacy NextAuth `users` table's. + */ +const VERIFIED_COLUMNS = ["emailVerified", "email_verified"] as const; + +/** + * What each attempt reads, in order. Only the legacy table has a + * `created_at`, and it may be gone: the second attempt drops it. + */ +const ATTEMPTS = [ + { verifiedColumn: "emailVerified", createdAt: false }, + { verifiedColumn: "email_verified", createdAt: true }, + { verifiedColumn: "email_verified", createdAt: false }, +] as const; + +/** + * Every column is qualified with the table alias: SQLite reads an unqualified + * double-quoted name that matches no column as a string literal, so a missing + * `"emailVerified"` would come back as the text "emailVerified" on every row, + * which reads as verified. + */ +export function buildAuthJsQuery( + client: DbClient, + table: string, + verifiedColumn: (typeof VERIFIED_COLUMNS)[number] = "emailVerified", + createdAt = false, +): string { + const q = (identifier: string) => client.quote(identifier); + // `next_auth.users` is a schema and a table, each quoted on its own. + const from = table.split(".").map(q).join("."); + return ( + `SELECT u.${q("id")}, u.${q("name")}, u.${q("email")}, u.${q(verifiedColumn)} AS ${q("email_verified")}` + + (createdAt ? `, u.${q("created_at")}` : "") + + ` FROM ${from} u ORDER BY u.${q("id")} ASC` + ); +} + +const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +/** True for an error that means "no such column". Postgres says "does not exist" for both. */ +function isMissingColumn(error: unknown): boolean { + return /no such column|column .* does not exist|unknown column/i.test(messageOf(error)); +} + +/** True for an error that means "wrong table name", not "broken connection". */ +function isMissingTable(error: unknown): boolean { + return /does not exist|no such table|doesn't exist|unknown table/i.test(messageOf(error)); +} + +/** + * Reads the user table, trying each casing until one answers. + * + * A table that exists but lacks the columns is passed over too: Postgres + * keeps a quoted `"User"` apart from `"user"`, and an app's own `User` table + * must not hide Auth.js's. When nothing reads, the first column error is the + * one thrown, since it names a table that was there. + * + * @returns The rows and the table they came from, so the run can say which. + */ +export async function fetchAuthJsUsers( + client: DbClient, +): Promise<{ rows: AuthJsRow[]; table: string }> { + let lastError: unknown; + let columnError: unknown; + + const candidates = tableCandidates(client); + for (const table of candidates) { + for (const { verifiedColumn, createdAt } of ATTEMPTS) { + try { + const rows = await client.query( + buildAuthJsQuery(client, table, verifiedColumn, createdAt), + ); + return { rows, table }; + } catch (error) { + // The table is there: try the next shape of it. + if (isMissingColumn(error)) { + columnError ??= error; + continue; + } + if (!isMissingTable(error)) throw error; + lastError = error; + break; + } + } + } + + if (columnError) throw columnError; + throw lastError instanceof Error + ? new Error(`No Auth.js user table found. Tried ${candidates.join(", ")}. ${lastError.message}`) + : new Error(`No Auth.js user table found. Tried ${candidates.join(", ")}.`); +} + +export function buildAuthJsExport(rows: AuthJsRow[], record: (line: UserLine) => void = () => {}) { + const users: Record[] = []; + const counts = { email: 0, emailVerified: 0, name: 0 }; + + for (const row of rows) { + const userId = String(row.id ?? ""); + const user: Record = { id: userId }; + + if (row.name) { + user.name = row.name; + counts.name++; + } + if (row.email) { + user.email = row.email; + counts.email++; + } + // A nullable timestamp, not a boolean: the transformer reads presence. + // A zero date is no verification, as a number or as a driver's Date. + const zeroDate = row.email_verified instanceof Date && row.email_verified.getTime() === 0; + const verified = row.email_verified && !zeroDate ? exportValue(row.email_verified) : undefined; + if (verified !== undefined) { + user.email_verified = verified; + counts.emailVerified++; + } + // Only a legacy NextAuth table has one: Auth.js's own schemas don't. + const created = row.created_at ? exportValue(row.created_at) : undefined; + if (created !== undefined) user.created_at = created; + + 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 name", count: counts.name }, + ], + }; +} + +const AUTHJS_DB = { + platform: "authjs", + envVar: "AUTHJS_DB_URL", + prompt: "Auth.js database connection string", + hint: "Postgres, MySQL, libsql://… or a SQLite file — whichever your Auth.js adapter uses.", +} as const satisfies ResolveConfig; + +export async function exportAuthJs(options: DbExportOptions): Promise { + const dbUrl = await resolveDbUrl(options, AUTHJS_DB); + + await withGutter("Exporting users from Auth.js", async () => { + if (!options.json) printTarget({ platform: "authjs" }); + const { + value: { rows, table }, + } = await withInputRetry( + dbUrl, + async () => promptDbUrl(AUTHJS_DB), + async (connectionString) => + withSpinner("Reading the user table...", async () => + withDbClient(connectionString, "authjs", fetchAuthJsUsers), + ), + options, + ); + log.info(`Read ${rows.length} row${rows.length === 1 ? "" : "s"} from ${table}.`); + + const run = await startExportRun(options, { platform: "authjs" }); + const { users, coverage } = buildAuthJsExport(rows, run.append); + finishExport({ run, options, users, coverage }); + + if (users.length > 0) { + log.warn( + "Auth.js core stores no passwords, so users arrive without one. That covers OAuth and email-link sign-in. " + + "If the app also uses the Credentials provider, its passwords live in the app's own tables and were not exported: " + + "migrate them separately, or have those users reset their password.", + ); + } + }); +} diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index bc1e31452..dc9673cad 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -20,7 +20,7 @@ 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 { exportValue, withDbClient, type DbClient } from "../lib/db.ts"; import { finishExport, startExportRun } from "./shared.ts"; import { promptDbUrl, @@ -238,13 +238,15 @@ export function buildBetterAuthExport( 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; + const exported = exportValue(value); + if (exported !== undefined) user[FIELD_ALIASES[key] ?? key] = exported; } // 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++; + // From the exported value: a date that holds none is left out of it. + if (user.email_verified) counts.emailVerified++; if (row.password_hash && !conflicting) counts.password++; if (row.name) counts.name++; if (row.username) counts.username++; 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 effcc242d..2076141b0 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,7 +1,8 @@ /** * 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. + * Auth.js export's table detection, 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, the same query building and the same plugin @@ -17,6 +18,7 @@ import path from "node:path"; import type { UserLine } from "../lib/run-store.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import { createDbClient, type DbClient } from "../lib/db.ts"; +import { buildAuthJsExport, buildAuthJsQuery, exportAuthJs, fetchAuthJsUsers } from "./authjs.ts"; import { buildBetterAuthExport, buildBetterAuthQuery, @@ -147,17 +149,17 @@ describe("normalizeConnectionString", () => { }); describe("resolveDbUrl", () => { - const config = { platform: "supabase" as const, envVar: "SUPABASE_DB_URL", prompt: "url" }; + const config = { platform: "authjs" as const, envVar: "AUTHJS_DB_URL", prompt: "url" }; test("prefers the flag", async () => { const url = await resolveDbUrl({ dbUrl: "postgres://u:p@h/db" }, config, { - SUPABASE_DB_URL: "mysql://u:p@h/db", + AUTHJS_DB_URL: "mysql://u:p@h/db", }); expect(url).toBe("postgres://u:p@h/db"); }); test("falls back to the environment variable", async () => { - expect(await resolveDbUrl({}, config, { SUPABASE_DB_URL: "mysql://u:p@h/db" })).toBe( + expect(await resolveDbUrl({}, config, { AUTHJS_DB_URL: "mysql://u:p@h/db" })).toBe( "mysql://u:p@h/db", ); }); @@ -173,10 +175,10 @@ describe("resolveDbUrl", () => { test("warns and moves on when the environment variable is unusable", async () => { // Tests run non-TTY, so it then hits the agent-mode branch. - await expect(resolveDbUrl({}, config, { SUPABASE_DB_URL: "garbage" })).rejects.toThrow( + await expect(resolveDbUrl({}, config, { AUTHJS_DB_URL: "garbage" })).rejects.toThrow( /cannot prompt here/, ); - expect(captured.err).toContain("SUPABASE_DB_URL is not a valid connection string"); + expect(captured.err).toContain("AUTHJS_DB_URL is not a valid connection string"); }); test("says the variable is set but unusable, rather than asking for it", async () => { @@ -186,7 +188,172 @@ describe("resolveDbUrl", () => { }); test("names both the flag and the variable when it cannot prompt", async () => { - await expect(resolveDbUrl({}, config, {})).rejects.toThrow(/--db-url.*SUPABASE_DB_URL/s); + await expect(resolveDbUrl({}, config, {})).rejects.toThrow(/--db-url.*AUTHJS_DB_URL/s); + }); +}); + +describe("authjs export", () => { + const authJsDb = (table: string) => + makeDb((db) => { + db.run( + `CREATE TABLE "${table}" (id TEXT PRIMARY KEY, name TEXT, email TEXT, "emailVerified" TEXT)`, + ); + db.run(`INSERT INTO "${table}" VALUES (?,?,?,?)`, [ + "aj1", + "Jane Doe", + "jane@x.dev", + "2024-01-15", + ]); + db.run(`INSERT INTO "${table}" VALUES (?,?,?,?)`, ["aj2", "John Smith", "john@x.dev", null]); + }); + + test("quotes identifiers for the dialect", async () => { + await withClient(authJsDb("User"), async (client) => { + expect(buildAuthJsQuery(client, "User")).toContain('"User" u'); + expect(buildAuthJsQuery(client, "User")).toContain('u."emailVerified" AS "email_verified"'); + }); + }); + + // `@auth/supabase-adapter` keeps its tables in the `next_auth` schema, and an + // app's own `public.users` must not answer for it. + test("on Postgres, reads next_auth.users first, quoting schema and table", async () => { + const sql: string[] = []; + const client = { + dbType: "postgres", + quote: (identifier: string) => `"${identifier}"`, + placeholder: (position: number) => `$${position}`, + close: async () => {}, + query: async (query: string) => { + sql.push(query); + if (query.includes('"next_auth"."users"')) return [{ id: "aj1", email: "a@x.dev" }]; + throw new Error('relation "public.users" does not exist'); + }, + } as unknown as DbClient; + + const { rows, table } = await fetchAuthJsUsers(client); + + expect(table).toBe("next_auth.users"); + expect(rows).toHaveLength(1); + expect(sql[0]).toContain('FROM "next_auth"."users" u'); + }); + + // Prisma capitalizes the table, Drizzle does not, and Auth.js has no single + // schema — so the export tries rather than making the user guess. + test.each([["User"], ["user"], ["users"]])("finds the %s table", async (table) => { + const { rows } = await withClient(authJsDb(table), fetchAuthJsUsers); + expect(rows).toHaveLength(2); + }); + + // SQLite reads an unqualified "emailVerified" that matches no column as the + // string "emailVerified", which would mark every email verified. + test("reads a legacy NextAuth table's email_verified, and keeps null unverified", async () => { + const file = makeDb((db) => { + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, email_verified TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["n1", "Nv", "nv@x.dev", null]); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["n2", "V", "v@x.dev", "2024-01-15"]); + }); + + const { rows, table } = await withClient(file, fetchAuthJsUsers); + + expect(table).toBe("users"); + expect(rows.map((row) => row.email_verified)).toEqual([null, "2024-01-15"]); + }); + + // Auth.js's own schemas have no creation time; a legacy NextAuth table does. + test("keeps a legacy NextAuth table's created_at, and reads one without it", async () => { + const withCreated = makeDb((db) => { + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, email_verified TEXT, created_at TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?,?)`, ["n1", "N", "n@x.dev", null, "2021-03-04"]); + }); + const { rows } = await withClient(withCreated, fetchAuthJsUsers); + expect(buildAuthJsExport(rows).users[0]).toMatchObject({ created_at: "2021-03-04" }); + + const without = makeDb((db) => { + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, email_verified TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["n1", "N", "n@x.dev", null]); + }); + const plain = await withClient(without, fetchAuthJsUsers); + expect(plain.rows).toHaveLength(1); + expect("created_at" in (buildAuthJsExport(plain.rows).users[0] ?? {})).toBe(false); + }); + + // Postgres keeps a quoted "User" apart from "user"; SQLite does not, so the + // plural table stands in for the later candidate here. + test("passes over a candidate table without the columns for a later 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, name TEXT, email TEXT, "emailVerified" TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["u1", "U", "u@x.dev", null]); + }); + + const { rows, table } = await withClient(file, fetchAuthJsUsers); + + expect(table).toBe("users"); + expect(rows).toHaveLength(1); + }); + + test("a missing column is an error, not a literal", async () => { + const file = makeDb((db) => { + db.run(`CREATE TABLE "User" (id TEXT PRIMARY KEY, email TEXT, "emailVerified" TEXT)`); + db.run(`INSERT INTO "User" VALUES (?,?,?)`, ["a", "a@x.dev", null]); + }); + + await expect(withClient(file, fetchAuthJsUsers)).rejects.toThrow(/no such column/); + }); + + test("fails clearly when no candidate table exists", async () => { + const file = makeDb((db) => db.run(`CREATE TABLE unrelated (id TEXT)`)); + await expect(withClient(file, fetchAuthJsUsers)).rejects.toThrow( + /No Auth.js user table found. Tried User, user, users/, + ); + }); + + test("treats email_verified as a nullable timestamp, not a boolean", () => { + const { users } = buildAuthJsExport([ + { id: "a", email: "a@x.dev", email_verified: "2024-01-15" }, + { id: "b", email: "b@x.dev", email_verified: null }, + ]); + expect(users[0]?.email_verified).toBe("2024-01-15"); + expect("email_verified" in (users[1] ?? {})).toBe(false); + }); + + // Postgres and MySQL hand back a Date: the epoch is no verification there either. + test("a zero-epoch Date exports as unverified", () => { + const { users } = buildAuthJsExport([ + { id: "a", email: "a@x.dev", email_verified: new Date(0) }, + { id: "b", email: "b@x.dev", email_verified: new Date("2024-01-15T00:00:00Z") }, + ]); + expect("email_verified" in (users[0] ?? {})).toBe(false); + expect(users[1]?.email_verified).toBe("2024-01-15T00:00:00.000Z"); + }); + + test("counts coverage", () => { + const { coverage } = buildAuthJsExport([ + { id: "a", email: "a@x.dev", name: "A", email_verified: "2024-01-01" }, + { id: "b" }, + ]); + const byLabel = Object.fromEntries(coverage.map((c) => [c.label, c.count])); + expect(byLabel["have an email address"]).toBe(1); + expect(byLabel["have a verified email"]).toBe(1); + }); + + test("exports end to end and says which table it read", async () => { + await exportAuthJs({ dbUrl: authJsDb("User"), output: "authjs.json" }); + + const written = JSON.parse(fs.readFileSync(path.join(workDir, "authjs.json"), "utf-8")); + expect(written).toMatchObject({ clerkMigrate: 1, source: "authjs" }); + expect(written.users).toHaveLength(2); + expect(captured.err).toContain("Read 2 rows from"); + expect(captured.err).toContain("stores no passwords"); + expect(captured.err).toContain("Credentials provider"); }); }); @@ -518,6 +685,42 @@ describe("supabase export", () => { }); }); +// A MySQL zero date can come back as an Invalid Date, whose toISOString throws. +describe("a date that holds no real date", () => { + const invalid = new Date(Number.NaN); + + test("is no verification in an Auth.js export, and does not throw", () => { + const { users } = buildAuthJsExport([{ id: "aj1", email: "a@x.dev", email_verified: invalid }]); + expect(users[0]).not.toHaveProperty("email_verified"); + }); + + // Counted from what is exported, so coverage agrees with the file. + test.each([ + [ + "supabase", + () => buildSupabaseExport([{ id: "u1", email: "a@x.dev", email_confirmed_at: invalid }]), + "have a confirmed email", + ], + [ + "betterauth", + () => buildBetterAuthExport([{ id: "u1", email: "a@x.dev", emailVerified: invalid }]), + "have a verified email", + ], + ])("is not counted as a verified email in a %s export", (_platform, build, label) => { + expect(build().coverage.find((row) => row.label === label)?.count).toBe(0); + }); + + // Supabase skipped the whole row on the throw; Better Auth ended the export. + test.each([ + ["supabase", () => buildSupabaseExport([{ id: "u1", created_at: invalid }]).users], + ["betterauth", () => buildBetterAuthExport([{ id: "u1", createdAt: invalid }]).users], + ])("is left out of a %s export, which keeps the user", (_platform, build) => { + const users = build(); + expect(users).toHaveLength(1); + expect(users[0]).not.toHaveProperty("created_at"); + }); +}); + describe("connection failures", () => { // Supabase is Postgres: anything else would connect and then fail. test.each([["mysql://u:p@127.0.0.1:1/db"], ["./definitely-not-here.sqlite"]])( diff --git a/packages/cli-core/src/commands/migrate/export/db-options.ts b/packages/cli-core/src/commands/migrate/export/db-options.ts index 8abb2ff1c..7853171a7 100644 --- a/packages/cli-core/src/commands/migrate/export/db-options.ts +++ b/packages/cli-core/src/commands/migrate/export/db-options.ts @@ -1,8 +1,8 @@ /** * Resolving a `--db-url` for the database-backed exports. * - * Shared by the database-backed exports: each takes one connection string, - * from a flag, an environment variable, or a prompt. + * Shared by supabase, authjs and betterauth: all three take one connection + * string, from a flag, an environment variable, or a prompt. */ import { CliError, throwUsageError } from "../../../lib/errors.ts"; diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index 47813e5e3..69678bef4 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 { exportAuthJs } from "./authjs.ts"; import { exportBetterAuth } from "./betterauth.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; @@ -54,12 +55,13 @@ const handlers = { clerk: exportClerk, auth0: exportAuth0, supabase: exportSupabase, + authjs: exportAuthJs, betterauth: exportBetterAuth, firebase: exportFirebase, workos: exportWorkOs, }; -/** The platforms that read a database, which share `--db-url`. */ +/** The three platforms that read a database, which share `--db-url`. */ const DB_PLATFORMS = [ { key: "supabase", @@ -68,6 +70,13 @@ const DB_PLATFORMS = [ dbUrl: "Postgres connection string", example: "postgres://postgres:password@db.xxx.supabase.co:5432/postgres", }, + { + key: "authjs", + summary: "Export users from an Auth.js database", + envVar: "AUTHJS_DB_URL", + dbUrl: "Postgres, MySQL, libsql/Turso or SQLite connection string", + example: "mysql://user:password@127.0.0.1:3306/authjs", + }, { key: "betterauth", summary: "Export users from a Better Auth database", @@ -211,8 +220,8 @@ export function registerMigrateExport(migrateCommand: Command<[], Record[0]), ); - // Each takes exactly one connection string, so they are registered from a - // table rather than near-identical blocks. + // All three take exactly one connection string, so they are registered from + // a table rather than three near-identical blocks. for (const platform of DB_PLATFORMS) { exportCommand .command(platform.key) 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 e53f07085..fbdb8ec7b 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.test.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.test.ts @@ -8,6 +8,7 @@ describe("export registry", () => { "clerk", "auth0", "supabase", + "authjs", "firebase", "betterauth", "workos", diff --git a/packages/cli-core/src/commands/migrate/export/registry.ts b/packages/cli-core/src/commands/migrate/export/registry.ts index bfef51ac2..385953b20 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 { exportAuthJs } from "./authjs.ts"; import { exportBetterAuth } from "./betterauth.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; @@ -48,6 +49,13 @@ export const exportPlatforms: ExportRegistryEntry[] = [ sourceKey: "supabase", run: async (options) => exportSupabase(options), }, + { + key: "authjs", + label: "Auth.js (NextAuth)", + description: "An Auth.js database — Postgres, MySQL or SQLite", + sourceKey: "authjs", + run: async (options) => exportAuthJs(options), + }, { key: "firebase", label: "Firebase", diff --git a/packages/cli-core/src/commands/migrate/export/supabase.ts b/packages/cli-core/src/commands/migrate/export/supabase.ts index efe8427da..4d43058f8 100644 --- a/packages/cli-core/src/commands/migrate/export/supabase.ts +++ b/packages/cli-core/src/commands/migrate/export/supabase.ts @@ -14,7 +14,7 @@ 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 type { DbClient } from "../lib/db.ts"; +import { exportValue, type DbClient } from "../lib/db.ts"; import { finishExport, startExportRun } from "./shared.ts"; import { resolveDbUrl, @@ -88,7 +88,8 @@ function normalizeRow(row: SupabaseRow): Record { if (value === -Infinity) continue; // Postgres returns timestamps as Date objects; the transformer parses // strings, and JSON.stringify would otherwise bury the format difference. - normalized[key] = value instanceof Date ? value.toISOString() : value; + const exported = exportValue(value); + if (exported !== undefined) normalized[key] = exported; } return normalized; @@ -115,10 +116,12 @@ export function buildSupabaseExport( for (const row of rows) { const userId = String(row.id ?? ""); try { - users.push(normalizeRow(row)); + const user = normalizeRow(row); + users.push(user); if (row.email) counts.email++; - if (row.email_confirmed_at) counts.emailConfirmed++; + // From the exported value: a date that holds none is left out of it. + if (user.email_confirmed_at) counts.emailConfirmed++; if (row.encrypted_password) counts.password++; if (row.phone) counts.phone++; // The source splits a name it finds in the metadata, so count those too. diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index bd794973a..449e4853c 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -112,11 +112,14 @@ describe("registerMigrate", () => { ]); }); - test.each(["supabase", "betterauth"])("migrate export %s takes exactly its flags", (platform) => { - expect( - findCommand(["migrate", "export", platform])?.options.map((option) => option.long), - ).toEqual(["--db-url", "--output", "--yes", "--runs-dir", "--json"]); - }); + test.each(["supabase", "authjs", "betterauth"])( + "migrate export %s takes exactly its flags", + (platform) => { + expect( + findCommand(["migrate", "export", platform])?.options.map((option) => option.long), + ).toEqual(["--db-url", "--output", "--yes", "--runs-dir", "--json"]); + }, + ); test("migrate export firebase takes exactly its flags", () => { expect( diff --git a/packages/cli-core/src/commands/migrate/lib/db.test.ts b/packages/cli-core/src/commands/migrate/lib/db.test.ts index a4c34bba0..7453b4db8 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.test.ts @@ -303,13 +303,35 @@ describe("withDbClient", () => { // A schema the export cannot read is a refusal (exit 2), and the advice is // about the columns, not the connection. - test("names a missing column, and exits 2", async () => { + test("names the columns for a missing one, and exits 2", async () => { + const db = new Database(dbPath); + db.run( + `CREATE TABLE IF NOT EXISTS "User" (id TEXT, name TEXT, email TEXT, email_verified TEXT)`, + ); + db.close(); + + const error = (await withDbClient(dbPath, "authjs", (client) => + client.query(`SELECT no_such_column FROM "User"`), + ).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("reads `id`, `name`, `email` and `emailVerified`"); + // Prisma's Auth.js schema: a quoted "User" table and camelCase "emailVerified". + expect(error.message).toContain( + 'SELECT id, full_name AS name, email, "emailVerified" AS email_verified FROM "User"', + ); + expect(error.message).not.toContain("Check the connection string"); + }); + + // Supabase and Better Auth get the generic advice; only Auth.js has its own. + test("gives the generic column advice outside Auth.js, and exits 2", async () => { const error = (await withDbClient(dbPath, "supabase", (client) => client.query(`SELECT no_such_column FROM sqlite_master`), ).catch((caught: unknown) => caught)) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain("missing a column the export reads"); + expect(error.message).not.toContain("reads `id`, `name`, `email`"); expect(error.message).not.toContain("Check the connection string"); }); diff --git a/packages/cli-core/src/commands/migrate/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index ef3a46e62..ff19fac25 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.ts @@ -21,6 +21,16 @@ import { CliError, ERROR_CODE, EXIT_CODE } from "../../../lib/errors.ts"; export type DbType = "postgres" | "mysql" | "sqlite"; +/** + * A driver value as the export file carries it: a `Date` as ISO 8601, and + * one that holds no real date as absent. A MySQL zero date may come back as an + * Invalid Date, whose `toISOString()` throws. + */ +export function exportValue(value: unknown): unknown { + if (!(value instanceof Date)) return value; + return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); +} + export interface DbClient { dbType: DbType; query>(sql: string, params?: unknown[]): Promise; @@ -323,7 +333,7 @@ export async function createDbClient( } } -export type DbPlatform = "supabase" | "betterauth"; +export type DbPlatform = "supabase" | "betterauth" | "authjs"; /** * Turns a driver error into something a user can act on. @@ -372,7 +382,18 @@ export function describeDbError(error: unknown, platform?: DbPlatform): string { // Before the table check: Postgres words a missing column "does not exist" too. if (/no such column|column .* does not exist|unknown column/i.test(message)) { - return "The user table is missing a column the export reads. Check the schema matches the platform's default."; + const needs: Partial> = { + authjs: + "The Auth.js export reads `id`, `name`, `email` and `emailVerified` (or `email_verified`). " + + 'For a schema that renames another column (Prisma `@map("full_name")` on `name`, for one), ' + + "export the users with your own query that aliases it back, " + + '`SELECT id, full_name AS name, email, "emailVerified" AS email_verified FROM "User"` on Postgres ' + + "(MySQL quotes with backticks), and import that file with the authjs source.", + }; + return ( + needs[platform as DbPlatform] ?? + "The user table is missing a column the export reads. Check the schema matches the platform's default." + ); } // MySQL says "doesn't exist" and "Unknown table" where Postgres says "does not exist". diff --git a/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts b/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts index 4f3f597e3..9c088901b 100644 --- a/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts @@ -42,9 +42,9 @@ const { setAssumeYes } = await import("./assume-yes.ts"); const captured = useCaptureLog(); const CONFIG = { - platform: "supabase", - envVar: "SUPABASE_DB_URL", - prompt: "Supabase Postgres connection string", + platform: "authjs", + envVar: "AUTHJS_DB_URL", + prompt: "Auth.js database connection string", } as const; const FIRST = "libsql://typo.turso.io?authToken=t"; diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 76890f483..383f693a7 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2023,6 +2023,7 @@ describe("run", () => { ], "auth0|1", ], + ["authjs", [{ id: "aj1", email: "a@x.dev", email_verified: "2024-01-01T00:00:00Z" }], "aj1"], [ "betterauth", [{ user_id: "ba1", email: "a@x.dev", email_verified: true, password_hash: BCRYPT }], @@ -2177,7 +2178,7 @@ describe("run", () => { )) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain( - 'Unknown source "nope". Valid sources: clerk, auth0, betterauth, firebase, supabase, workos.', + 'Unknown source "nope". Valid sources: clerk, auth0, authjs, betterauth, firebase, supabase, workos.', ); expect(requests).toHaveLength(0); }); diff --git a/packages/cli-core/src/commands/migrate/sources/authjs.ts b/packages/cli-core/src/commands/migrate/sources/authjs.ts new file mode 100644 index 000000000..7b5daffc8 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/sources/authjs.ts @@ -0,0 +1,59 @@ +import type { SourceEntry } from "../types.ts"; +import { routeByVerification, splitName } from "./shared.ts"; + +/** + * Auth.js (formerly NextAuth) → Clerk transformer. + * + * Auth.js has no export tool and no fixed user table, so this assumes the + * common shape: `SELECT id, name, email, email_verified, created_at FROM users`. + * A different schema means editing the mapping below or supplying a custom + * transformer file. + * + * `email_verified` is a nullable timestamp rather than a boolean: any real + * date means verified. `0` (the epoch) and a value that isn't a date don't, + * as the export reads them; digits alone are a stored integer timestamp. + * + * No password default: Auth.js core stores no passwords, so users arrive + * without a digest and are imported with `skip_password_requirement`. An app + * using the Credentials provider keeps its own, which this does not read. + */ +const authjsSource = { + key: "authjs", + label: "Auth.js (NextAuth)", + description: + "Assumes an export of `SELECT id, name, email, email_verified, created_at FROM users`. `name` is split into firstName and lastName.", + carries: { + passwords: { + level: "no", + note: "Auth.js core stores no passwords. An app using the Credentials provider keeps its own, which this does not read: migrate them separately, or have those users reset their password.", + }, + mfa: { level: "no", note: "Auth.js has no MFA of its own." }, + metadata: { + level: "no", + note: "Only `id`, `name`, `email`, `email_verified` and `created_at` are read.", + }, + }, + transformer: { + id: "userId", + email: "email", + email_verified: "emailVerified", + name: "name", + created_at: "createdAt", + }, + postTransform: (user) => { + const verified = user.emailVerified; + if ( + verified === 0 || + (typeof verified === "string" && + (/^\d+$/.test(verified.trim()) + ? Number(verified) === 0 + : Number.isNaN(Date.parse(verified)))) + ) { + user.emailVerified = null; + } + routeByVerification(user, "email", "emailVerified", "timestamp"); + splitName(user); + }, +} satisfies SourceEntry; + +export default authjsSource; diff --git a/packages/cli-core/src/commands/migrate/sources/registry.ts b/packages/cli-core/src/commands/migrate/sources/registry.ts index 0b37827a7..25d376e71 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 authjsSource from "./authjs.ts"; import betterAuthSource from "./betterauth.ts"; import clerkSource from "./clerk.ts"; import firebaseSource from "./firebase.ts"; @@ -19,6 +20,7 @@ import workosSource from "./workos.ts"; export const sources: SourceEntry[] = [ clerkSource, auth0Source, + authjsSource, betterAuthSource, firebaseSource, supabaseSource, diff --git a/packages/cli-core/src/commands/migrate/sources/shared.ts b/packages/cli-core/src/commands/migrate/sources/shared.ts index 64405b279..aeebe3c63 100644 --- a/packages/cli-core/src/commands/migrate/sources/shared.ts +++ b/packages/cli-core/src/commands/migrate/sources/shared.ts @@ -80,12 +80,25 @@ export function routeByVerification( delete user[verifiedField]; } +/** The user's email addresses, wherever the source routed them. */ +function emailsOf(user: Record): Set { + const emails = new Set(); + for (const field of ["email", "emailAddresses", "unverifiedEmailAddresses"]) { + const value = user[field]; + for (const email of Array.isArray(value) ? value : [value]) { + if (typeof email === "string") emails.add(email.trim().toLowerCase()); + } + } + return emails; +} + /** * Splits a single display name into `firstName` and `lastName`: the first word, * then the rest. A one-word name (`Cher`) becomes the first name alone. * - * A one-word value that is an email address is dropped instead: Auth0 fills - * `name` with the email for database users, and that is not anyone's name. + * A one-word value that is one of the user's own email addresses is dropped + * instead: Auth0 fills `name` with the email for database users, and that is + * not anyone's name. Any other name with an `@` in it is kept. */ export function splitName(user: Record, field = "name"): void { const name = user[field]; @@ -95,7 +108,7 @@ export function splitName(user: Record, field = "name"): void { if (parts.length > 1) { user.firstName = parts[0]; user.lastName = parts.slice(1).join(" "); - } else if (parts[0] && !parts[0].includes("@")) { + } else if (parts[0] && !emailsOf(user).has(parts[0].toLowerCase())) { user.firstName = parts[0]; } delete user[field]; 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 186b62f43..221babe5d 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -45,10 +45,11 @@ const one = (key: string, record: Record, context = {}) => | undefined; describe("registry", () => { - test("registers the built-in platforms", () => { + test("registers all seven platforms", () => { expect(sourceKeys()).toEqual([ "clerk", "auth0", + "authjs", "betterauth", "firebase", "supabase", @@ -198,6 +199,12 @@ describe("auth0", () => { const user = one("auth0", { ...base, name: "a@x.dev" }); expect(user?.firstName).toBeUndefined(); }); + + // Only the user's own address is a placeholder. + test("keeps a one-word name with an @ that is not the user's email", () => { + const user = one("auth0", { ...base, name: "ada@work" }); + expect(user?.firstName).toBe("ada@work"); + }); }); describe("workos", () => { @@ -262,6 +269,65 @@ describe("workos", () => { }); }); +describe("authjs", () => { + const base = { id: "cuid1", email: "a@x.dev" }; + + // Auth.js lets `name` be any string; only the user's own email is dropped. + test.each([ + ["jane@acme", "jane@acme"], + ["a@x.dev", undefined], + ])("name %p becomes first name %p", (name, firstName) => { + expect(one("authjs", { ...base, name })?.firstName).toBe(firstName); + }); + + test("treats a confirmation timestamp as verified", () => { + const user = one("authjs", { ...base, email_verified: "2024-01-15T10:30:00.000Z" }); + expect(user?.email).toBe("a@x.dev"); + expect(user?.unverifiedEmailAddresses).toBeUndefined(); + }); + + test.each([[null], [""], [undefined], [0], ["0"], ["not-a-date"]])( + "treats email_verified=%p as unverified", + (value) => { + const user = one("authjs", { ...base, email_verified: value }); + expect(user?.unverifiedEmailAddresses).toBe("a@x.dev"); + }, + ); + + // A SQLite integer column exports as digits, and Postgres or MySQL as a + // date string: each is a real verification. + test.each([[1700000000000], ["1700000000000"], ["2024-06-29 20:25:06+00"]])( + "treats email_verified=%p as verified", + (value) => { + const user = one("authjs", { ...base, email_verified: value }); + expect(user?.email).toBe("a@x.dev"); + }, + ); + + test.each([ + ["Jane Doe", "Jane", "Doe"], + ["Mary Jane Watson", "Mary", "Jane Watson"], + [" Ada Lovelace ", "Ada", "Lovelace"], + ])("splits %p into %p / %p", (name, firstName, lastName) => { + const user = one("authjs", { ...base, name }); + expect(user?.firstName).toBe(firstName); + expect(user?.lastName).toBe(lastName); + }); + + test("keeps a single-word name as the first name, without inventing a last name", () => { + const user = one("authjs", { ...base, name: "Prince" }); + expect(user?.firstName).toBe("Prince"); + expect(user?.lastName).toBeUndefined(); + expect("name" in (user ?? {})).toBe(false); + }); + + test("imports without a password, since Auth.js core is passwordless", async () => { + const { users } = await load("authjs", [{ ...base, email_verified: "2024-01-01" }]); + expect(users[0]?.password).toBeUndefined(); + expect(users[0]?.passwordHasher).toBeUndefined(); + }); +}); + describe("betterauth", () => { const base = { user_id: "ba1", email: "a@x.dev", email_verified: true }; @@ -830,6 +896,7 @@ describe("fields the CLI's own export adds", () => { describe("invalid records", () => { const INVALID: [string, Record][] = [ ["auth0", { user_id: "a1" }], + ["authjs", { id: "a2" }], ["betterauth", { user_id: "a3" }], ["firebase", { localId: "a4" }], ["supabase", { id: "a5" }], @@ -854,6 +921,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 === "authjs") return { email, email_verified: "2024-01-01" }; 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 394742c6c..df3d17bd3 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", + "authjs", "betterauth", "firebase", "supabase", diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 66bb697cf..f0b1fc91c 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -230,7 +230,15 @@ describe("generateCompletions", () => { "--source", "", ]).completions.map((c) => c.name); - expect(names).toEqual(["clerk", "auth0", "betterauth", "firebase", "supabase", "workos"]); + expect(names).toEqual([ + "clerk", + "auth0", + "authjs", + "betterauth", + "firebase", + "supabase", + "workos", + ]); } finally { if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; else process.env.CLERK_EXPERIMENTAL = saved;