From b2e1d305e88de0196adb7f66fd31df2ac6fe652a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:46:32 -0400 Subject: [PATCH 01/16] feat(migrate): add export auth0 and Auth0 imports `clerk migrate export auth0` reads a tenant's users through the Management API with a machine-to-machine application's credentials, taken from flags, AUTH0_* environment variables, or a prompt. It stops at Auth0's 1000-user paging limit and points at the bulk export job, whose NDJSON file imports as it is. `clerk migrate import` reads Auth0 exports, routing unverified identifiers away from the primary fields and user_metadata to unsafe metadata. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 65 ++- .../src/commands/migrate/export/auth0.test.ts | 376 +++++++++++++++++ .../src/commands/migrate/export/auth0.ts | 384 ++++++++++++++++++ .../src/commands/migrate/export/clerk.ts | 4 +- .../src/commands/migrate/export/index.ts | 32 +- .../commands/migrate/export/registry.test.ts | 4 +- .../src/commands/migrate/export/registry.ts | 8 + .../commands/migrate/export/shared.test.ts | 2 +- .../src/commands/migrate/index.test.ts | 14 + .../cli-core/src/commands/migrate/index.ts | 2 +- .../commands/migrate/lib/input-retry.test.ts | 2 +- .../cli-core/src/commands/migrate/run.test.ts | 46 ++- packages/cli-core/src/commands/migrate/run.ts | 2 +- .../src/commands/migrate/sources/auth0.ts | 65 +++ .../src/commands/migrate/sources/registry.ts | 3 +- .../commands/migrate/sources/sources.test.ts | 84 +++- .../src/commands/migrate/wizard.test.ts | 5 +- .../src/test/integration/completion.test.ts | 2 +- 18 files changed, 1057 insertions(+), 43 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/export/auth0.test.ts create mode 100644 packages/cli-core/src/commands/migrate/export/auth0.ts create mode 100644 packages/cli-core/src/commands/migrate/sources/auth0.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index bb0b165cf..f177375c2 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -214,18 +214,20 @@ Gets users **out** of a source platform, so there is something to feed ```sh clerk migrate export # pick a platform clerk migrate export clerk --output users.json +clerk migrate export auth0 --domain my-tenant.us.auth0.com \ + --client-id … --client-secret … clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" clerk migrate export firebase --service-account ./service-account.json ``` The platform is an optional positional. Omitted, you get a picker built from the registry; given, it runs directly. Each platform resolves its own flags — -what a Clerk export needs (a secret key) has nothing in common with what a -database export needs. +what Auth0 needs (a tenant domain and M2M credentials) has nothing in common +with what a database export needs. -**A credential the far end rejects is asked for again.** Connection strings and -Firebase service account keys are long, pasted by hand, masked as they are -typed, and wrong in ways nothing local can check: a typo'd host, a revoked key, +**A credential the far end rejects is asked for again.** Connection strings, +Firebase service account keys and Auth0 client secrets are all long, pasted by +hand, masked as they are typed, and wrong in ways nothing local can check: a typo'd host, a revoked key, an expired token, the right server but the wrong database. Only the connection or the token exchange can say, and by then the operator has answered every other question the command asked. So that step — and only that step, @@ -238,6 +240,7 @@ 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` | @@ -277,6 +280,9 @@ keep `--runs-dir`: the import looks the run ID up there. | `--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` | +| `--client-id ` | `auth0` | Machine-to-machine application client ID | +| `--client-secret ` | `auth0` | Machine-to-machine application client secret | `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 @@ -350,12 +356,17 @@ profile in that order. The export run has one line per exported user, so `clerk migrate runs` lists it alongside imports. -#### Clerk exports no passwords +#### Two platforms 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 password in -the destination instance. The export says so on every run, and the coverage row -counts users who _have_ a password, so the size of the gap is visible up front. +- **Clerk** never returns password digests, TOTP secrets or backup codes over + the API — only the `*_enabled` booleans. Migrated users must reset their + password in the destination instance. +- **Auth0**'s Management API does not return password hashes either; they come + only from a support request. Add a `passwordHash` field to each user before + importing, or migrate without passwords. + +Both say so on every run. The coverage row counts users who _have_ a password, +so the size of the gap is visible up front. #### `supabase` reads the database @@ -478,6 +489,24 @@ for an access token, then two Identity Toolkit endpoints, `accounts:batchGet` (paged, 1000 users a request) and the project config, and Bun's Web Crypto signs RS256 with no dependency at all. +#### Auth0 credentials + +Needs a machine-to-machine application with the `read:users` scope +(Applications → APIs → Auth0 Management API → Machine to Machine +Applications). Resolved from flags, then `AUTH0_DOMAIN` / `AUTH0_CLIENT_ID` / +`AUTH0_CLIENT_SECRET`, then a prompt. In agent mode a prompt is impossible, so +it exits naming **every** missing credential at once rather than one per run. + +Auth0 pages this endpoint only through the first **1000** users. Past that the +export stops and says so, pointing at Auth0's bulk export job — silently +returning the first thousand would read as "that is everyone". It still exits +0, and `--json` carries `truncated: true`. A tenant of exactly 1000 is +complete, and gets no warning. The bulk job's NDJSON file imports as it is. + +A credential the platform rejects (400, 401 or 403) is asked for again at a +terminal. An outage, a `429` or a refused connection is not: another +credential would not fix it, and it exits 1. + ### `clerk migrate import` Reads an exported user file, maps it onto Clerk's user schema, checks every @@ -931,6 +960,7 @@ brings across. Adding a platform is one file in `sources/` plus one line in | Key | Reads | Passwords | MFA | Metadata | | ---------- | ---------------------------- | --------- | ------- | -------- | | `clerk` | Clerk Dashboard export | partial | partial | partial | +| `auth0` | Auth0 Export Users API | partial | no | yes | | `firebase` | `firebase auth:export` | yes | no | no | | `supabase` | Supabase `auth.users` export | yes | no | partial | @@ -943,10 +973,11 @@ is linked to their imported account by verified email. See ### Metadata -Supabase's `raw_user_meta_data`, which a user can edit on Supabase, goes to -Clerk's `unsafe_metadata`, which is the user-editable one. Public metadata is -read-only to the user, so putting it there would take away an edit the user -had. A Clerk Dashboard CSV carries no metadata. +Metadata a user can edit on the source platform — Auth0's `user_metadata`, +Supabase's `raw_user_meta_data` — goes to Clerk's `unsafe_metadata`, which is +the user-editable one. Public metadata is read-only to the user, so putting it +there would take away an edit the user had. Auth0's `app_metadata` goes to +`private_metadata`. A Clerk Dashboard CSV carries no metadata. ### Verified vs unverified identifiers @@ -956,7 +987,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** (`firebase`): `true`/`false`. A CSV export stringifies these, so `"false"` is +- **Boolean** (`auth0`, `firebase`): `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 @@ -1091,10 +1122,12 @@ The checks also read the instance's Frontend API `GET /v1/environment` attributes and enabled social providers. Nothing in `clerk migrate` writes instance settings: the checks print the `clerk config patch` to run instead. -`export firebase` talks to Google rather than to Clerk: +Two exports talk to their own platform rather than to Clerk: | Method | Path | Used by | | ------ | ---------------------------------------------- | ---------------------------------------------------- | +| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | +| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | | `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | | `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | | `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts new file mode 100644 index 000000000..0ced7a22f --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -0,0 +1,376 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CliError, EXIT_CODE } from "../../../lib/errors.ts"; +import type { UserLine } from "../lib/run-store.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { + buildAuth0Export, + exportAuth0, + fetchAllAuth0Users, + fetchAuth0Token, + mapAuth0UserToExport, + normalizeAuth0Domain, + resolveAuth0Credentials, +} from "./auth0.ts"; + +const captured = useCaptureLog(); + +const CREDENTIALS = { domain: "t.auth0.com", clientId: "cid", clientSecret: "csec" }; + +let workDir: string; +let originalCwd: string; +let originalFetch: typeof globalThis.fetch; +let requests: { url: string; body: unknown }[]; + +let originalMode: string | undefined; + +beforeAll(() => { + originalMode = process.env.CLERK_MODE; + originalCwd = process.cwd(); + originalFetch = globalThis.fetch; + workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-expauth0-"))); + process.chdir(workDir); +}); + +afterAll(() => { + if (originalMode === undefined) delete process.env.CLERK_MODE; + else process.env.CLERK_MODE = originalMode; + globalThis.fetch = originalFetch; + process.chdir(originalCwd); + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + // Tests that need a prompt set human mode themselves; without this a + // leaked "human" from an earlier test stops a later one on the destination prompt. + process.env.CLERK_MODE = "agent"; + requests = []; + fs.rmSync(path.join(workDir, ".clerk"), { recursive: true, force: true }); + fs.rmSync(path.join(workDir, "exports"), { recursive: true, force: true }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const auth0User = (i: number, overrides: Record = {}) => ({ + user_id: `auth0|a${i}`, + email: `a${i}@x.dev`, + email_verified: true, + given_name: `Given${i}`, + family_name: `Family${i}`, + ...overrides, +}); + +/** Stubs the token exchange plus one page of users per entry in `pages`. */ +function stubAuth0(pages: Record[][], token: Response | null = null) { + let page = 0; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input.toString(); + requests.push({ url, body: init?.body ? JSON.parse(init.body as string) : null }); + + if (url.includes("/oauth/token")) { + return token ?? Response.json({ access_token: "tok" }); + } + return Response.json({ + users: pages[page++] ?? [], + total: pages.reduce((sum, p) => sum + p.length, 0), + }); + }) as unknown as typeof fetch; +} + +describe("normalizeAuth0Domain", () => { + test.each([ + ["t.auth0.com", "t.auth0.com"], + ["https://t.auth0.com", "t.auth0.com"], + ["http://t.auth0.com/", "t.auth0.com"], + [" t.auth0.com ", "t.auth0.com"], + ])("%s -> %s", (input, expected) => { + expect(normalizeAuth0Domain(input)).toBe(expected); + }); +}); + +describe("resolveAuth0Credentials", () => { + test("prefers flags", async () => { + const resolved = await resolveAuth0Credentials( + { domain: "flag.auth0.com", clientId: "f", clientSecret: "s" }, + { AUTH0_DOMAIN: "env.auth0.com" }, + ); + expect(resolved.domain).toBe("flag.auth0.com"); + }); + + test("falls back to the environment", async () => { + const resolved = await resolveAuth0Credentials( + {}, + { + AUTH0_DOMAIN: "env.auth0.com", + AUTH0_CLIENT_ID: "e", + AUTH0_CLIENT_SECRET: "s", + }, + ); + expect(resolved).toEqual({ domain: "env.auth0.com", clientId: "e", clientSecret: "s" }); + }); + + test("normalizes a domain that came with a scheme", async () => { + const resolved = await resolveAuth0Credentials( + { domain: "https://t.auth0.com/", clientId: "c", clientSecret: "s" }, + {}, + ); + expect(resolved.domain).toBe("t.auth0.com"); + }); + + // Tests run non-TTY, the same signal an agent gives. + test("names every missing credential at once rather than one at a time", async () => { + await expect(resolveAuth0Credentials({}, {})).rejects.toThrow( + /--domain \(or AUTH0_DOMAIN\), --client-id \(or AUTH0_CLIENT_ID\), --client-secret \(or AUTH0_CLIENT_SECRET\)/, + ); + }); + + test("names only what is actually missing", async () => { + await expect( + resolveAuth0Credentials({ domain: "t.auth0.com", clientId: "c" }, {}), + ).rejects.toThrow(/Missing: --client-secret \(or AUTH0_CLIENT_SECRET\)\./); + }); +}); + +describe("fetchAuth0Token", () => { + test("exchanges client credentials for the Management API audience", async () => { + stubAuth0([[]]); + + expect(await fetchAuth0Token(CREDENTIALS)).toBe("tok"); + expect(requests[0]?.url).toBe("https://t.auth0.com/oauth/token"); + expect(requests[0]?.body).toEqual({ + grant_type: "client_credentials", + client_id: "cid", + client_secret: "csec", + audience: "https://t.auth0.com/api/v2/", + }); + }); + + test("explains a rejection instead of surfacing a raw status", async () => { + stubAuth0( + [[]], + new Response(JSON.stringify({ error_description: "Wrong client secret" }), { status: 401 }), + ); + + await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow( + /Auth0 rejected the credentials \(401\): Wrong client secret/, + ); + }); + + // An outage is not a bad credential: no re-prompt, and exit 1, not 2. + test("a 5xx is an outage, not a rejected credential", async () => { + stubAuth0([[]], new Response("{}", { status: 503 })); + const error = (await fetchAuth0Token(CREDENTIALS).catch((e: unknown) => e)) as CliError; + expect(error.message).toContain("Auth0 did not issue a token (503)"); + expect(error.exitCode).not.toBe(EXIT_CODE.USAGE); + }); + + test("mentions the read:users scope, the usual cause", async () => { + stubAuth0([[]], new Response("{}", { status: 403 })); + await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(/read:users/); + }); + + test("fails when a 200 carries no token", async () => { + stubAuth0([[]], Response.json({})); + await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(CliError); + }); +}); + +describe("fetchAllAuth0Users", () => { + test("pages until a short page arrives", async () => { + stubAuth0([ + Array.from({ length: 100 }, (_, i) => auth0User(i)), + Array.from({ length: 4 }, (_, i) => auth0User(100 + i)), + ]); + + const { users: all, truncated } = await fetchAllAuth0Users({ + credentials: CREDENTIALS, + token: "tok", + }); + + expect(all).toHaveLength(104); + expect(truncated).toBe(false); + expect(requests[0]?.url).toContain("page=0"); + expect(requests[1]?.url).toContain("page=1"); + expect(requests).toHaveLength(2); + }); + + test("asks for totals and the documented page size", async () => { + stubAuth0([[]]); + await fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" }); + expect(requests[0]?.url).toContain("per_page=100"); + expect(requests[0]?.url).toContain("include_totals=true"); + }); + + // Auth0 caps offset pagination at 1000. Returning the first thousand quietly + // would read as "that is everyone". + test("stops at Auth0's 1000-record ceiling and says so", async () => { + stubAuth0( + Array.from({ length: 12 }, () => Array.from({ length: 100 }, (_, i) => auth0User(i))), + ); + + const { users: all, truncated } = await fetchAllAuth0Users({ + credentials: CREDENTIALS, + token: "tok", + }); + + expect(all).toHaveLength(1000); + expect(truncated).toBe(true); + expect(captured.err).toContain("only pages through the first 1000 users"); + expect(captured.err).toContain("bulk user export job"); + }); + + test("a tenant of exactly 1000 users is complete, with no warning", async () => { + stubAuth0( + Array.from({ length: 10 }, () => Array.from({ length: 100 }, (_, i) => auth0User(i))), + ); + + const { users: all, truncated } = await fetchAllAuth0Users({ + credentials: CREDENTIALS, + token: "tok", + }); + + expect(all).toHaveLength(1000); + expect(truncated).toBe(false); + expect(captured.err).not.toContain("only pages through"); + }); + + test("raises a clear error on a failed page request", async () => { + globalThis.fetch = (async () => + new Response("nope", { status: 500 })) as unknown as typeof fetch; + + await expect(fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" })).rejects.toThrow( + /Auth0 returned 500 listing users/, + ); + }); +}); + +describe("mapAuth0UserToExport", () => { + test("keeps the fields the auth0 transformer maps from", () => { + expect( + mapAuth0UserToExport(auth0User(0, { phone_number: "+1555", created_at: "2025-01-01" })), + ).toEqual({ + user_id: "auth0|a0", + email: "a0@x.dev", + given_name: "Given0", + family_name: "Family0", + phone_number: "+1555", + created_at: "2025-01-01", + email_verified: true, + }); + }); + + // Dropping a false flag would import an unconfirmed address as verified. + test.each([ + ["email_verified", false], + ["phone_verified", false], + ])("keeps %s when it is %p", (field, value) => { + const mapped = mapAuth0UserToExport(auth0User(0, { [field]: value })); + expect(mapped[field]).toBe(value); + }); + + test("drops tenant internals the import has no use for", () => { + const mapped = mapAuth0UserToExport( + auth0User(0, { + identities: [{ provider: "auth0" }], + logins_count: 42, + last_login: "2026-01-01", + multifactor: ["guardian"], + }), + ); + for (const noise of ["identities", "logins_count", "last_login", "multifactor"]) { + expect(noise in mapped).toBe(false); + } + }); + + test("keeps blocked only when it is true", () => { + expect(mapAuth0UserToExport(auth0User(0, { blocked: true })).blocked).toBe(true); + expect("blocked" in mapAuth0UserToExport(auth0User(0, { blocked: false }))).toBe(false); + }); + + test("keeps name", () => { + expect(mapAuth0UserToExport(auth0User(0, { name: "Ada Lovelace" })).name).toBe("Ada Lovelace"); + }); + + test("omits empty metadata", () => { + const mapped = mapAuth0UserToExport( + auth0User(0, { user_metadata: {}, app_metadata: { plan: "pro" } }), + ); + expect("user_metadata" in mapped).toBe(false); + expect(mapped.app_metadata).toEqual({ plan: "pro" }); + }); +}); + +describe("buildAuth0Export", () => { + test("counts coverage and records each user", () => { + const lines: UserLine[] = []; + const { users, coverage } = buildAuth0Export( + [auth0User(0), auth0User(1, { given_name: undefined })], + (line) => lines.push(line), + ); + + expect(users).toHaveLength(2); + const byLabel = Object.fromEntries(coverage.map((c) => [c.label, c.count])); + expect(byLabel["have an email address"]).toBe(2); + expect(byLabel["have a first name"]).toBe(1); + + expect(lines.map((line) => line.status)).toEqual(["exported", "exported"]); + }); +}); + +/** The envelope the one export run in this project wrote. */ +function onlyExportFile(): string { + const dir = path.join(workDir, ".clerk", "migrate"); + const entries = fs.readdirSync(dir); + expect(entries).toHaveLength(1); + return path.join(dir, entries[0] as string, "export.json"); +} + +/** The users inside that envelope. */ +function exportedUsers(): Record[] { + return ( + JSON.parse(fs.readFileSync(onlyExportFile(), "utf-8")) as { users: Record[] } + ).users; +} + +describe("exportAuth0", () => { + test("writes the default path and reports coverage", async () => { + stubAuth0([[auth0User(0)], []]); + + await exportAuth0({ ...CREDENTIALS }); + expect(JSON.parse(fs.readFileSync(onlyExportFile(), "utf-8"))).toMatchObject({ + source: "auth0", + }); + const written = exportedUsers(); + expect(written[0]?.user_id).toBe("auth0|a0"); + expect(captured.err).toContain("Field coverage"); + }); + + test("names the command that consumes the file", async () => { + stubAuth0([[auth0User(0)], []]); + // The suggestion now rides the gutter's Next steps block, which only + // renders in human mode. + process.env.CLERK_MODE = "human"; + await exportAuth0({ ...CREDENTIALS, output: "exports/mine.json" }); + expect(captured.err).toMatch(/clerk migrate import \d{8}-\d{6}-[0-9a-f]{4}/); + }); + + test("--output controls the destination", async () => { + stubAuth0([[auth0User(0)], []]); + + await exportAuth0({ ...CREDENTIALS, output: "tenant.json" }); + + expect(fs.existsSync(path.join(workDir, "tenant.json"))).toBe(true); + }); + + // Auth0 only releases hashes through a support request; finding that out + // after the import means nobody can sign in. + test("says plainly that password hashes are not in the file", async () => { + stubAuth0([[auth0User(0)], []]); + await exportAuth0({ ...CREDENTIALS }); + expect(captured.err).toContain("does not return password hashes"); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts new file mode 100644 index 000000000..8b2628988 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -0,0 +1,384 @@ +/** + * `clerk migrate export auth0` — pull users out of an Auth0 tenant. + * + * Ported from the standalone migration-tool's `src/export/auth0.ts`, but + * **without the `auth0` SDK**. The SDK is 28 MB across five transitive + * dependencies — including a bundled legacy copy of itself — to make two REST + * calls, and it does its own HTTP, so nothing it sends would appear under + * `--verbose`. `.claude/rules/debug-logging.md` requires library HTTP to go + * through `loggedFetch`; two direct calls satisfy that and ship nothing extra + * inside the compiled binary. + * + * **Passwords do not come out of the Management API.** Auth0 exports password + * hashes only via a support request. The coverage report says so rather than + * leaving it to be discovered when nobody can sign in. + */ + +import { throwUsageError } from "../../../lib/errors.ts"; +import { loggedFetch } from "../../../lib/fetch.ts"; +import { dim } from "../../../lib/color.ts"; +import { log } from "../../../lib/log.ts"; +import { password as passwordPrompt, text } from "../../../lib/prompts.ts"; +import { withGutter, withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; +import { isAgent, isHuman } from "../../../mode.ts"; +import type { UserLine } from "../lib/run-store.ts"; +import { printTarget } from "../lib/target.ts"; +import { isCredentialStatus, throwApiFailure, withInputRetry } from "../lib/input-retry.ts"; +import { finishExport, startExportRun } from "./shared.ts"; + +const PAGE_SIZE = 100; + +/** + * Auth0 caps offset pagination on `GET /api/v2/users` at 1000 records. + * Past that the tenant needs a bulk export job, so the run says so instead of + * quietly returning the first thousand as though that were everyone. + */ +const AUTH0_PAGINATION_CEILING = 1000; + +const DOCS_URL = "https://clerk.com/docs/guides/development/migrating/auth0"; + +export type ExportAuth0Options = { + domain?: string; + clientId?: string; + clientSecret?: string; + output?: string; + /** Where runs are kept; overrides `CLERK_MIGRATE_DIR`. */ + runsDir?: string; + /** Print the result as JSON on stdout; never prompts. */ + json?: boolean; +}; + +export type Auth0Credentials = { + domain: string; + clientId: string; + clientSecret: string; +}; + +/** Strips a scheme and trailing slash, so both forms of `--domain` work. */ +export function normalizeAuth0Domain(domain: string): string { + return domain + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); +} + +/** + * Resolves the tenant credentials: flags, then environment, then a prompt. + * + * @throws CliError in agent mode when anything is still missing, naming each + * absent flag rather than failing on the first one. + */ +export async function resolveAuth0Credentials( + options: ExportAuth0Options, + env: Record = process.env, +): Promise { + const resolved = { + domain: options.domain ?? env.AUTH0_DOMAIN, + clientId: options.clientId ?? env.AUTH0_CLIENT_ID, + clientSecret: options.clientSecret ?? env.AUTH0_CLIENT_SECRET, + }; + + const missing = ( + [ + ["domain", "--domain", "AUTH0_DOMAIN"], + ["clientId", "--client-id", "AUTH0_CLIENT_ID"], + ["clientSecret", "--client-secret", "AUTH0_CLIENT_SECRET"], + ] as const + ).filter(([key]) => !resolved[key]); + + if (missing.length === 0) { + return { + domain: normalizeAuth0Domain(resolved.domain as string), + clientId: resolved.clientId as string, + clientSecret: resolved.clientSecret as string, + }; + } + + if (isAgent() || !isHuman()) { + throwUsageError( + `\`clerk migrate export auth0\` needs credentials for a machine-to-machine application and cannot prompt here.\n` + + `Missing: ${missing.map(([, flag, variable]) => `${flag} (or ${variable})`).join(", ")}.`, + DOCS_URL, + undefined, + [ + { + command: + "clerk migrate export auth0 --domain my-tenant.us.auth0.com --client-id … --client-secret …", + description: "Export with explicit credentials", + }, + ], + ); + } + + log.info( + "Auth0 needs a machine-to-machine application with the `read:users` scope. Create one under Applications → APIs → Auth0 Management API → Machine to Machine Applications.", + ); + + return promptAuth0Credentials(resolved); +} + +/** + * Asks for whichever of the three are still missing. + * + * Called with nothing known after Auth0 has rejected a set: its error names no + * field, and the operator may have mistyped any of them — so all three are + * asked again rather than guessing which one to keep. + */ +export async function promptAuth0Credentials( + known: Partial = {}, +): Promise { + const domain = + known.domain ?? + (await text({ + message: "Auth0 tenant domain (e.g. my-tenant.us.auth0.com)", + validate: (value) => (value?.trim() ? undefined : "A domain is required"), + })); + const clientId = + known.clientId ?? + (await text({ + message: "Machine-to-machine client ID", + validate: (value) => (value?.trim() ? undefined : "A client ID is required"), + })); + const clientSecret = + known.clientSecret ?? + (await passwordPrompt({ + message: "Machine-to-machine client secret", + validate: (value) => (value?.trim() ? undefined : "A client secret is required"), + })); + + return { + domain: normalizeAuth0Domain(domain), + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }; +} + +/** Exchanges the client credentials for a Management API access token. */ +export async function fetchAuth0Token(credentials: Auth0Credentials): Promise { + const url = new URL(`https://${credentials.domain}/oauth/token`); + + const response = await loggedFetch(url, { + tag: "auth0", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + audience: `https://${credentials.domain}/api/v2/`, + }), + }); + + const body = (await response.json().catch(() => ({}))) as { + access_token?: string; + error_description?: string; + error?: string; + }; + + const detail = body.error_description ?? body.error ?? "no access token returned"; + if (!response.ok && !isCredentialStatus(response.status)) { + throwApiFailure( + response.status, + `Auth0 did not issue a token (${response.status}): ${detail}. Try again shortly.`, + DOCS_URL, + ); + } + if (!body.access_token) { + throwUsageError( + `Auth0 rejected the credentials (${response.status}): ${detail}\n` + + "Check the domain, client ID and secret, and that the application is authorized for the Management API with the `read:users` scope.", + DOCS_URL, + ); + } + + return body.access_token; +} + +type Auth0User = Record & { user_id?: string }; + +/** Fetches one page of users from the Management API. */ +async function fetchAuth0Page( + credentials: Auth0Credentials, + token: string, + page: number, +): Promise<{ users: Auth0User[]; total: number }> { + const url = new URL(`https://${credentials.domain}/api/v2/users`); + url.searchParams.set("page", String(page)); + url.searchParams.set("per_page", String(PAGE_SIZE)); + url.searchParams.set("include_totals", "true"); + + const response = await loggedFetch(url, { + tag: "auth0", + method: "GET", + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }); + + if (!response.ok) { + const body = await response.text(); + throwApiFailure( + response.status, + `Auth0 returned ${response.status} listing users: ${body}`, + DOCS_URL, + ); + } + + const body = (await response.json()) as { users?: Auth0User[]; total?: number }; + return { users: body.users ?? [], total: body.total ?? 0 }; +} + +/** + * Pages through the tenant's users. + * + * Stops at Auth0's 1000-record ceiling with a warning naming the bulk export + * job — silently truncating would read as "that is everyone". + */ +export async function fetchAllAuth0Users(options: { + credentials: Auth0Credentials; + token: string; + spinner?: SpinnerControls; +}): Promise<{ users: Auth0User[]; truncated: boolean }> { + const all: Auth0User[] = []; + + for (let page = 0; ; page++) { + const { users, total } = await fetchAuth0Page(options.credentials, options.token, page); + all.push(...users); + options.spinner?.update(`Fetching users from Auth0: ${all.length} so far...`); + + if (users.length < PAGE_SIZE) break; + + if (all.length >= AUTH0_PAGINATION_CEILING) { + // A tenant of exactly the ceiling is complete. Without a total, a full + // last page may hide more. + const truncated = total ? total > AUTH0_PAGINATION_CEILING : true; + if (truncated) { + log.warn( + `Auth0 only pages through the first ${AUTH0_PAGINATION_CEILING} users on this endpoint` + + (total ? `, and this tenant reports ${total}` : "") + + ". Exported what is reachable; use Auth0's bulk user export job for the rest.", + ); + } + return { users: all, truncated }; + } + } + + return { users: all, truncated: false }; +} + +/** + * Keeps the fields the `auth0` transformer maps from. + * + * Deliberately a copy rather than the raw record: an Auth0 user carries + * identities, session counts and tenant internals that would bloat the export + * and mean nothing to the import. + */ +export function mapAuth0UserToExport(user: Auth0User): Record { + const exported: Record = {}; + + for (const field of [ + "user_id", + "email", + "username", + "name", + "given_name", + "family_name", + "phone_number", + "created_at", + ] as const) { + if (user[field]) exported[field] = user[field]; + } + + // Verification flags are meaningful when false, so they are copied on + // presence rather than on truthiness. + for (const field of ["email_verified", "phone_verified"] as const) { + if (user[field] !== undefined) exported[field] = user[field]; + } + + // Only when true: every unblocked user would otherwise carry a `false`. + if (user.blocked === true) exported.blocked = true; + + for (const field of ["user_metadata", "app_metadata"] as const) { + const value = user[field]; + if (value && typeof value === "object" && Object.keys(value).length > 0) { + exported[field] = value; + } + } + + return exported; +} + +export type Auth0ExportResult = { + users: Record[]; + coverage: { label: string; count: number }[]; +}; + +export function buildAuth0Export( + users: Auth0User[], + record: (line: UserLine) => void = () => {}, +): Auth0ExportResult { + const exported: Record[] = []; + const counts = { email: 0, username: 0, firstName: 0, lastName: 0, phone: 0 }; + + for (const user of users) { + const userId = String(user.user_id ?? ""); + try { + const mapped = mapAuth0UserToExport(user); + exported.push(mapped); + + if (mapped.email) counts.email++; + if (mapped.username) counts.username++; + if (mapped.given_name) counts.firstName++; + if (mapped.family_name) counts.lastName++; + if (mapped.phone_number) counts.phone++; + + record({ sourceId: userId, status: "exported" }); + } catch (error) { + record({ sourceId: userId, status: "skipped", error: (error as Error).message }); + } + } + + return { + users: exported, + coverage: [ + { label: "have an email address", count: counts.email }, + { label: "have a phone number", count: counts.phone }, + { label: "have a username", count: counts.username }, + { label: "have a first name", count: counts.firstName }, + { label: "have a last name", count: counts.lastName }, + ], + }; +} + +export async function exportAuth0(options: ExportAuth0Options): Promise { + const resolved = await resolveAuth0Credentials(options); + + await withGutter("Exporting users from Auth0", async () => { + if (!options.json) printTarget({ platform: "auth0" }); + // Only Auth0 can say whether these three go together, and whether the + // application carries the `read:users` scope, so a rejected set is asked + // for again here. + const { value: token, input: credentials } = await withInputRetry( + resolved, + async () => promptAuth0Credentials(), + async (candidate) => { + log.info(`Exporting from ${candidate.domain}.`); + return withSpinner("Authenticating with Auth0...", async () => fetchAuth0Token(candidate)); + }, + ); + + const { users, truncated } = await withSpinner( + "Fetching users from Auth0...", + async (spinner) => fetchAllAuth0Users({ credentials, token, spinner }), + ); + + const run = await startExportRun(options, { platform: "auth0" }); + const { users: exported, coverage } = buildAuth0Export(users, run.append); + finishExport({ run, options, users: exported, coverage, truncated }); + + if (exported.length > 0) { + log.warn( + "Auth0's Management API does not return password hashes. Request a password hash export from Auth0 support and add a `passwordHash` field to each user before importing, or migrate without passwords.", + ); + log.info(dim(`See ${DOCS_URL}`)); + } + }); +} diff --git a/packages/cli-core/src/commands/migrate/export/clerk.ts b/packages/cli-core/src/commands/migrate/export/clerk.ts index f2f8c161a..48f32bad8 100644 --- a/packages/cli-core/src/commands/migrate/export/clerk.ts +++ b/packages/cli-core/src/commands/migrate/export/clerk.ts @@ -268,8 +268,8 @@ export function buildClerkExport( } export async function exportClerk(options: ExportClerkOptions): Promise { - // Resolved before the gutter opens, the way `export supabase` resolves its - // connection string: confirming the source is a question about whether to run at + // Resolved before the gutter opens, the way `export auth0` resolves its + // credentials: confirming the source is a question about whether to run at // all, not a step of the run. const source = await resolveClerkSource({ secretKey: options.secretKey, diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index ba4e163a0..c1a600616 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -2,6 +2,7 @@ import type { Command } from "@commander-js/extra-typings"; import { throwUsageError } from "../../../lib/errors.ts"; import { select } from "../../../lib/listage.ts"; import { isAgent, isHuman } from "../../../mode.ts"; +import { exportAuth0 } from "./auth0.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; import { exportSupabase } from "./supabase.ts"; @@ -49,6 +50,7 @@ export async function exportPicker(options: Record = {}): Promi const handlers = { picker: exportPicker, clerk: exportClerk, + auth0: exportAuth0, supabase: exportSupabase, firebase: exportFirebase, }; @@ -76,8 +78,9 @@ export function registerMigrateExport(migrateCommand: Command<[], Record[0]), ); + exportCommand + .command("auth0") + .description("Export users from an Auth0 tenant") + .option("--domain ", "Auth0 tenant domain, e.g. my-tenant.us.auth0.com") + .option("--client-id ", "Machine-to-machine application client ID") + .option("--client-secret ", "Machine-to-machine application client secret") + .option("-o, --output ", "Write the export here instead of the run folder") + .option("-y, --yes", "Do not prompt: fail on a rejected credential") + .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) + .option("--json", "Print the result as JSON; never prompts") + .setExamples([ + { + command: + "clerk migrate export auth0 --domain my-tenant.us.auth0.com --client-id … --client-secret …", + description: "Export with explicit credentials", + }, + { + command: "clerk migrate export auth0", + description: "Read AUTH0_DOMAIN, AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET, or prompt", + }, + ]) + .action(async (_opts, cmd) => + handlers.auth0(cmd.optsWithGlobals() as Parameters[0]), + ); + exportCommand .command("firebase") .description("Export users from a Firebase project") 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 4a92d606b..69f594a4e 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,7 @@ import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./regist describe("export registry", () => { test("registers every source platform", () => { - expect(exportPlatformKeys()).toEqual(["clerk", "supabase", "firebase"]); + expect(exportPlatformKeys()).toEqual(["clerk", "auth0", "supabase", "firebase"]); }); test.each([...exportPlatforms])("$key carries a label and description", (entry) => { @@ -23,7 +23,7 @@ describe("export registry", () => { }); test("looks a platform up by key", () => { - expect(getExportPlatform("supabase")?.label).toBe("Supabase"); + expect(getExportPlatform("auth0")?.label).toBe("Auth0"); }); test("returns nothing for a platform that is not registered", () => { diff --git a/packages/cli-core/src/commands/migrate/export/registry.ts b/packages/cli-core/src/commands/migrate/export/registry.ts index 0ecb9108e..9ff2dc3ef 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.ts @@ -10,6 +10,7 @@ * and M2M credentials) has nothing in common with what a database export needs. */ +import { exportAuth0 } from "./auth0.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; import { exportSupabase } from "./supabase.ts"; @@ -31,6 +32,13 @@ export const exportPlatforms: ExportRegistryEntry[] = [ sourceKey: "clerk", run: async (options) => exportClerk(options), }, + { + key: "auth0", + label: "Auth0", + description: "An Auth0 tenant, via the Management API", + sourceKey: "auth0", + run: async (options) => exportAuth0(options), + }, { key: "supabase", label: "Supabase", diff --git a/packages/cli-core/src/commands/migrate/export/shared.test.ts b/packages/cli-core/src/commands/migrate/export/shared.test.ts index 84f161f0b..3e6cc04ba 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.test.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.test.ts @@ -136,7 +136,7 @@ describe("finishExport", () => { }); test("--output writes somewhere else, and the run still records where", async () => { - const run = await startExportRun({ runsDir }, { platform: "supabase" }); + const run = await startExportRun({ runsDir }, { platform: "auth0" }); const { record, outputPath } = finishExport({ run, diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index b80201419..65f59bb34 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -98,6 +98,20 @@ describe("registerMigrate", () => { ).toEqual(["--output", "--yes", "--runs-dir", "--json", "--secret-key", "--app", "--instance"]); }); + test("migrate export auth0 takes exactly its flags", () => { + expect( + findCommand(["migrate", "export", "auth0"])?.options.map((option) => option.long), + ).toEqual([ + "--domain", + "--client-id", + "--client-secret", + "--output", + "--yes", + "--runs-dir", + "--json", + ]); + }); + test("migrate export supabase takes exactly its flags", () => { expect( findCommand(["migrate", "export", "supabase"])?.options.map((option) => option.long), diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index de2b44b55..e0c1728c1 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -62,7 +62,7 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce // mode: every prompt in this tree already stands down for an agent, with the // usage error naming what to pass instead. An explicit `--mode` still wins. migrateCommand.hook("preAction", (_thisCommand, actionCommand) => { - // With globals: `export supabase --json` lands on the export group's own + // With globals: `export auth0 --json` lands on the export group's own // --json, which the subcommand's opts() never sees. const opts = actionCommand.optsWithGlobals(); setAssumeYes(Boolean(opts.yes)); 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 db1c30c85..4f3f597e3 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 @@ -245,7 +245,7 @@ describe("withInputRetry", () => { // Another credential would not fix an outage, a 429 or a refused connection. test.each([ ["a refused connection", new CliError("Could not reach x")], - ["a 503", new CliError("The server returned 503 listing users")], + ["a 503", new CliError("Auth0 returned 503 listing users")], ])("does not ask again after %s", async (_label, failure) => { let attempts = 0; diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 73ecb0cc7..62a8e9598 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -561,8 +561,8 @@ describe("run", () => { test("refuses a source that contradicts the envelope", async () => { const { record } = exportRun("clerk", export2); - await expect(run({ ...noSource, source: "supabase", input: record.id })).rejects.toThrow( - /exported from clerk, but --source names supabase/, + await expect(run({ ...noSource, source: "auth0", input: record.id })).rejects.toThrow( + /exported from clerk, but --source names auth0/, ); expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(0); }); @@ -2008,23 +2008,45 @@ describe("run", () => { }); describe("per-platform imports", () => { - test("supabase transforms, validates and imports its export", async () => { - fs.writeFileSync( - path.join(workDir, "export.json"), - JSON.stringify([ + /** One realistic record per platform, in that platform's export shape. */ + const PLATFORMS: [string, unknown, string][] = [ + [ + "auth0", + [ + { + user_id: "auth0|1", + email: "a@x.dev", + email_verified: true, + given_name: "Ada", + family_name: "L", + }, + ], + "auth0|1", + ], + [ + "supabase", + [ { id: "sb1", email: "a@x.dev", email_confirmed_at: "2024-06-29 20:25:06+00", encrypted_password: BCRYPT, }, - ]), - ); + ], + "sb1", + ], + ]; - await run({ ...baseOptions, source: "supabase" }); + test.each(PLATFORMS)( + "%s transforms, validates and imports its export", + async (key, records, externalId) => { + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(records)); - expect(created()).toEqual(["sb1"]); - }); + await run({ ...baseOptions, source: key }); + + expect(created()).toEqual([externalId]); + }, + ); test("an unknown source fails before signing in", async () => { const { secretKey: _key, ...signedOut } = baseOptions; @@ -2144,7 +2166,7 @@ describe("run", () => { )) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain( - 'Unknown source "nope". Valid sources: clerk, firebase, supabase.', + 'Unknown source "nope". Valid sources: clerk, auth0, firebase, supabase.', ); 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 acfc6ca23..17943d32b 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -385,7 +385,7 @@ async function applySource(options: MigrateRunOptions): Promise { + routeByVerification(user, "email", "emailVerified", "boolean"); + routeByVerification(user, "phone", "phoneVerified", "boolean"); + + // given_name/family_name win when present. Auth0 fills `name` with the + // email for database users; splitName drops an email-shaped value. + if (user.firstName || user.lastName) delete user.name; + else splitName(user); + + // Runs before normalizeUserData, so accept CSV's "true" as well. + if (isVerified(user.banned, "boolean")) user.banned = true; + else delete user.banned; + }, + defaults: { + passwordHasher: "bcrypt" as const, + }, +} satisfies SourceEntry; + +export default auth0Source; diff --git a/packages/cli-core/src/commands/migrate/sources/registry.ts b/packages/cli-core/src/commands/migrate/sources/registry.ts index f6f14f758..9ecfc0ba4 100644 --- a/packages/cli-core/src/commands/migrate/sources/registry.ts +++ b/packages/cli-core/src/commands/migrate/sources/registry.ts @@ -9,11 +9,12 @@ import { throwUsageError } from "../../../lib/errors.ts"; import type { SourceEntry } from "../types.ts"; +import auth0Source from "./auth0.ts"; import clerkSource from "./clerk.ts"; import firebaseSource from "./firebase.ts"; import supabaseSource from "./supabase.ts"; -export const sources: SourceEntry[] = [clerkSource, firebaseSource, supabaseSource]; +export const sources: SourceEntry[] = [clerkSource, auth0Source, firebaseSource, supabaseSource]; export const ACCOUNT_LINKING_URL = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/account-linking"; 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 b0ac09a54..0a4777f2a 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,7 @@ const one = (key: string, record: Record, context = {}) => describe("registry", () => { test("registers the built-in platforms", () => { - expect(sourceKeys()).toEqual(["clerk", "firebase", "supabase"]); + expect(sourceKeys()).toEqual(["clerk", "auth0", "firebase", "supabase"]); }); test.each([...sources])("$key maps a source field to userId", (source) => { @@ -113,6 +113,86 @@ describe("isVerified", () => { }); }); +describe("auth0", () => { + const base = { user_id: "auth0|abc", email: "a@x.dev", passwordHash: "$2b$10$hash" }; + + test("maps identity, name and metadata onto the Clerk schema", async () => { + const { users } = await load("auth0", [ + { ...base, email_verified: true, given_name: "Ada", family_name: "Lovelace" }, + ]); + expect(users[0]).toMatchObject({ + userId: "auth0|abc", + email: "a@x.dev", + firstName: "Ada", + lastName: "Lovelace", + password: "$2b$10$hash", + passwordHasher: "bcrypt", + }); + }); + + test.each([ + [true, "email", undefined], + [false, undefined, "a@x.dev"], + [undefined, undefined, "a@x.dev"], + ])("email_verified=%p routes the address correctly", (verified, kept, unverified) => { + const user = one("auth0", { ...base, email_verified: verified }); + expect(user?.email).toBe(kept ? "a@x.dev" : undefined); + expect(user?.unverifiedEmailAddresses).toBe(unverified); + }); + + test("routes an unverified phone away from the primary field", () => { + const user = one("auth0", { ...base, phone_number: "+15555550100", phone_verified: false }); + expect(user?.phone).toBeUndefined(); + expect(user?.unverifiedPhoneNumbers).toBe("+15555550100"); + }); + + test("drops the platform's verification markers", () => { + const user = one("auth0", { ...base, email_verified: true, phone_verified: true }); + expect("emailVerified" in (user ?? {})).toBe(false); + expect("phoneVerified" in (user ?? {})).toBe(false); + }); + + // `user_metadata` is the user's own to edit in Auth0, which is what Clerk's + // unsafe metadata is; public metadata is read-only to the user. + test("sends user_metadata to unsafe metadata and app_metadata to private", async () => { + const { users } = await load("auth0", [ + { + ...base, + email_verified: true, + user_metadata: { theme: "dark" }, + app_metadata: { plan: "pro" }, + }, + ]); + expect(users[0]?.unsafeMetadata).toEqual({ theme: "dark" }); + expect(users[0]?.publicMetadata).toBeUndefined(); + expect(users[0]?.privateMetadata).toEqual({ plan: "pro" }); + }); + + test.each([ + [true, true], + ["true", true], + [false, undefined], + [undefined, undefined], + ])("blocked=%p is carried as banned=%p", (blocked, expected) => { + expect(one("auth0", { ...base, blocked })?.banned).toBe(expected as boolean | undefined); + }); + + test("splits name when given_name and family_name are absent", () => { + const user = one("auth0", { ...base, name: "Ada King Lovelace" }); + expect(user).toMatchObject({ firstName: "Ada", lastName: "King Lovelace" }); + }); + + test("prefers given_name/family_name over name", () => { + const user = one("auth0", { ...base, name: "a@x.dev", given_name: "Ada", family_name: "L" }); + expect(user).toMatchObject({ firstName: "Ada", lastName: "L" }); + }); + + test("leaves Auth0's email-default name unset", () => { + const user = one("auth0", { ...base, name: "a@x.dev" }); + expect(user?.firstName).toBeUndefined(); + }); +}); + describe("firebase", () => { const base = { localId: "fb1", email: "a@x.dev", emailVerified: true }; @@ -532,6 +612,7 @@ describe("fields the CLI's own export adds", () => { describe("invalid records", () => { const INVALID: [string, Record][] = [ + ["auth0", { user_id: "a1" }], ["firebase", { localId: "a4" }], ["supabase", { id: "a5" }], ]; @@ -554,6 +635,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 === "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 c29372f99..6906f0c3d 100644 --- a/packages/cli-core/src/commands/migrate/wizard.test.ts +++ b/packages/cli-core/src/commands/migrate/wizard.test.ts @@ -58,11 +58,12 @@ const selectCall = (index: number): SelectPrompt | undefined => mockSelect.mock. describe("promptForSource", () => { test("is built from the registry, so every platform appears", async () => { - mockSelect.mockResolvedValue("supabase"); + mockSelect.mockResolvedValue("auth0"); - expect(await promptForSource()).toBe("supabase"); + expect(await promptForSource()).toBe("auth0"); expect(selectCall(0)?.choices.map((choice) => choice.value)).toEqual([ "clerk", + "auth0", "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 886f339b2..1eb4ecb74 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", "firebase", "supabase"]); + expect(names).toEqual(["clerk", "auth0", "firebase", "supabase"]); } finally { if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; else process.env.CLERK_EXPERIMENTAL = saved; From 2794b80a50cd8ef57a290353a385dcb475c58254 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:46:32 -0400 Subject: [PATCH 02/16] docs(changeset): Add clerk migrate export auth0 and Auth0 imports Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-auth0.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-auth0.md diff --git a/.changeset/migrate-auth0.md b/.changeset/migrate-auth0.md new file mode 100644 index 000000000..d1caf948b --- /dev/null +++ b/.changeset/migrate-auth0.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate export auth0`, and let `clerk migrate import` read Auth0 exports, including the NDJSON file from Auth0's bulk export job. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. From 81b606329171edea3653180a5f606dbeb8f29b31 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 14:47:11 -0400 Subject: [PATCH 03/16] fix(migrate): never prompt for Auth0 credentials under --json Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/export/auth0.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index 8b2628988..e0f807998 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -94,7 +94,7 @@ export async function resolveAuth0Credentials( }; } - if (isAgent() || !isHuman()) { + if (options.json || isAgent() || !isHuman()) { throwUsageError( `\`clerk migrate export auth0\` needs credentials for a machine-to-machine application and cannot prompt here.\n` + `Missing: ${missing.map(([, flag, variable]) => `${flag} (or ${variable})`).join(", ")}.`, @@ -363,6 +363,7 @@ export async function exportAuth0(options: ExportAuth0Options): Promise { log.info(`Exporting from ${candidate.domain}.`); return withSpinner("Authenticating with Auth0...", async () => fetchAuth0Token(candidate)); }, + options, ); const { users, truncated } = await withSpinner( From 2743adc839af44d21e9b992137831418d78cde6c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 15:21:17 -0400 Subject: [PATCH 04/16] fix(migrate): accept only a bare host name as the Auth0 domain A domain like `tenant.auth0.com@attacker.example` made URL parsing send the client secret to attacker.example. --domain, AUTH0_DOMAIN and the prompt now refuse userinfo, ports, paths and queries. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/auth0.test.ts | 35 +++++++++++++++++++ .../src/commands/migrate/export/auth0.ts | 20 ++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index 0ced7a22f..d949f5005 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -11,6 +11,7 @@ import { fetchAllAuth0Users, fetchAuth0Token, mapAuth0UserToExport, + isAuth0Domain, normalizeAuth0Domain, resolveAuth0Credentials, } from "./auth0.ts"; @@ -92,7 +93,41 @@ describe("normalizeAuth0Domain", () => { }); }); +// The client secret goes to this host, so anything that could make URL parsing +// pick a different one is refused. +describe("isAuth0Domain", () => { + test.each(["t.auth0.com", "https://t.auth0.com/", "login.example.com"])( + "accepts %s", + (domain) => { + expect(isAuth0Domain(domain)).toBe(true); + }, + ); + + test.each([ + "t.auth0.com@attacker.example", + "t.auth0.com/path", + "t.auth0.com:8443", + "t.auth0.com?x=1", + "localhost", + ])("refuses %s", (domain) => { + expect(isAuth0Domain(domain)).toBe(false); + }); +}); + describe("resolveAuth0Credentials", () => { + test("refuses a domain that would send the secret to another host", async () => { + await expect( + resolveAuth0Credentials( + {}, + { + AUTH0_DOMAIN: "t.auth0.com@attacker.example", + AUTH0_CLIENT_ID: "c", + AUTH0_CLIENT_SECRET: "s", + }, + ), + ).rejects.toThrow(/is not an Auth0 domain/); + }); + test("prefers flags", async () => { const resolved = await resolveAuth0Credentials( { domain: "flag.auth0.com", clientId: "f", clientSecret: "s" }, diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index e0f807998..ef3e7ff84 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -62,6 +62,17 @@ export function normalizeAuth0Domain(domain: string): string { .replace(/\/+$/, ""); } +/** + * True for a bare host name. The client secret goes to this host, so userinfo + * (`tenant.auth0.com@elsewhere`), a port, a path or a query is refused rather + * than letting URL parsing pick a different host. + */ +export function isAuth0Domain(domain: string): boolean { + return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(normalizeAuth0Domain(domain)); +} + +const DOMAIN_HINT = "Pass just the tenant's host name, e.g. my-tenant.us.auth0.com."; + /** * Resolves the tenant credentials: flags, then environment, then a prompt. * @@ -78,6 +89,10 @@ export async function resolveAuth0Credentials( clientSecret: options.clientSecret ?? env.AUTH0_CLIENT_SECRET, }; + if (resolved.domain && !isAuth0Domain(resolved.domain)) { + throwUsageError(`"${resolved.domain}" is not an Auth0 domain. ${DOMAIN_HINT}`, DOCS_URL); + } + const missing = ( [ ["domain", "--domain", "AUTH0_DOMAIN"], @@ -131,7 +146,10 @@ export async function promptAuth0Credentials( known.domain ?? (await text({ message: "Auth0 tenant domain (e.g. my-tenant.us.auth0.com)", - validate: (value) => (value?.trim() ? undefined : "A domain is required"), + validate: (value) => { + if (!value?.trim()) return "A domain is required"; + return isAuth0Domain(value) ? undefined : DOMAIN_HINT; + }, })); const clientId = known.clientId ?? From f28823bd0b7edc429b5cdf66627163b6073db0d3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 15:21:36 -0400 Subject: [PATCH 05/16] fix(migrate): never follow a redirect with Auth0 credentials A followed 307/308 resends the POST body, client secret included, to the redirect target. Both Auth0 requests now fail on a 3xx instead. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/auth0.test.ts | 30 +++++++++++++++++++ .../src/commands/migrate/export/auth0.ts | 4 +++ 2 files changed, 34 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index d949f5005..d723aa846 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -203,6 +203,20 @@ describe("fetchAuth0Token", () => { expect(error.exitCode).not.toBe(EXIT_CODE.USAGE); }); + test("does not follow a redirect with the client secret", async () => { + let redirect: RequestInit["redirect"]; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + redirect = init?.redirect; + return new Response(null, { + status: 307, + headers: { location: "https://elsewhere.example/" }, + }); + }) as unknown as typeof fetch; + + await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(/did not issue a token \(307\)/); + expect(redirect).toBe("manual"); + }); + test("mentions the read:users scope, the usual cause", async () => { stubAuth0([[]], new Response("{}", { status: 403 })); await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(/read:users/); @@ -273,6 +287,22 @@ describe("fetchAllAuth0Users", () => { expect(captured.err).not.toContain("only pages through"); }); + test("does not follow a redirect with the bearer token", async () => { + let redirect: RequestInit["redirect"]; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + redirect = init?.redirect; + return new Response(null, { + status: 302, + headers: { location: "https://elsewhere.example/" }, + }); + }) as unknown as typeof fetch; + + await expect(fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" })).rejects.toThrow( + /Auth0 returned 302 listing users/, + ); + expect(redirect).toBe("manual"); + }); + test("raises a clear error on a failed page request", async () => { globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index ef3e7ff84..96f6f9ca1 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -178,6 +178,9 @@ export async function fetchAuth0Token(credentials: Auth0Credentials): Promise Date: Wed, 7 Oct 2026 15:21:56 -0400 Subject: [PATCH 06/16] fix(migrate): warn whenever an Auth0 export reaches 1000 users Auth0 reports a larger tenant's total as 1000, so a total of 1000 never proved the export was complete, and a tenant of any size past it was exported without the warning. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 5 +++-- .../src/commands/migrate/export/auth0.test.ts | 8 ++++--- .../src/commands/migrate/export/auth0.ts | 21 +++++++++---------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index f177375c2..1065fdb7c 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -500,8 +500,9 @@ it exits naming **every** missing credential at once rather than one per run. Auth0 pages this endpoint only through the first **1000** users. Past that the export stops and says so, pointing at Auth0's bulk export job — silently returning the first thousand would read as "that is everyone". It still exits -0, and `--json` carries `truncated: true`. A tenant of exactly 1000 is -complete, and gets no warning. The bulk job's NDJSON file imports as it is. +0, and `--json` carries `truncated: true`. A tenant of exactly 1000 gets the +warning too: Auth0 reports a larger tenant's total as 1000, so the two look the +same. The bulk job's NDJSON file imports as it is. A credential the platform rejects (400, 401 or 403) is asked for again at a terminal. An outage, a `429` or a refused connection is not: another diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index d723aa846..627a4c7fc 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -272,7 +272,9 @@ describe("fetchAllAuth0Users", () => { expect(captured.err).toContain("bulk user export job"); }); - test("a tenant of exactly 1000 users is complete, with no warning", async () => { + // Auth0 reports a larger tenant's total as 1000, so 1000 of 1000 cannot be + // told apart from 1000 of 50,000. + test("a total of exactly 1000 may hide more, and says so", async () => { stubAuth0( Array.from({ length: 10 }, () => Array.from({ length: 100 }, (_, i) => auth0User(i))), ); @@ -283,8 +285,8 @@ describe("fetchAllAuth0Users", () => { }); expect(all).toHaveLength(1000); - expect(truncated).toBe(false); - expect(captured.err).not.toContain("only pages through"); + expect(truncated).toBe(true); + expect(captured.err).toContain("so there may be more"); }); test("does not follow a redirect with the bearer token", async () => { diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index 96f6f9ca1..1a6867238 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -269,17 +269,16 @@ export async function fetchAllAuth0Users(options: { if (users.length < PAGE_SIZE) break; if (all.length >= AUTH0_PAGINATION_CEILING) { - // A tenant of exactly the ceiling is complete. Without a total, a full - // last page may hide more. - const truncated = total ? total > AUTH0_PAGINATION_CEILING : true; - if (truncated) { - log.warn( - `Auth0 only pages through the first ${AUTH0_PAGINATION_CEILING} users on this endpoint` + - (total ? `, and this tenant reports ${total}` : "") + - ". Exported what is reachable; use Auth0's bulk user export job for the rest.", - ); - } - return { users: all, truncated }; + // Auth0 reports a larger tenant's total as 1000 too, so reaching the + // ceiling never proves the export is complete. + log.warn( + `Auth0 only pages through the first ${AUTH0_PAGINATION_CEILING} users on this endpoint` + + (total > AUTH0_PAGINATION_CEILING + ? `, and this tenant reports ${total}` + : `, and counts no higher, so there may be more`) + + ". Exported what is reachable; use Auth0's bulk user export job for the rest.", + ); + return { users: all, truncated: true }; } } From 60f8c81b97acb88fa571a24c8e03238ff613d791 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 16:32:20 -0400 Subject: [PATCH 07/16] fix(migrate): do not ask for Auth0 credentials under -y -y reads "Do not prompt", but missing Auth0 credentials were still asked for at a terminal. Under -y they are the usage error naming each flag, as for the other exports. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/auth0.test.ts | 12 ++++++++++++ .../cli-core/src/commands/migrate/export/auth0.ts | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index 627a4c7fc..4bb84bb82 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { CliError, EXIT_CODE } from "../../../lib/errors.ts"; import type { UserLine } from "../lib/run-store.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { setAssumeYes } from "../lib/assume-yes.ts"; import { buildAuth0Export, exportAuth0, @@ -156,6 +157,17 @@ describe("resolveAuth0Credentials", () => { expect(resolved.domain).toBe("t.auth0.com"); }); + // `-y` is "do not prompt", at a terminal too. + test("does not ask for missing credentials under -y", async () => { + process.env.CLERK_MODE = "human"; + setAssumeYes(true); + try { + await expect(resolveAuth0Credentials({}, {})).rejects.toThrow(/cannot prompt here/); + } finally { + setAssumeYes(false); + } + }); + // Tests run non-TTY, the same signal an agent gives. test("names every missing credential at once rather than one at a time", async () => { await expect(resolveAuth0Credentials({}, {})).rejects.toThrow( diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index 1a6867238..f0c7ec1af 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -21,6 +21,7 @@ import { log } from "../../../lib/log.ts"; import { password as passwordPrompt, text } from "../../../lib/prompts.ts"; import { withGutter, withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; import { isAgent, isHuman } from "../../../mode.ts"; +import { isAssumeYes } from "../lib/assume-yes.ts"; import type { UserLine } from "../lib/run-store.ts"; import { printTarget } from "../lib/target.ts"; import { isCredentialStatus, throwApiFailure, withInputRetry } from "../lib/input-retry.ts"; @@ -109,7 +110,8 @@ export async function resolveAuth0Credentials( }; } - if (options.json || isAgent() || !isHuman()) { + // `-y` is "do not prompt", as for the other exports. + if (options.json || isAssumeYes() || isAgent() || !isHuman()) { throwUsageError( `\`clerk migrate export auth0\` needs credentials for a machine-to-machine application and cannot prompt here.\n` + `Missing: ${missing.map(([, flag, variable]) => `${flag} (or ${variable})`).join(", ")}.`, From ccede6446c84f961f9c55e854ed213e353fb1ebc Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 13:28:12 -0400 Subject: [PATCH 08/16] fix(migrate): page an Auth0 export to its total, not to the first short page Auth0 can send a short page before the last one. The export stopped at the first short page, so it could leave users out and still report the export as complete. It now pages until it has `total` users, keeping the 1000-user ceiling check first; a short page ends it only when Auth0 sent no total, and an empty page always does. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/auth0.test.ts | 32 +++++++++++++++++++ .../src/commands/migrate/export/auth0.ts | 8 ++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index 4bb84bb82..2f227f24c 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -259,6 +259,38 @@ describe("fetchAllAuth0Users", () => { expect(requests).toHaveLength(2); }); + // A short page is not always the last: the total says how many there are. + test("keeps paging past a short page until the total is reached", async () => { + stubAuth0([ + Array.from({ length: 100 }, (_, i) => auth0User(i)), + Array.from({ length: 50 }, (_, i) => auth0User(100 + i)), + Array.from({ length: 30 }, (_, i) => auth0User(150 + i)), + ]); + + const { users: all, truncated } = await fetchAllAuth0Users({ + credentials: CREDENTIALS, + token: "tok", + }); + + expect(all).toHaveLength(180); + expect(truncated).toBe(false); + expect(requests).toHaveLength(3); + }); + + test("ends on a short page when Auth0 sends no total", async () => { + const pages = [Array.from({ length: 100 }, (_, i) => auth0User(i)), [auth0User(100)]]; + let page = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + requests.push({ url: input.toString(), body: null }); + return Response.json({ users: pages[page++] ?? [] }); + }) as unknown as typeof fetch; + + const { users: all } = await fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" }); + + expect(all).toHaveLength(101); + expect(requests).toHaveLength(2); + }); + test("asks for totals and the documented page size", async () => { stubAuth0([[]]); await fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" }); diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index f0c7ec1af..2ac88013e 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -268,7 +268,9 @@ export async function fetchAllAuth0Users(options: { all.push(...users); options.spinner?.update(`Fetching users from Auth0: ${all.length} so far...`); - if (users.length < PAGE_SIZE) break; + // An empty page ends it whatever `total` says, so an overstated total + // cannot page forever. + if (users.length === 0) break; if (all.length >= AUTH0_PAGINATION_CEILING) { // Auth0 reports a larger tenant's total as 1000 too, so reaching the @@ -282,6 +284,10 @@ export async function fetchAllAuth0Users(options: { ); return { users: all, truncated: true }; } + + // Auth0 can send a short page before the last one, so `total` decides + // when it is known; a short page ends it only when Auth0 sent no total. + if (total > 0 ? all.length >= total : users.length < PAGE_SIZE) break; } return { users: all, truncated: false }; From 3f1412ee08441a0985cce9d4fb064c79168dc482 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 17:17:16 -0400 Subject: [PATCH 09/16] docs(migrate): say only clerk's export counts the passwords it can't export Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 1065fdb7c..25ec0aafe 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -365,8 +365,8 @@ alongside imports. only from a support request. Add a `passwordHash` field to each user before importing, or migrate without passwords. -Both say so on every run. The coverage row counts users who _have_ a password, -so the size of the gap is visible up front. +Both say so on every run. `clerk`'s coverage also counts users who _have_ a +password, so the size of the gap is visible up front. #### `supabase` reads the database From ae084e66a77c4ce04306c8b3f9f6b1540dc6e774 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 21:12:56 -0400 Subject: [PATCH 10/16] docs(migrate): name the Auth0 API the exporter calls as the Management API Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 25ec0aafe..05df96f4e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -961,7 +961,7 @@ brings across. Adding a platform is one file in `sources/` plus one line in | Key | Reads | Passwords | MFA | Metadata | | ---------- | ---------------------------- | --------- | ------- | -------- | | `clerk` | Clerk Dashboard export | partial | partial | partial | -| `auth0` | Auth0 Export Users API | partial | no | yes | +| `auth0` | Auth0 Management API | partial | no | yes | | `firebase` | `firebase auth:export` | yes | no | no | | `supabase` | Supabase `auth.users` export | yes | no | partial | From 58c91587e16e13788fc86ba3310f686196c6a4fe Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 00:51:57 -0400 Subject: [PATCH 11/16] fix(migrate): page an Auth0 export so it never passes short for complete, and back off on a 429 - A 429 now backs off through retryOn429, honouring Retry-After. One used to end the export with nothing written. - A short page ends the paging again: `page` is an offset, so paging past one skipped the records after it. Fewer users than Auth0 counted is now a truncated export, with a warning, rather than a complete one. - The 1000 ceiling is checked by offset, so no page past what the endpoint serves is ever asked for. - Pages come in created_at order, and a repeated user_id is exported once. - A truncated export exits 1, with its file still written, so an agent cannot miss it. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 19 ++++-- .../src/commands/migrate/export/auth0.test.ts | 67 +++++++++++++++++-- .../src/commands/migrate/export/auth0.ts | 67 +++++++++++++++---- .../commands/migrate/export/shared.test.ts | 12 ++++ .../src/commands/migrate/export/shared.ts | 3 + 5 files changed, 141 insertions(+), 27 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 05df96f4e..60aa476fb 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -499,14 +499,21 @@ it exits naming **every** missing credential at once rather than one per run. Auth0 pages this endpoint only through the first **1000** users. Past that the export stops and says so, pointing at Auth0's bulk export job — silently -returning the first thousand would read as "that is everyone". It still exits -0, and `--json` carries `truncated: true`. A tenant of exactly 1000 gets the -warning too: Auth0 reports a larger tenant's total as 1000, so the two look the -same. The bulk job's NDJSON file imports as it is. +returning the first thousand would read as "that is everyone". A tenant of +exactly 1000 gets the warning too: Auth0 reports a larger tenant's total as +1000, so the two look the same. It also says so when fewer users come back +than Auth0 counted. Either way the file holds what came back, `--json` carries +`truncated: true`, and the export exits 1. The bulk job's NDJSON file imports +as it is. + +Pages are read in `created_at` order, and a user a shifted page repeats is +exported once. A `429` backs off, honouring `Retry-After`, as the import does; +a free or development tenant allows only a few Management API requests a +second. A credential the platform rejects (400, 401 or 403) is asked for again at a -terminal. An outage, a `429` or a refused connection is not: another -credential would not fix it, and it exits 1. +terminal. An outage, a refused connection, or a `429` that outlasts its +retries is not: another credential would not fix it, and it exits 1. ### `clerk migrate import` diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index 2f227f24c..76c148d8c 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -259,8 +259,9 @@ describe("fetchAllAuth0Users", () => { expect(requests).toHaveLength(2); }); - // A short page is not always the last: the total says how many there are. - test("keeps paging past a short page until the total is reached", async () => { + // Offset pages: paging past a short one would skip the records after it. + // The export says it is short instead of passing for complete. + test("a short page before the total ends it, and says the export is short", async () => { stubAuth0([ Array.from({ length: 100 }, (_, i) => auth0User(i)), Array.from({ length: 50 }, (_, i) => auth0User(100 + i)), @@ -272,9 +273,57 @@ describe("fetchAllAuth0Users", () => { token: "tok", }); - expect(all).toHaveLength(180); - expect(truncated).toBe(false); - expect(requests).toHaveLength(3); + expect(all).toHaveLength(150); + expect(truncated).toBe(true); + expect(requests).toHaveLength(2); + expect(captured.err).toContain("Auth0 reported 180 users but returned 150"); + }); + + test("backs off on a 429, honouring Retry-After, and carries on", async () => { + let calls = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + requests.push({ url: input.toString(), body: null }); + calls++; + if (calls === 1) { + return new Response("Too Many Requests", { status: 429, headers: { "retry-after": "1" } }); + } + return Response.json({ users: [auth0User(0)], total: 1 }); + }) as unknown as typeof fetch; + + const started = performance.now(); + const { users: all } = await fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" }); + + expect(all).toHaveLength(1); + expect(requests).toHaveLength(2); + expect(performance.now() - started).toBeGreaterThanOrEqual(900); + }); + + // A sign-up mid-export can shift a page and repeat a user. + test("asks for a stable order, and exports a repeated user once", async () => { + stubAuth0([ + Array.from({ length: 100 }, (_, i) => auth0User(i)), + [auth0User(99), auth0User(100)], + ]); + + const { users: all } = await fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" }); + + expect(requests[0]?.url).toContain("sort=created_at%3A1"); + expect(all.map((user) => user.user_id).filter((id) => id === "auth0|a99")).toHaveLength(1); + expect(all).toHaveLength(101); + }); + + // Offset 1000 is past what this endpoint serves. + test("never asks for a page past offset 1000", async () => { + stubAuth0( + Array.from({ length: 12 }, (_, page) => + Array.from({ length: 100 }, (_, i) => auth0User(page * 100 + i)), + ), + ); + + await fetchAllAuth0Users({ credentials: CREDENTIALS, token: "tok" }); + + expect(requests).toHaveLength(10); + expect(requests.at(-1)?.url).toContain("page=9"); }); test("ends on a short page when Auth0 sends no total", async () => { @@ -302,7 +351,9 @@ describe("fetchAllAuth0Users", () => { // would read as "that is everyone". test("stops at Auth0's 1000-record ceiling and says so", async () => { stubAuth0( - Array.from({ length: 12 }, () => Array.from({ length: 100 }, (_, i) => auth0User(i))), + Array.from({ length: 12 }, (_, page) => + Array.from({ length: 100 }, (_, i) => auth0User(page * 100 + i)), + ), ); const { users: all, truncated } = await fetchAllAuth0Users({ @@ -320,7 +371,9 @@ describe("fetchAllAuth0Users", () => { // told apart from 1000 of 50,000. test("a total of exactly 1000 may hide more, and says so", async () => { stubAuth0( - Array.from({ length: 10 }, () => Array.from({ length: 100 }, (_, i) => auth0User(i))), + Array.from({ length: 10 }, (_, page) => + Array.from({ length: 100 }, (_, i) => auth0User(page * 100 + i)), + ), ); const { users: all, truncated } = await fetchAllAuth0Users({ diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index 2ac88013e..ba0aed80d 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -14,7 +14,7 @@ * leaving it to be discovered when nobody can sign in. */ -import { throwUsageError } from "../../../lib/errors.ts"; +import { ApiError, CliError, throwUsageError } from "../../../lib/errors.ts"; import { loggedFetch } from "../../../lib/fetch.ts"; import { dim } from "../../../lib/color.ts"; import { log } from "../../../lib/log.ts"; @@ -25,6 +25,7 @@ import { isAssumeYes } from "../lib/assume-yes.ts"; import type { UserLine } from "../lib/run-store.ts"; import { printTarget } from "../lib/target.ts"; import { isCredentialStatus, throwApiFailure, withInputRetry } from "../lib/input-retry.ts"; +import { RateLimitExceededError, retryOn429 } from "../lib/retry.ts"; import { finishExport, startExportRun } from "./shared.ts"; const PAGE_SIZE = 100; @@ -229,6 +230,9 @@ async function fetchAuth0Page( url.searchParams.set("page", String(page)); url.searchParams.set("per_page", String(PAGE_SIZE)); url.searchParams.set("include_totals", "true"); + // A stable order, so a sign-up mid-export shifts a page rather than + // repeating one user and skipping another. + url.searchParams.set("sort", "created_at:1"); const response = await loggedFetch(url, { tag: "auth0", @@ -239,6 +243,8 @@ async function fetchAuth0Page( if (!response.ok) { const body = await response.text(); + // Thrown as an ApiError so `retryOn429` backs off, honouring Retry-After. + if (response.status === 429) throw new ApiError(429, body, response.headers); throwApiFailure( response.status, `Auth0 returned ${response.status} listing users: ${body}`, @@ -250,11 +256,30 @@ async function fetchAuth0Page( return { users: body.users ?? [], total: body.total ?? 0 }; } +/** One page, backing off on a 429 as `export clerk` does. */ +async function fetchAuth0PageWithRetry( + credentials: Auth0Credentials, + token: string, + page: number, +): Promise<{ users: Auth0User[]; total: number }> { + try { + return await retryOn429(async () => fetchAuth0Page(credentials, token, page)); + } catch (error) { + if (!(error instanceof RateLimitExceededError)) throw error; + throw new CliError( + `Auth0 kept rate-limiting the Management API (429, ${error.attempts} retries). Nothing was exported. ` + + "Try again shortly; a free or development tenant allows only a few requests a second.", + { docsUrl: DOCS_URL }, + ); + } +} + /** * Pages through the tenant's users. * * Stops at Auth0's 1000-record ceiling with a warning naming the bulk export - * job — silently truncating would read as "that is everyone". + * job — silently truncating would read as "that is everyone" — and says so + * too when fewer users come back than Auth0 counted. */ export async function fetchAllAuth0Users(options: { credentials: Auth0Credentials; @@ -262,19 +287,30 @@ export async function fetchAllAuth0Users(options: { spinner?: SpinnerControls; }): Promise<{ users: Auth0User[]; truncated: boolean }> { const all: Auth0User[] = []; + const seen = new Set(); + let total = 0; for (let page = 0; ; page++) { - const { users, total } = await fetchAuth0Page(options.credentials, options.token, page); - all.push(...users); + const result = await fetchAuth0PageWithRetry(options.credentials, options.token, page); + total = result.total; + for (const user of result.users) { + // A user a shifted page repeats would reach the import twice. + if (typeof user.user_id === "string") { + if (seen.has(user.user_id)) continue; + seen.add(user.user_id); + } + all.push(user); + } options.spinner?.update(`Fetching users from Auth0: ${all.length} so far...`); - // An empty page ends it whatever `total` says, so an overstated total - // cannot page forever. - if (users.length === 0) break; + // A short or empty page is the last one Auth0 has. + if (result.users.length < PAGE_SIZE) break; + // Complete under the ceiling. At it, Auth0 counts no higher, so 1000 of + // 1000 cannot be told apart from 1000 of 50,000. + if (total > 0 && total < AUTH0_PAGINATION_CEILING && all.length >= total) break; - if (all.length >= AUTH0_PAGINATION_CEILING) { - // Auth0 reports a larger tenant's total as 1000 too, so reaching the - // ceiling never proves the export is complete. + // The next page would start at the offset this endpoint won't serve. + if ((page + 1) * PAGE_SIZE >= AUTH0_PAGINATION_CEILING) { log.warn( `Auth0 only pages through the first ${AUTH0_PAGINATION_CEILING} users on this endpoint` + (total > AUTH0_PAGINATION_CEILING @@ -284,12 +320,15 @@ export async function fetchAllAuth0Users(options: { ); return { users: all, truncated: true }; } - - // Auth0 can send a short page before the last one, so `total` decides - // when it is known; a short page ends it only when Auth0 sent no total. - if (total > 0 ? all.length >= total : users.length < PAGE_SIZE) break; } + // Short of what Auth0 counted: some users were never returned. + if (total > 0 && all.length < Math.min(total, AUTH0_PAGINATION_CEILING)) { + log.warn( + `Auth0 reported ${total} users but returned ${all.length}. Exported what was returned; use Auth0's bulk user export job for the rest.`, + ); + return { users: all, truncated: true }; + } return { users: all, truncated: false }; } diff --git a/packages/cli-core/src/commands/migrate/export/shared.test.ts b/packages/cli-core/src/commands/migrate/export/shared.test.ts index 3e6cc04ba..7a779311d 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.test.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.test.ts @@ -163,6 +163,18 @@ describe("finishExport", () => { expect(readEnvelope(outputPath)?.firebase).toEqual(firebase); }); + // `truncated: true` alone is easy for an agent to miss. + test("a truncated export exits 1, with its file still written", async () => { + const run = await startExportRun({ runsDir }, { platform: "auth0" }); + try { + const { outputPath } = finishExport({ run, options: {}, users, coverage, truncated: true }); + expect(process.exitCode).toBe(1); + expect(fs.existsSync(outputPath)).toBe(true); + } finally { + process.exitCode = 0; + } + }); + test("prints the import command by run ID", async () => { const run = await startExportRun({ runsDir }, { platform: "clerk" }); diff --git a/packages/cli-core/src/commands/migrate/export/shared.ts b/packages/cli-core/src/commands/migrate/export/shared.ts index 7f1b17246..7bdc9b1ea 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.ts @@ -177,6 +177,9 @@ export function finishExport(input: FinishExportInput): FinishedExport { const carry = carryFlags({ runsDir: options.runsDir }); const next = withFlags(`clerk migrate import ${record.id}`, carry); const skipped = record.counts.skipped ?? 0; + // Partial, like an import with failures: the file holds what came back, and + // the exit says it is not everyone, where `truncated` alone is easy to miss. + if (input.truncated) process.exitCode = 1; if (options.json) { log.data( From d4444abc8064dc71ca8223de8d931cf211af1f51 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 19:00:25 -0400 Subject: [PATCH 12/16] fix(migrate): ask again for Auth0 credentials whose token lacks read:users, and name the tenant domain Auth0 issues a token to an application without read:users, and only the user listing refused it, outside the credential retry, so it exited 2 instead of asking again. The token lists the scopes it was granted, so one without read:users is now refused at the exchange. A response without a scope is left to the listing as before. A custom domain gets an audience Auth0 rejects, which reads as bad credentials. The domain hint, the rejection, --domain's help and the README now name the tenant's *.auth0.com domain, not a custom one. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 27 ++++++++++--------- .../src/commands/migrate/export/auth0.test.ts | 17 ++++++++++++ .../src/commands/migrate/export/auth0.ts | 18 +++++++++++-- .../src/commands/migrate/export/index.ts | 5 +++- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 60aa476fb..025ab7a55 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -272,17 +272,17 @@ 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` | -| `--client-id ` | `auth0` | Machine-to-machine application client ID | -| `--client-secret ` | `auth0` | Machine-to-machine application client secret | +| 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 | `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 @@ -512,8 +512,9 @@ a free or development tenant allows only a few Management API requests a second. A credential the platform rejects (400, 401 or 403) is asked for again at a -terminal. An outage, a refused connection, or a `429` that outlasts its -retries is not: another credential would not fix it, and it exits 1. +terminal, and so is an Auth0 token granted without `read:users`. An outage, a +refused connection, or a `429` that outlasts its retries is not: another +credential would not fix it, and it exits 1. ### `clerk migrate import` diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index 76c148d8c..4c220a02d 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -234,6 +234,23 @@ describe("fetchAuth0Token", () => { await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(/read:users/); }); + test("refuses a token granted without read:users, so the credentials are asked for again", async () => { + stubAuth0([[]], Response.json({ access_token: "tok", scope: "read:clients" })); + const error = (await fetchAuth0Token(CREDENTIALS).catch((e: unknown) => e)) as CliError; + expect(error.message).toContain("without the `read:users` scope"); + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + }); + + test("takes a token whose scope includes read:users", async () => { + stubAuth0([[]], Response.json({ access_token: "tok", scope: "read:clients read:users" })); + expect(await fetchAuth0Token(CREDENTIALS)).toBe("tok"); + }); + + test("names the tenant's own domain when the credentials are rejected", async () => { + stubAuth0([[]], new Response("{}", { status: 401 })); + await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(/not a custom domain/); + }); + test("fails when a 200 carries no token", async () => { stubAuth0([[]], Response.json({})); await expect(fetchAuth0Token(CREDENTIALS)).rejects.toThrow(CliError); diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index ba0aed80d..7cc2ae08a 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -73,7 +73,10 @@ export function isAuth0Domain(domain: string): boolean { return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(normalizeAuth0Domain(domain)); } -const DOMAIN_HINT = "Pass just the tenant's host name, e.g. my-tenant.us.auth0.com."; +/** The Management API answers only on the tenant's own domain, not a custom one. */ +const AUTH0_HOST = "the tenant's *.auth0.com domain, not a custom domain"; + +const DOMAIN_HINT = `Pass just the host name of ${AUTH0_HOST}, e.g. my-tenant.us.auth0.com.`; /** * Resolves the tenant credentials: flags, then environment, then a prompt. @@ -195,6 +198,7 @@ export async function fetchAuth0Token(credentials: Auth0Credentials): Promise ({}))) as { access_token?: string; + scope?: string; error_description?: string; error?: string; }; @@ -210,7 +214,17 @@ export async function fetchAuth0Token(credentials: Auth0Credentials): Promise", "Auth0 tenant domain, e.g. my-tenant.us.auth0.com") + .option( + "--domain ", + "Auth0 tenant domain, e.g. my-tenant.us.auth0.com, not a custom domain", + ) .option("--client-id ", "Machine-to-machine application client ID") .option("--client-secret ", "Machine-to-machine application client secret") .option("-o, --output ", "Write the export here instead of the run folder") From d108e41a3158c51b01630831b09a86aeaf3c57e2 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 19:33:24 -0400 Subject: [PATCH 13/16] docs(migrate): say the Clerk and Auth0 exports warn about passwords when they export anyone The Auth0 header comment said the coverage report flags the missing passwords, but Auth0's coverage has no password row: it is a warning after the export, and the API gives no way to count who has a password. The README said both warn on every run, but neither warns when it exports nobody. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 4 ++-- packages/cli-core/src/commands/migrate/export/auth0.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 025ab7a55..c155f50d0 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -365,8 +365,8 @@ alongside imports. only from a support request. Add a `passwordHash` field to each user before importing, or migrate without passwords. -Both say so on every run. `clerk`'s coverage also counts users who _have_ a -password, so the size of the gap is visible up front. +Both print a warning whenever they export anyone. `clerk`'s coverage also +counts users who _have_ a password, so the size of the gap is visible up front. #### `supabase` reads the database diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index 7cc2ae08a..a08b6f968 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -10,8 +10,9 @@ * inside the compiled binary. * * **Passwords do not come out of the Management API.** Auth0 exports password - * hashes only via a support request. The coverage report says so rather than - * leaving it to be discovered when nobody can sign in. + * hashes only via a support request, and the API gives no way to count who has + * one. A warning after the export says so rather than leaving it to be + * discovered when nobody can sign in. */ import { ApiError, CliError, throwUsageError } from "../../../lib/errors.ts"; From a85ec352528deb66501228eaa426191873afa83b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 16:16:25 -0400 Subject: [PATCH 14/16] fix(migrate): let a blank Auth0 flag fall back to its environment variable resolveAuth0Credentials took a whitespace-only --domain, --client-id or --client-secret over a set AUTH0_* variable, and sent it as is. Each value is now trimmed before the fallback, as the WorkOS key is. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/export/auth0.test.ts | 8 ++++++++ packages/cli-core/src/commands/migrate/export/auth0.ts | 9 ++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/auth0.test.ts b/packages/cli-core/src/commands/migrate/export/auth0.test.ts index 4c220a02d..38476dc7a 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.test.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -137,6 +137,14 @@ describe("resolveAuth0Credentials", () => { expect(resolved.domain).toBe("flag.auth0.com"); }); + test("a blank flag falls back to the environment, trimmed", async () => { + const resolved = await resolveAuth0Credentials( + { domain: " ", clientId: " ", clientSecret: "" }, + { AUTH0_DOMAIN: "env.auth0.com ", AUTH0_CLIENT_ID: "c", AUTH0_CLIENT_SECRET: " s" }, + ); + expect(resolved).toMatchObject({ domain: "env.auth0.com", clientId: "c", clientSecret: "s" }); + }); + test("falls back to the environment", async () => { const resolved = await resolveAuth0Credentials( {}, diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index a08b6f968..39a4ad78f 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -89,10 +89,13 @@ export async function resolveAuth0Credentials( options: ExportAuth0Options, env: Record = process.env, ): Promise { + // Trimmed before the fallback: a blank flag must not hide a set variable. + const pick = (flag: string | undefined, variable: string | undefined) => + flag?.trim() || variable?.trim(); const resolved = { - domain: options.domain ?? env.AUTH0_DOMAIN, - clientId: options.clientId ?? env.AUTH0_CLIENT_ID, - clientSecret: options.clientSecret ?? env.AUTH0_CLIENT_SECRET, + domain: pick(options.domain, env.AUTH0_DOMAIN), + clientId: pick(options.clientId, env.AUTH0_CLIENT_ID), + clientSecret: pick(options.clientSecret, env.AUTH0_CLIENT_SECRET), }; if (resolved.domain && !isAuth0Domain(resolved.domain)) { From 7ea4d1123a27c3d9e0962cb468242dbf324f5286 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 17:53:08 -0400 Subject: [PATCH 15/16] docs(migrate): keep the Auth0 secret out of the example, and narrow the missing-scope retry The example now leaves --client-secret to the masked prompt, so the secret stays out of shell history. Only a token response that lists scopes without read:users is asked for again; one that lists none fails on the first users request. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index c155f50d0..a089a442e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -215,7 +215,7 @@ Gets users **out** of a source platform, so there is something to feed clerk migrate export # pick a platform clerk migrate export clerk --output users.json clerk migrate export auth0 --domain my-tenant.us.auth0.com \ - --client-id … --client-secret … + --client-id … # asks for the secret, masked clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" clerk migrate export firebase --service-account ./service-account.json ``` @@ -512,9 +512,11 @@ a free or development tenant allows only a few Management API requests a second. A credential the platform rejects (400, 401 or 403) is asked for again at a -terminal, and so is an Auth0 token granted without `read:users`. An outage, a -refused connection, or a `429` that outlasts its retries is not: another -credential would not fix it, and it exits 1. +terminal, and so is an Auth0 token whose granted scopes leave out `read:users`. +When Auth0's token response lists no scopes, the first users request finds the +missing permission instead, and exits. An outage, a refused connection, or a +`429` that outlasts its retries is not asked for again: another credential would +not fix it, and it exits 1. ### `clerk migrate import` From 7382de0f2df90e34cc4572169eb7b8429be9ea0f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 19:00:19 -0400 Subject: [PATCH 16/16] docs(migrate): keep the Auth0 secret out of the export help examples Both examples now leave --client-secret to the masked prompt or AUTH0_CLIENT_SECRET, so following them keeps the secret out of shell history and process arguments, as the README's example does. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/export/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index 2850715ef..34550a924 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -78,9 +78,8 @@ export function registerMigrateExport(migrateCommand: Command<[], Record