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 4bc3b5016..32d58ec8e 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,18 @@ 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. That file has no `user_id`: a user's ID is + `auth0|` plus `alt_id`, or `_id` when there's no `alt_id`. Copy each + `passwordHash` onto the matching 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 +491,38 @@ 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 run finishes `partial`, and +importing it by run ID warns that it is not every user. The bulk job downloads gzipped +NDJSON: unzip it before importing. Ask the job for `user_metadata`, +`app_metadata` and `blocked` by name, since a job with no `fields` leaves +them out. + +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 @@ -932,6 +977,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 | @@ -941,13 +987,17 @@ There is no column for social sign-ins, because no source copies them and none needs to. Enable the same providers in Clerk, and a user who signs in with one is linked to their imported account by verified email. See [account linking](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/account-linking). +The import's checks count the providers in a Supabase or Auth0 file and offer +to enable any that are off. Auth0's come from each `user_id`'s prefix +(`github|…`), so a user who linked a second provider counts under the first. ### 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 @@ -957,7 +1007,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 @@ -1092,10 +1142,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..2230e2ccb --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/auth0.test.ts @@ -0,0 +1,580 @@ +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"]); + }); + + // Import splits a plain `name`, so coverage counts it the same way, and + // drops the email Auth0 fills `name` with for database users. + test("counts the names import would split from `name`", () => { + const bare = { given_name: undefined, family_name: undefined }; + const { coverage } = buildAuth0Export([ + auth0User(0, { ...bare, name: "Ada Abbott" }), + auth0User(1, { ...bare, name: "Grace" }), + auth0User(2, { ...bare, name: "a2@x.dev" }), + ]); + + const byLabel = Object.fromEntries(coverage.map((c) => [c.label, c.count])); + expect(byLabel["have a first name"]).toBe(2); + expect(byLabel["have a last name"]).toBe(1); + }); +}); + +/** 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..95841dadc --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -0,0 +1,481 @@ +/** + * `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 { splitName } from "../sources/shared.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++; + // As the import reads them: given_name/family_name, else `name` split. + // The email goes along so splitName drops a `name` that is just it. + const names: Record = { + email: mapped.email, + firstName: mapped.given_name, + lastName: mapped.family_name, + }; + if (!names.firstName && !names.lastName) + splitName(Object.assign(names, { name: mapped.name })); + if (names.firstName) counts.firstName++; + if (names.lastName) 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..fbe57ebec 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.test.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.test.ts @@ -49,6 +49,16 @@ describe("finishExport", () => { expect(latestUserLines(runsDir, record.id).get("u1")?.status).toBe("exported"); }); + // Exit code and --json are gone by the time someone imports the run. + test("records a truncated export, and finishes it partial", async () => { + const run = await startExportRun({ runsDir }, { platform: "auth0" }); + run.append({ sourceId: "u1", status: "exported" }); + + const { record } = finishExport({ run, options: {}, users, coverage, truncated: true }); + + expect(readRun(runsDir, record.id)).toMatchObject({ status: "partial", truncated: true }); + }); + // The file holds password hashes and PII, so no other local account reads it. test("writes the file owner-only, in an owner-only run folder", async () => { const run = await startExportRun({ runsDir }, { platform: "supabase" }); @@ -136,7 +146,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 +173,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..0a9244729 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.ts @@ -170,13 +170,19 @@ export function finishExport(input: FinishExportInput): FinishedExport { `Nothing was written. Run the export again${options.output ? " with another --output" : ""}.`, ); } - run.update({ file: { path: outputPath, sha256: sha256File(outputPath) } }); + run.update({ + file: { path: outputPath, sha256: sha256File(outputPath) }, + ...(input.truncated ? { truncated: true } : {}), + }); const record = run.finish(); // Only the runs folder: the export's --secret-key, --app and --instance name // the source, and would aim the import at it. 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/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 9c8d697e4..690fd756a 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -446,6 +446,24 @@ describe("rejects", () => { }); // Each reject names that user's providers, not every disabled one in the file. + // Counts only: Auth0 users are never rejected for a provider, but the run + // still says which providers to enable before they import. + test("a source's own provider reading offers the fix, and rejects no one", async () => { + const checks = await checkImport( + input({ + settings: settings( + { email_address: { enabled: true } }, + { oauth_google: { enabled: true } }, + ), + socialProviders: (u) => (u.userId.startsWith("github|") ? ["github"] : ["google"]), + users: [user("github|1"), user("google-oauth2|2")], + }), + ); + expect(checks.rejects).toEqual([]); + expect(checks.fixes.map((fix) => fix.label)).toContain("Enable GitHub sign-in"); + expect(checks.fixes.map((fix) => fix.label)).not.toContain("Enable Google sign-in"); + }); + test("a supabase reject names only that user's own providers", async () => { const rows = [ { id: "discord", raw_app_meta_data: { providers: ["discord"] } }, diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 9f3c1c551..f910cffa4 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -99,6 +99,8 @@ export type CheckInput = { unknownFields?: Record; /** The raw Supabase rows, for the provider checks. Absent for any other source. */ supabaseRows?: Record[]; + /** Another source's reading of each user's social providers, for the readiness check. */ + socialProviders?: (user: User) => string[]; settings: UserSettingsJSON | null; /** Users already in the instance, for the quota. Development instances only. */ existingUsers?: number | null; @@ -737,6 +739,18 @@ function rowsFor(input: CheckInput, users: User[]): Record[] { return input.supabaseRows.filter((row) => ids.has(String(row.id))); } +/** Users per social provider, from Supabase's rows or the source's own reading. */ +function socialCounts(input: CheckInput, users: User[]): Record { + if (input.supabaseRows) return countSocialProviders(rowsFor(input, users)); + const counts: Record = {}; + for (const user of users) { + for (const provider of input.socialProviders?.(user) ?? []) { + counts[provider] = (counts[provider] ?? 0) + 1; + } + } + return counts; +} + /** Each readiness row's field, with its article: "an email", not "a email". */ const FIELD_NOUNS: Record = { email_address: "an email", @@ -753,7 +767,7 @@ function buildWarnings(input: CheckInput, importable: User[]): string[] { const report = buildReadinessReport({ analysis: analyzeFields(importable), settings: input.settings, - providerCounts: countSocialProviders(rowsFor(input, importable)), + providerCounts: socialCounts(input, importable), }); for (const item of report.blocking) { if (item.consequence === "stored") { @@ -833,7 +847,7 @@ function buildFixes(input: CheckInput, users: User[]): Fix[] { const report = buildReadinessReport({ analysis: analyzeFields(users), settings: input.settings, - providerCounts: countSocialProviders(rowsFor(input, users)), + providerCounts: socialCounts(input, users), }); // An unverified email does not satisfy a required one, so a file of only 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/lib/run-store.ts b/packages/cli-core/src/commands/migrate/lib/run-store.ts index 01c46a8c3..8a4945656 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -95,6 +95,11 @@ export type RunRecord = { * with others is refused. */ firebaseHash?: string; + /** + * An export the source cut short (Auth0's 1000-user ceiling): its file is + * not everyone, so it finishes `partial` and importing it says so. + */ + truncated?: boolean; /** The import run an undo reverses. */ undoes?: string; /** The undo run that reversed this one. */ @@ -515,7 +520,7 @@ function openRun(runsDir: string, record: RunRecord): Run { (counts.pending ?? 0); run.update({ counts, - status: unfinished > 0 ? "partial" : "complete", + status: unfinished > 0 || run.record.truncated ? "partial" : "complete", finishedAt: new Date().toISOString(), }); run.release(); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 73ecb0cc7..1e7ede25c 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -403,6 +403,16 @@ describe("run", () => { await expect(run({ ...noSource, input: record.id })).rejects.toThrow(/is gone/); }); + test("warns that a truncated export run is not every user", async () => { + const { record } = exportRun("clerk", export2); + patchRun(runsDir(), record.id, { truncated: true }); + + await run({ ...noSource, input: record.id }); + + expect(captured.err).toContain(`Export run ${record.id} did not get every user`); + expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(2); + }); + test("names an --output export file outside the run folder for deletion too", async () => { const { record, file } = exportRun("clerk", export2); const outside = path.join(workDir, `users-${record.id}.json`); @@ -561,8 +571,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 +2018,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 +2176,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..05c333607 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -101,7 +101,7 @@ import { loadUsersFromFile, resolveImportFilePath, } from "./lib/transform.ts"; -import { resolveSource, sourceKeys } from "./sources/registry.ts"; +import { getSource, resolveSource, sourceKeys } from "./sources/registry.ts"; import type { ImportSummary, User } from "./types.ts"; import { promptForFile, promptForFirebaseHashConfig, promptForSource } from "./wizard.ts"; import { login } from "../auth/login.ts"; @@ -345,6 +345,12 @@ async function resolveInput(options: MigrateRunOptions): Promise<{ if (sha256File(file) !== sha256 || readEnvelope(file)?.runId !== record.id) { throwChangedExport(value, file); } + if (record.truncated) { + log.warn( + `Export run ${value} did not get every user: the source cut it short. ` + + "Users it never reached are not in this import.", + ); + } // Carried on, so the file is checked again where the users are read: another // export can still overwrite the path after this. return { @@ -385,7 +391,7 @@ async function applySource(options: MigrateRunOptions): Promise { failures, unknownFields: loaded.unknownFields, ...(supabaseRows ? { supabaseRows } : {}), + socialProviders: getSource(source).socialProviders, settings, existingUsers, instanceType: limits.instanceType, diff --git a/packages/cli-core/src/commands/migrate/sources/auth0.ts b/packages/cli-core/src/commands/migrate/sources/auth0.ts new file mode 100644 index 000000000..f1cb0d9b2 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/sources/auth0.ts @@ -0,0 +1,102 @@ +import type { SourceEntry } from "../types.ts"; +import { isVerified, routeByVerification, splitName } from "./shared.ts"; + +/** + * Auth0 → Clerk transformer. + * + * Works with Auth0's Export Users API. `user_id` is a `provider|id` string + * (`auth0|abc123`, `github|12345`) and is carried through as the Clerk user's + * `external_id`. + * + * Auth0 does not include password hashes in a standard export — they have to + * be requested from Auth0 support. When present they are bcrypt (`$2a$`/`$2b$`, + * 10 rounds), which is why `passwordHasher` defaults to `bcrypt`. + */ +/** Auth0 social connections named differently from Clerk's provider key. */ +const AUTH0_PROVIDER_KEYS: Record = { + "google-oauth2": "google", + windowslive: "microsoft", + linkedin: "linkedin_oidc", +}; + +/** + * `user_id` prefixes that are not social sign-in: the database, passwordless, + * enterprise connections, and custom `oauth2` ones Clerk cannot match by name. + */ +const AUTH0_NOT_SOCIAL = new Set([ + "auth0", + "email", + "sms", + "samlp", + "waad", + "adfs", + "ad", + "oidc", + "okta", + "pingfederate", + "google-apps", + "oauth2", +]); + +const auth0Source = { + key: "auth0", + label: "Auth0", + description: + "Works with Auth0's Export Users API. Password hashes require a support request to Auth0.", + carries: { + passwords: { + level: "partial", + note: "Auth0 releases bcrypt hashes only through a support request. Add each as `passwordHash` before importing.", + }, + mfa: { level: "no", note: "Auth0 exports no MFA enrolments. Users enrol again in Clerk." }, + metadata: { + level: "yes", + note: "`user_metadata` → unsafe metadata, which users can edit, as in Auth0. `app_metadata` → private metadata.", + }, + }, + transformer: { + user_id: "userId", + email: "email", + email_verified: "emailVerified", + username: "username", + name: "name", + blocked: "banned", + given_name: "firstName", + family_name: "lastName", + phone_number: "phone", + phone_verified: "phoneVerified", + passwordHash: "password", + user_metadata: "unsafeMetadata", + app_metadata: "privateMetadata", + created_at: "createdAt", + }, + postTransform: (user) => { + 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; + }, + + // The prefix names the connection the user signed up with. A user who later + // linked another provider is counted only under the first. + socialProviders: (user) => { + const separator = user.userId.indexOf("|"); + if (separator < 1) return []; + const connection = user.userId.slice(0, separator); + if (AUTH0_NOT_SOCIAL.has(connection)) return []; + return [AUTH0_PROVIDER_KEYS[connection] ?? connection]; + }, + + 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 575dd3407..c32a407c9 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { CliError } from "../../../lib/errors.ts"; import { loadUsersFromFile, transformUsers } from "../lib/transform.ts"; -import type { FirebaseHashConfig } from "../types.ts"; +import type { FirebaseHashConfig, User } from "../types.ts"; import { getSource, sourceKeys, sources } from "./registry.ts"; import { isVerified } from "./shared.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 }; @@ -546,9 +626,28 @@ describe("fields the CLI's own export adds", () => { }); }); +describe("auth0 social providers", () => { + test.each([ + ["github|1", ["github"]], + ["google-oauth2|1", ["google"]], + ["windowslive|1", ["microsoft"]], + ["linkedin|1", ["linkedin_oidc"]], + ["amazon|1", ["amazon"]], + ["auth0|1", []], + ["samlp|acme|1", []], + ["email|1", []], + ["plain-id", []], + ])("%s signs in with %p", (userId, expected) => { + expect(getSource("auth0").socialProviders?.({ userId } as User)).toEqual(expected); + }); +}); + describe("invalid records", () => { // Firebase skips such a user instead; see "skips a user with no email or phone". - const INVALID: [string, Record][] = [["supabase", { id: "a5" }]]; + const INVALID: [string, Record][] = [ + ["auth0", { user_id: "a1" }], + ["supabase", { id: "a5" }], + ]; test.each(INVALID)( "%s reports a user with no identifier instead of crashing", @@ -568,6 +667,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/types.ts b/packages/cli-core/src/commands/migrate/types.ts index 74527c367..eaf3746bb 100644 --- a/packages/cli-core/src/commands/migrate/types.ts +++ b/packages/cli-core/src/commands/migrate/types.ts @@ -130,6 +130,9 @@ export type SourceCarries = { passwords: Carry; mfa: Carry; metadata: Carry }; * @property preTransform - Runs before field mapping. * @property postTransform - Mutates a user after field mapping, given the * run's {@link TransformContext}. + * @property socialProviders - The social providers a user signs in with, as + * Clerk provider keys (`google`, `github`), for the readiness check. + * Supabase's come from its raw rows instead. */ export type SourceEntry = { key: string; @@ -144,4 +147,5 @@ export type SourceEntry = { fileType: string, ) => PreTransformResult | Promise; postTransform?: (user: Record, context: TransformContext) => void; + socialProviders?: (user: User) => string[]; }; 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;