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`. diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index bb0b165cf..a089a442e 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 … # 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 ``` 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` | @@ -269,14 +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 | +| 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 @@ -350,11 +356,16 @@ 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 +- **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 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 @@ -478,6 +489,35 @@ 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". 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, 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` Reads an exported user file, maps it onto Clerk's user schema, checks every @@ -931,6 +971,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 Management API | partial | no | yes | | `firebase` | `firebase auth:export` | yes | no | no | | `supabase` | Supabase `auth.users` export | yes | no | partial | @@ -943,10 +984,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 +998,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 +1133,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..38476dc7a --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -0,0 +1,565 @@ +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 { setAssumeYes } from "../lib/assume-yes.ts"; +import { + buildAuth0Export, + exportAuth0, + fetchAllAuth0Users, + fetchAuth0Token, + mapAuth0UserToExport, + isAuth0Domain, + 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); + }); +}); + +// 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" }, + { AUTH0_DOMAIN: "env.auth0.com" }, + ); + 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( + {}, + { + 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"); + }); + + // `-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( + /--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("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/); + }); + + 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); + }); +}); + +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); + }); + + // 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)), + Array.from({ length: 30 }, (_, i) => auth0User(150 + i)), + ]); + + const { users: all, truncated } = await fetchAllAuth0Users({ + credentials: CREDENTIALS, + token: "tok", + }); + + 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 () => { + 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" }); + 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 }, (_, page) => + Array.from({ length: 100 }, (_, i) => auth0User(page * 100 + 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"); + }); + + // 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 }, (_, page) => + Array.from({ length: 100 }, (_, i) => auth0User(page * 100 + i)), + ), + ); + + const { users: all, truncated } = await fetchAllAuth0Users({ + credentials: CREDENTIALS, + token: "tok", + }); + + expect(all).toHaveLength(1000); + expect(truncated).toBe(true); + expect(captured.err).toContain("so there may be more"); + }); + + 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; + + 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..39a4ad78f --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -0,0 +1,471 @@ +/** + * `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, 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"; +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 { 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; + +/** + * 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(/\/+$/, ""); +} + +/** + * 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)); +} + +/** 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. + * + * @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 { + // 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: 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)) { + throwUsageError(`"${resolved.domain}" is not an Auth0 domain. ${DOMAIN_HINT}`, DOCS_URL); + } + + 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, + }; + } + + // `-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(", ")}.`, + 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) => { + if (!value?.trim()) return "A domain is required"; + return isAuth0Domain(value) ? undefined : DOMAIN_HINT; + }, + })); + 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", + // A followed 307/308 would resend the client secret to wherever it points. + // Unfollowed, a 3xx is just a failed response. + redirect: "manual", + 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; + scope?: 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 (${AUTH0_HOST}), client ID and secret, and that the application is authorized for the Management API with the \`read:users\` scope.`, + DOCS_URL, + ); + } + // Auth0 issues a token without the scope, and only the user listing then + // refuses it, outside the credential retry. The token lists what it was + // granted, so a missing scope is refused here, where it can be asked again. + // A response without `scope` is left to the listing. + if (body.scope !== undefined && !body.scope.split(" ").includes("read:users")) { + throwUsageError( + "Auth0 issued a token without the `read:users` scope. Grant it to the application under Applications → APIs → Auth0 Management API → Machine to Machine Applications.", + 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"); + // 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", + method: "GET", + redirect: "manual", + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }); + + 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}`, + DOCS_URL, + ); + } + + const body = (await response.json()) as { users?: Auth0User[]; total?: number }; + 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" — and says so + * too when fewer users come back than Auth0 counted. + */ +export async function fetchAllAuth0Users(options: { + credentials: Auth0Credentials; + token: string; + spinner?: SpinnerControls; +}): Promise<{ users: Auth0User[]; truncated: boolean }> { + const all: Auth0User[] = []; + const seen = new Set(); + let total = 0; + + for (let page = 0; ; page++) { + 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...`); + + // 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; + + // 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 + ? `, 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 }; + } + } + + // 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 }; +} + +/** + * 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)); + }, + options, + ); + + 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..34550a924 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,8 @@ 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, 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") + .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 …", + description: + "Export with the secret entered at a masked prompt, or from AUTH0_CLIENT_SECRET", + }, + { + 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..7a779311d 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, @@ -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( 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;