diff --git a/docs/auth0_universal-portals.md b/docs/auth0_universal-portals.md index ab905bfbd..e0879e43a 100644 --- a/docs/auth0_universal-portals.md +++ b/docs/auth0_universal-portals.md @@ -5,9 +5,13 @@ has_children: true --- # auth0 universal-portals -Manage Auth0 Universal Portals resources. +Set up and audit Auth0 Universal Portals. + +These commands run a tool that is built into the CLI. It needs Node.js 20.12 or newer, and it calls the +Auth0 Management API as the tenant you are logged in to. ## Commands -- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/auth0_universal-portals_audit.md b/docs/auth0_universal-portals_audit.md new file mode 100644 index 000000000..ee4e50dc2 --- /dev/null +++ b/docs/auth0_universal-portals_audit.md @@ -0,0 +1,43 @@ +--- +layout: default +parent: auth0 universal-portals +has_toc: false +--- +# auth0 universal-portals audit + +Checks a Universal Portal and its application against the setup defaults, and fixes the errors the tool is able to fix. + +All flags are handled by the tool. Run "auth0 universal-portals audit --help" for the list. + +## Usage +``` +auth0 universal-portals audit [flags] +``` + +## Examples + +``` + auth0 universal-portals audit up_123 + auth0 universal-portals audit up_123 --fix + auth0 up audit up_123 --organization org_123 --strict --json +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal + + diff --git a/docs/auth0_universal-portals_setup.md b/docs/auth0_universal-portals_setup.md index 23c70fdf6..0c4e0724c 100644 --- a/docs/auth0_universal-portals_setup.md +++ b/docs/auth0_universal-portals_setup.md @@ -5,11 +5,9 @@ has_toc: false --- # auth0 universal-portals setup -Provisions the Auth0 resources required by a Universal Portals application: +Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs. - - Auth0 My Account API and My Organization API (resource servers) - - A Regular Web App client with the required configuration - - Client grants for My Account API, My Organization API, and the Management API +All flags are handled by the tool. Run "auth0 universal-portals setup --help" for the list. ## Usage ``` @@ -20,17 +18,12 @@ auth0 universal-portals setup [flags] ``` auth0 universal-portals setup - auth0 universal-portals setup --name "Acme" --slug "acme" - auth0 up setup -n "Acme" -s "acme" + auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 up setup --dry-run --template consumer --name "Acme Account" --json + auth0 up setup --no-input --template consumer --name "Acme Account" --json ``` -## Flags - -``` - -n, --name string Display name of the portal. - -s, --slug string URL-friendly identifier for the portal (e.g. my-portal). -``` ## Inherited Flags @@ -45,6 +38,7 @@ auth0 universal-portals setup [flags] ## Related Commands -- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/index.md b/docs/index.md index 29ca5e41d..7a47f03d6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,6 +105,6 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead - [auth0 test](auth0_test.md) - Try your Universal Login box or get a token - [auth0 token-exchange](auth0_token-exchange.md) - Manage token exchange profiles - [auth0 universal-login](auth0_universal-login.md) - Manage the Universal Login experience -- [auth0 universal-portals](auth0_universal-portals.md) - Manage Universal Portals resources +- [auth0 universal-portals](auth0_universal-portals.md) - Set up and audit Universal Portals - [auth0 users](auth0_users.md) - Manage resources for users diff --git a/internal/cli/data/README.md b/internal/cli/data/README.md index 13eff4781..fe571a739 100644 --- a/internal/cli/data/README.md +++ b/internal/cli/data/README.md @@ -9,3 +9,11 @@ A series of JS files prefixed with `rule-template-`. Each file contains a single ### Action templates A series of JS files prefixed with `action-template-`, each containing an empty action for a given extensibility point. There's an empty action for each extensibility point. These are used as code templates for new actions created with `auth0 actions create`. + +### Universal Portals tool +The `universal-portals` folder holds the Universal Portals setup and audit tool. + +- `universal-portals/universal-portals.mjs` is the only file embedded into the binary. It is one JavaScript file that Node.js can run, generated from the sources next to it. Do not edit it by hand. +- `universal-portals/_source` is the TypeScript project the file is built from, with its own tests, `package.json` and lockfile. It is not embedded, and Go tooling skips it because of the leading underscore. + +To change the tool, edit `_source/src`, then run `npm test` and `npm run bundle` inside `_source`, and commit the sources together with the rebuilt `universal-portals.mjs`. A test fails when the file is older than its sources. diff --git a/internal/cli/data/universal-portals/_source/src/args.ts b/internal/cli/data/universal-portals/_source/src/args.ts index 1e2b706a4..df4eab241 100644 --- a/internal/cli/data/universal-portals/_source/src/args.ts +++ b/internal/cli/data/universal-portals/_source/src/args.ts @@ -24,6 +24,8 @@ export interface ParsedSetupArgs { organization?: string; json: boolean; noInput: boolean; + // Setup only: show what a run would create and change, without writing to the tenant. + dryRun?: boolean; } // Parses the arguments the plugin receives from the CLI. Interactive questions @@ -44,6 +46,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { organization: { type: "string", short: "o" }, json: { type: "boolean" }, "no-input": { type: "boolean" }, + "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" }, strict: { type: "boolean" }, fix: { type: "boolean" }, @@ -101,6 +104,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { organization: values.organization, json: Boolean(values.json), noInput: Boolean(values["no-input"]), + dryRun: Boolean(values["dry-run"]), }; } @@ -126,6 +130,7 @@ export function usage(): string { " -u, --user Business only: the user who will test the portal.", " -o, --organization Business only: the organization, when the user has several.", " --no-input Never prompt; fail on anything missing.", + " --dry-run Show what setup would create, without changing the tenant.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", "", diff --git a/internal/cli/data/universal-portals/_source/src/dry-run.ts b/internal/cli/data/universal-portals/_source/src/dry-run.ts new file mode 100644 index 000000000..c4deb1951 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/dry-run.ts @@ -0,0 +1,63 @@ +import type { ApiClient, HttpMethod } from "./api.js"; + +// A request a real run would send. Secrets in the body are replaced by CLIENT_SECRET_PLACEHOLDER. +export interface PlannedRequest { + method: Exclude; + path: string; + body?: unknown; +} + +export const CLIENT_SECRET_PLACEHOLDER = ""; + +// Sends every read to the tenant and records each other request instead of sending it. Setup runs +// as it does for real, so the created, reused and updated decisions and the payloads all come from +// the same code. The IDs of resources a real run would create are placeholders, and a read about one +// of them returns an empty list, because that resource does not exist yet. +export function planWrites(api: ApiClient): { api: ApiClient; requests: () => PlannedRequest[] } { + const requests: PlannedRequest[] = []; + const placeholders: string[] = []; + let counter = 0; + + return { + api: { + async request(method: HttpMethod, path: string, body?: unknown): Promise { + if (method === "GET") { + if (placeholders.some((id) => path.includes(encodeURIComponent(id)))) return [] as T; + return api.request(method, path, body); + } + + requests.push({ method: method as PlannedRequest["method"], path, body: redact(body) }); + if (method !== "POST") return {} as T; + + const id = ``; + placeholders.push(id); + return placeholderResponse(path, body, id) as T; + }, + }, + requests: () => JSON.parse(JSON.stringify(requests)) as PlannedRequest[], + }; +} + +// The part of a create response that setup reads back: the ID, and for clients the secret. +function placeholderResponse(path: string, body: unknown, id: string): unknown { + const fields = isRecord(body) ? body : {}; + if (path === "clients") return { ...fields, client_id: id, client_secret: CLIENT_SECRET_PLACEHOLDER }; + if (path === "forms/import") return { form: { id, nodes: [] }, flows: [] }; + return { ...fields, id }; +} + +// Copies the value and replaces every client secret, so a secret read from the tenant for a reused +// application can never reach the output. +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, key === "client_secret" ? CLIENT_SECRET_PLACEHOLDER : redact(item)]), + ); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup-command.ts b/internal/cli/data/universal-portals/_source/src/setup-command.ts index 901767a0b..8752f46c4 100644 --- a/internal/cli/data/universal-portals/_source/src/setup-command.ts +++ b/internal/cli/data/universal-portals/_source/src/setup-command.ts @@ -4,8 +4,9 @@ import { openBrowser } from "./browser.js"; import { CancelledError, type Prompter } from "./prompt.js"; import type { TemplateId } from "./templates/types.js"; import type { IoStreams } from "./cli.js"; +import { type PlannedRequest, planWrites } from "./dry-run.js"; import { reportError } from "./errors.js"; -import { runSetup, SetupOptions, SlugRaceError } from "./setup/run.js"; +import { runSetup, SetupOptions, SetupResult, SlugRaceError } from "./setup/run.js"; import { toPortalSlug } from "./slug.js"; import { trackWrites } from "./write-tracker.js"; import type { Ui } from "./ui.js"; @@ -28,19 +29,24 @@ export async function runSetupCommand( } } const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run })); + // A dry run reads the tenant as a real run does, but keeps the requests that would change it. + const planned = args.dryRun ? planWrites(tracked.api) : undefined; ui.banner("Universal Portals setup", context.tenantDomain); + if (planned) ui.info(DRY_RUN_NOTE); try { const options = prompter ? await askForMissing(args, prompter, context.tenantDomain) : optionsFromArgs(args); const result = await runSetup(options, { - api: tracked.api, + api: planned?.api ?? tracked.api, ui, prompt: prompter ?? unansweredPrompter(), interactive, tenantDomain: context.tenantDomain, + dryRun: planned !== undefined, }); + if (planned) return reportPlan(result, planned.requests(), args.json, io, ui); if (args.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return 0; @@ -65,6 +71,23 @@ export async function runSetupCommand( } } +// The statuses in a dry run are what a real run would report. Writes are not checked here, so +// the note says so: a real run is the only check of the permissions for creating things. +const DRY_RUN_NOTE = + "Dry run: nothing is created or changed. Statuses show what a real run would do. Write permissions are checked by a real run only."; + +// The result of a real run, plus the requests it would send in the order it would send them. The +// portal is not opened, because nothing was created. +function reportPlan(result: SetupResult, requests: PlannedRequest[], json: boolean, io: IoStreams, ui: Ui): number { + if (json) { + io.stdout.write(`${JSON.stringify({ dryRun: true, ...result, requests }, null, 2)}\n`); + return 0; + } + ui.result(`Dry run complete. ${requests.length} request(s) would change the tenant.`); + ui.detail("Run the same command without --dry-run to set up the portal."); + return 0; +} + // Enter opens the portal. "n" or Ctrl+C means no: the portal is already set up, // so cancelling this question is not a cancelled setup. async function declinesToOpen(prompter: Prompter): Promise { diff --git a/internal/cli/data/universal-portals/_source/src/setup/run.ts b/internal/cli/data/universal-portals/_source/src/setup/run.ts index bf04f720a..640678949 100644 --- a/internal/cli/data/universal-portals/_source/src/setup/run.ts +++ b/internal/cli/data/universal-portals/_source/src/setup/run.ts @@ -76,6 +76,8 @@ export interface SetupDeps { prompt: Prompter; interactive: boolean; tenantDomain: string; + // A dry run only reads, so the probes that send writes are skipped. Their scopes are not checked. + dryRun?: boolean; } export interface SetupResult { @@ -92,7 +94,10 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise< const resources: SetupResult["resources"] = []; const { ui, api, tenantDomain } = deps; - const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); + const probes = SETUP_PROBES.filter( + (probe) => + (!probe.businessOnly || options.template === "business") && (!deps.dryRun || probe.method === "GET"), + ); await checkPermissions(probes, deps); let slug = options.slug; diff --git a/internal/cli/data/universal-portals/_source/test/cli.test.ts b/internal/cli/data/universal-portals/_source/test/cli.test.ts index d5a58f415..aaf63c677 100644 --- a/internal/cli/data/universal-portals/_source/test/cli.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -82,6 +82,11 @@ test("parses the setup flags", () => { assert.equal(args.json, true); }); +test("--dry-run is a setup flag and is off unless it is given", () => { + assert.equal(parseSetupArgs(["setup", "--dry-run"]).dryRun, true); + assert.equal(parseSetupArgs(["setup"]).dryRun, false); +}); + test("rejects an unknown template and an unknown command", () => { assert.throws(() => parseSetupArgs(["setup", "--template", "both"]), UsageError); assert.throws(() => parseSetupArgs(["publish"]), /Unknown command "publish"/); @@ -381,6 +386,58 @@ test("a non-interactive consumer run prints the result as JSON and never the ful assert.ok(!stdout().includes(secret) && !stderr().includes(secret), "the full secret must not be printed"); }); +test("a dry run prints the plan as JSON and changes nothing in the tenant", async () => { + const tenant = new FakeTenant(); + const { io, stdout } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--dry-run", "--no-input", "--json", "--template", "consumer", "--name", "Acme Account"], + ENV, + io, + ); + + assert.equal(code, 0); + const plan = JSON.parse(stdout()); + assert.equal(plan.dryRun, true); + assert.equal(plan.portal.status, "created"); + assert.equal(plan.portal.url, `https://${DOMAIN}/portals/acme-account`); + assert.ok(plan.requests.some((request: { method: string; path: string }) => request.method === "POST" && request.path === "portals")); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []); + assert.equal(tenant.portals.size + tenant.clients.size, 0); +}); + +test("a dry run without --json writes its summary to stderr and nothing to stdout", async () => { + const tenant = new FakeTenant(); + const { io, stdout, stderr } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--dry-run", "--no-input", "--template", "consumer", "--name", "Acme Account"], ENV, io); + + assert.equal(code, 0); + assert.equal(stdout(), ""); + assert.match(stderr(), /Dry run complete/); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []); +}); + +test("a dry run with missing flags gives the same usage error as a real run", async () => { + const tenant = new FakeTenant(); + const { io, stderr } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--dry-run", "--no-input", "--template", "consumer"], ENV, io); + + assert.equal(code, 2); + assert.match(stderr(), /Missing required flags for a non-interactive run: --name/); + assert.equal(tenant.calls.length, 0); +}); + +test("a dry run without a login stops with the login error and reads nothing", async () => { + const tenant = new FakeTenant(); + const { io, stdout, stderr } = captureIo(fetchFor(tenant)); + io.run = async () => ({ exitCode: 1, stdout: "", stderr: "Error: failed to find tenant" }); + const code = await main(["setup", "--dry-run", "--json", "--template", "consumer", "--name", "Acme Account"], {}, io); + + assert.equal(code, 1); + assert.equal(stdout(), ""); + assert.equal(JSON.parse(stderr()).error, "auth0_login_required"); + assert.equal(tenant.calls.length, 0); +}); + test("in agent mode a missing scope is reported as a JSON error on stderr", async () => { const tenant = new FakeTenant(); tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); diff --git a/internal/cli/data/universal-portals/_source/test/run.test.ts b/internal/cli/data/universal-portals/_source/test/run.test.ts index 0803332d3..f0d0d37a4 100644 --- a/internal/cli/data/universal-portals/_source/test/run.test.ts +++ b/internal/cli/data/universal-portals/_source/test/run.test.ts @@ -7,6 +7,7 @@ import { BusinessAccessError } from "../src/setup/business-types.js"; import { MissingScopesError, runSetup, SetupDeps, SetupOptions } from "../src/setup/run.js"; import { FORMS_VAULT_NAME } from "../src/setup/vault.js"; import { FORMS_VAULT_SCOPES, MY_ACCOUNT_SCOPES, MY_ORGANIZATION_SCOPES } from "../src/setup/payload.js"; +import { CLIENT_SECRET_PLACEHOLDER, planWrites } from "../src/dry-run.js"; import { FakeTenant } from "./fake-tenant.js"; import { captureOutput } from "./capture-output.js"; @@ -394,3 +395,67 @@ test("leaving the organization empty puts a placeholder in the link", async () = assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-admin?organization=`); }); + +test("a dry run reports what a new consumer portal would create and sends no writes", async () => { + const tenant = new FakeTenant(); + const plan = planWrites(tenant); + const { deps } = harness(tenant, { api: plan.api, dryRun: true }); + const result = await runSetup(CONSUMER, deps); + + assert.equal(result.portal.status, "created"); + assert.equal(result.application.status, "created"); + assert.ok(result.resources.every((resource) => resource.status === "created")); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), [], "a dry run must not write"); + assert.equal(tenant.portals.size + tenant.clients.size + tenant.forms.size + tenant.vaults.size, 0); + + const requests = plan.requests().map((request) => `${request.method} ${request.path}`); + for (const expected of ["POST resource-servers", "POST clients", "POST client-grants", "POST forms/import", "POST portals"]) { + assert.ok(requests.includes(expected), `the plan includes ${expected}`); + } + assert.equal( + plan.requests().filter((request) => JSON.stringify(request.body) === "{}").length, + 0, + "the permission probes that send writes are not sent in a dry run", + ); +}); + +test("a dry run after setup reuses what exists and plans the portal with a placeholder secret", async () => { + const tenant = new FakeTenant(); + await runSetup(CONSUMER, harness(tenant).deps); + const secret = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Account"))?.client_secret; + assert.ok(secret); + tenant.portals.clear(); + const callsBefore = tenant.calls.length; + + const plan = planWrites(tenant); + const { deps, output } = harness(tenant, { api: plan.api, dryRun: true }); + const result = await runSetup(CONSUMER, deps); + + assert.equal(result.application.status, "reused"); + assert.equal(result.portal.status, "created"); + assert.deepEqual(tenant.calls.slice(callsBefore).filter((call) => !call.startsWith("GET ")), []); + + const planned = plan.requests(); + assert.ok(planned.some((request) => request.method === "PATCH" && request.path.startsWith("clients/"))); + const portalRequest = planned.find((request) => request.method === "POST" && request.path === "portals"); + assert.equal((portalRequest?.body as { client: { client_secret: string } }).client.client_secret, CLIENT_SECRET_PLACEHOLDER); + assert.ok(!JSON.stringify(planned).includes(secret), "the planned requests never carry the real secret"); + assert.ok(!output().includes(secret), "the output never shows the real secret"); +}); + +test("a dry run of a business portal plans its organization access and writes nothing", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: true, withRole: false }); + const plan = planWrites(tenant); + const { deps } = harness(tenant, { api: plan.api, dryRun: true }); + const result = await runSetup( + { ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin", userQuery: "jane@acme.com" }, + deps, + ); + + assert.equal(result.organization?.id, "org_1"); + assert.equal(result.organization?.hasRole, false); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), [], "a dry run must not write"); + assert.equal(tenant.portals.size, 0); + assert.ok(plan.requests().some((request) => request.path === "resource-servers")); +}); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index ae35bd3e5..8f941d5e8 100755 --- a/internal/cli/data/universal-portals/universal-portals.mjs +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generated by "npm run bundle" in the _source folder. Do not edit. -// source-sha256: 229a16a14dba7461826d1b828358f469e9f41e78b501a4dc5ffabb54f89513f9 +// source-sha256: 4db9c1862b6dc457ba8738cbeb2dbb9d5491b005a5f44ffa312c9d6421f09103 import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create; @@ -128,6 +128,7 @@ function parseSetupArgs(argv) { organization: { type: "string", short: "o" }, json: { type: "boolean" }, "no-input": { type: "boolean" }, + "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" }, strict: { type: "boolean" }, fix: { type: "boolean" } @@ -181,7 +182,8 @@ function parseSetupArgs(argv) { user: values.user, organization: values.organization, json: Boolean(values.json), - noInput: Boolean(values["no-input"]) + noInput: Boolean(values["no-input"]), + dryRun: Boolean(values["dry-run"]) }; } function missingFlags(args) { @@ -204,6 +206,7 @@ function usage() { " -u, --user Business only: the user who will test the portal.", " -o, --organization Business only: the organization, when the user has several.", " --no-input Never prompt; fail on anything missing.", + " --dry-run Show what setup would create, without changing the tenant.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", "", @@ -3304,6 +3307,48 @@ function openBrowser(url, platform = process.platform) { child.unref(); } +// src/dry-run.ts +var CLIENT_SECRET_PLACEHOLDER = ""; +function planWrites(api) { + const requests = []; + const placeholders = []; + let counter = 0; + return { + api: { + async request(method, path, body) { + if (method === "GET") { + if (placeholders.some((id2) => path.includes(encodeURIComponent(id2)))) return []; + return api.request(method, path, body); + } + requests.push({ method, path, body: redact(body) }); + if (method !== "POST") return {}; + const id = ``; + placeholders.push(id); + return placeholderResponse(path, body, id); + } + }, + requests: () => JSON.parse(JSON.stringify(requests)) + }; +} +function placeholderResponse(path, body, id) { + const fields = isRecord2(body) ? body : {}; + if (path === "clients") return { ...fields, client_id: id, client_secret: CLIENT_SECRET_PLACEHOLDER }; + if (path === "forms/import") return { form: { id, nodes: [] }, flows: [] }; + return { ...fields, id }; +} +function redact(value) { + if (Array.isArray(value)) return value.map(redact); + if (isRecord2(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, key === "client_secret" ? CLIENT_SECRET_PLACEHOLDER : redact(item)]) + ); + } + return value; +} +function isRecord2(value) { + return typeof value === "object" && value !== null; +} + // src/dashboard.ts function tenantLocation(tenantDomain) { const parts = tenantDomain.split("."); @@ -3902,7 +3947,9 @@ var MissingScopesError = class extends PluginError { async function runSetup(options, deps) { const resources = []; const { ui, api, tenantDomain } = deps; - const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); + const probes = SETUP_PROBES.filter( + (probe) => (!probe.businessOnly || options.template === "business") && (!deps.dryRun || probe.method === "GET") + ); await checkPermissions(probes, deps); let slug = options.slug; if (!isValidPortalSlug(slug)) throw new Error(`The slug "${slug}" must match ^[a-z][a-z0-9-]{0,99}$.`); @@ -4091,16 +4138,20 @@ async function runSetupCommand(args, env, io, ui, interactive, prompter) { } } const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run })); + const planned = args.dryRun ? planWrites(tracked.api) : void 0; ui.banner("Universal Portals setup", context.tenantDomain); + if (planned) ui.info(DRY_RUN_NOTE); try { const options = prompter ? await askForMissing(args, prompter, context.tenantDomain) : optionsFromArgs(args); const result = await runSetup(options, { - api: tracked.api, + api: planned?.api ?? tracked.api, ui, prompt: prompter ?? unansweredPrompter(), interactive, - tenantDomain: context.tenantDomain + tenantDomain: context.tenantDomain, + dryRun: planned !== void 0 }); + if (planned) return reportPlan(result, planned.requests(), args.json, io, ui); if (args.json) { io.stdout.write(`${JSON.stringify(result, null, 2)} `); @@ -4123,6 +4174,17 @@ async function runSetupCommand(args, env, io, ui, interactive, prompter) { return reportError(error, args.json, io, ui); } } +var DRY_RUN_NOTE = "Dry run: nothing is created or changed. Statuses show what a real run would do. Write permissions are checked by a real run only."; +function reportPlan(result, requests, json, io, ui) { + if (json) { + io.stdout.write(`${JSON.stringify({ dryRun: true, ...result, requests }, null, 2)} +`); + return 0; + } + ui.result(`Dry run complete. ${requests.length} request(s) would change the tenant.`); + ui.detail("Run the same command without --dry-run to set up the portal."); + return 0; +} async function declinesToOpen(prompter) { try { return !await prompter.confirm("Open the portal in your browser?", true); diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index f4c3ecfa4..ab0ebcf86 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -1,1048 +1,121 @@ package cli import ( - "context" - "encoding/json" - "errors" "fmt" - "net/http" - "strings" + "os" + "os/exec" - "github.com/auth0/go-auth0/management" - "github.com/pkg/browser" "github.com/spf13/cobra" - - "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth0" ) -// Scopes granted to the portal client per audience. -var ( - myAccountAPIScopes = []string{ - "read:me:authentication_methods", - "delete:me:authentication_methods", - "update:me:authentication_methods", - "read:me:factors", - "create:me:authentication_methods", - } - - myOrgAPIScopes = []string{ - "read:my_org:configuration", - "read:my_org:details", - "update:my_org:details", - } - - managementAPIScopes = []string{ - "read:branding", - "read:organizations_summary", - "read:organizations", - "update:users", - "update:users_app_metadata", - } - - portalGrantTypes = []string{ - "authorization_code", - "refresh_token", - "client_credentials", - "http://auth0.com/oauth/grant-type/mfa-oob", - "http://auth0.com/oauth/grant-type/mfa-otp", - "http://auth0.com/oauth/grant-type/mfa-recovery-code", - } -) - -var ( - upPortalName = Flag{ - Name: "Name", - LongForm: "name", - ShortForm: "n", - Help: "Display name of the portal.", - } - upPortalSlug = Flag{ - Name: "Slug", - LongForm: "slug", - ShortForm: "s", - Help: "URL-friendly identifier for the portal (e.g. my-portal).", - } -) +// Temporary: these commands run a Node.js tool built into the CLI, until the plugin framework can +// install it. When it can, remove universal_portals*.go, the data/universal-portals folder (and its +// section in data/README.md) and the universalPortalsCmd line in root.go, then run "make docs". +// The framework ignores a plugin named like a built-in command, so a test fails once plugins.go exists. -// universalPortalsCmd groups Universal Portals management commands. +// universalPortalsCmd groups the Universal Portals commands. func universalPortalsCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "universal-portals", Aliases: []string{"up"}, - Short: "Manage Universal Portals resources", - Long: "Manage Auth0 Universal Portals resources.", + Short: "Set up and audit Universal Portals", + Long: `Set up and audit Auth0 Universal Portals. + +These commands run a tool that is built into the CLI. It needs Node.js 20.12 or newer, and it calls the +Auth0 Management API as the tenant you are logged in to.`, } cmd.SetUsageTemplate(namespaceUsageTemplate()) - cmd.AddCommand(universalPortalsSetupCmd(cli)) + cmd.AddCommand( + universalPortalsToolCmd(cli, "setup", + "Set up a Universal Portal", + `Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs. + +All flags are handled by the tool. Run "auth0 universal-portals setup --help" for the list.`, + ` auth0 universal-portals setup + auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 up setup --dry-run --template consumer --name "Acme Account" --json + auth0 up setup --no-input --template consumer --name "Acme Account" --json`), + universalPortalsToolCmd(cli, "audit", + "Check a Universal Portal", + `Checks a Universal Portal and its application against the setup defaults, and fixes the errors the tool is able to fix. + +All flags are handled by the tool. Run "auth0 universal-portals audit --help" for the list.`, + ` auth0 universal-portals audit up_123 + auth0 universal-portals audit up_123 --fix + auth0 up audit up_123 --organization org_123 --strict --json`), + ) return cmd } -// universalPortalsSetupCmd provisions all Auth0 resources required by a Universal Portals application. -func universalPortalsSetupCmd(cli *cli) *cobra.Command { - var inputs struct { - PortalName string - Slug string - } - - cmd := &cobra.Command{ - Use: "setup", - Args: cobra.NoArgs, - Short: "Set up Auth0 resources for a Universal Portals application", - Long: `Provisions the Auth0 resources required by a Universal Portals application: - - - Auth0 My Account API and My Organization API (resource servers) - - A Regular Web App client with the required configuration - - Client grants for My Account API, My Organization API, and the Management API`, - Example: ` auth0 universal-portals setup - auth0 universal-portals setup --name "Acme" --slug "acme" - auth0 up setup -n "Acme" -s "acme"`, +// universalPortalsToolCmd is one command of the tool. Flag parsing is off, so every argument goes to the +// tool, which has its own help and flags. +func universalPortalsToolCmd(cli *cli, name, short, long, example string) *cobra.Command { + return &cobra.Command{ + Use: name, + Short: short, + Long: long, + Example: example, + DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runUniversalPortalsSetup(cmd, cli, inputs.PortalName, inputs.Slug) - }, - } - - upPortalName.RegisterString(cmd, &inputs.PortalName, "") - upPortalSlug.RegisterString(cmd, &inputs.Slug, "") - - return cmd -} - -// runUniversalPortalsSetup orchestrates the provisioning flow. -// It owns all display logic; business functions only return (value, error). -func runUniversalPortalsSetup(cmd *cobra.Command, cli *cli, portalName, slug string) error { - if err := cli.setupWithAuthentication(cmd.Context()); err != nil { - return fmt.Errorf("authentication required: %w", err) - } - - // Verify the token carries the scopes required by the EA setup endpoints. - // These are intentionally not in RequiredScopes to avoid breaking login for - // tenants without the feature flag. - // create:flows_vault_connections — POST /api/v2/flows/vault/connections - // create:forms, create:flows — POST /api/v2/forms/import - // create:portals — POST /api/v2/portals - // - // Scopes are read from the JWT directly so this works regardless of auth - // method (device code, client secret, etc.). - upRequiredScopes := []string{ - "create:flows_vault_connections", - "create:forms", - "create:flows", - "create:portals", - } - if tenant, err := cli.Config.GetTenant(cli.tenant); err == nil { - if granted := scopesFromToken(tenant.GetAccessToken()); granted != nil { - if missing := missingScopes(granted, upRequiredScopes); len(missing) > 0 { - // In --no-input mode we can't launch the interactive login, - // so surface the exact command the user needs to run. - if cli.noInput { - return fmt.Errorf( - "insufficient scopes to provision Universal Portals\nMissing: %s\nRe-authenticate to continue: auth0 login --scopes %s", - strings.Join(missing, ", "), - strings.Join(upRequiredScopes, ","), - ) - } - - // RunLoginAsUser gates on a "Press Enter / ^C" prompt before - // opening the browser, so warn here instead of double-confirming. - cli.renderer.Warnf( - "Universal Portals needs additional scopes (missing: %s).\nYou'll be asked to log in again to grant them.\n", - strings.Join(missing, ", "), - ) - - // Request the full scope set so one login grants everything. - if _, err := RunLoginAsUser(cmd.Context(), cli, upRequiredScopes, ""); err != nil { - return fmt.Errorf("failed to re-authenticate with the required scopes: %w", err) - } - - // Rebuild the API clients so subsequent calls use the new token. - if err := cli.setupWithAuthentication(cmd.Context()); err != nil { - return fmt.Errorf("authentication required: %w", err) - } - } - } - } - - ctx := cmd.Context() - - // Resolve the portal domain: default custom domain, or fall back to the tenant domain. - domain, isCustom := resolvePortalDomain(ctx, cli.api.CustomDomain, cli.tenant) - if isCustom { - cli.renderer.Infof("Domain: %s (custom domain)", ansi.Cyan(domain)) - } else { - cli.renderer.Infof("Domain: %s", ansi.Cyan(domain)) - } - cli.renderer.Newline() - - // Collect portal name. - defaultPortalName := portalName - if defaultPortalName == "" { - defaultPortalName = "My Portal" - } - if err := upPortalName.Ask(cmd, &portalName, &defaultPortalName); err != nil { - return fmt.Errorf("failed to enter portal name: %w", err) - } - if portalName == "" { - return fmt.Errorf("portal name cannot be empty") - } - - // Collect portal slug, defaulting to a slugified version of the portal name. - if slug == "" { - slug = toPortalSlug(portalName) - } - if err := upPortalSlug.Ask(cmd, &slug, &slug); err != nil { - return fmt.Errorf("failed to enter portal slug: %w", err) - } - if slug == "" { - return fmt.Errorf("portal slug cannot be empty") - } - - // The application name is derived automatically from the portal name. - appName := portalName + " (Universal Portals)" - - tenant := cli.tenant - - // Ensure the resource servers required by Universal Portals exist. - type rsResult struct { - name string - existed bool - } - var rsResults []rsResult - for _, rs := range portalResourceServers(tenant) { - existed, err := ensurePortalResourceServer(ctx, cli.api.ResourceServer, rs.name, rs.identifier) - if err != nil { - return err - } - rsResults = append(rsResults, rsResult{name: rs.name, existed: existed}) - } - cli.renderer.Successf("Resource servers ready") - for _, rs := range rsResults { - if rs.existed { - cli.renderer.Detailf("%s", ansi.Faint(rs.name+" (already exists)")) - } else { - cli.renderer.Detailf("%s", ansi.Faint(rs.name)) - } - } - - // Create the portal application client. - var client portalClientResult - if err := ansi.Waiting(func() error { - var err error - client, err = createPortalClient(ctx, cli.api.Client, appName, domain, tenant, isCustom) - return err - }); err != nil { - return fmt.Errorf("failed to create application: %w", err) - } - - clientURL := portalManageClientURL(cli.tenant, cli.Config.Tenants[cli.tenant].Name, client.ClientID) - maskedSecret := client.ClientSecret[:4] + strings.Repeat("•", len(client.ClientSecret)-4) - - cli.renderer.Successf("Application %q created", appName) - cli.renderer.Detailf("Client ID: %s", ansi.Hyperlink(clientURL, ansi.Magenta(client.ClientID))) - cli.renderer.Detailf("Client secret: %s", ansi.Faint(maskedSecret)) - cli.renderer.Newline() - - // Create the three client grants. - grants := buildPortalGrants(client.ClientID, tenant) - for _, g := range grants { - g := g - if err := ansi.Waiting(func() error { - return createPortalGrant(ctx, cli.api.ClientGrant, g) - }); err != nil { - return fmt.Errorf("failed to create client grant for %q: %w", g.GetAudience(), err) - } - } - cli.renderer.Successf("Client grants created") - for _, g := range grants { - cli.renderer.Detailf("%s", ansi.Faint(g.GetAudience())) - } - cli.renderer.Newline() - - // Create the vault connection used by the portal forms to call the Management API. - var vaultConnID string - if err := ansi.Waiting(func() error { - setup := map[string]interface{}{ - "domain": tenant, - "client_id": client.ClientID, - "client_secret": client.ClientSecret, - "type": "OAUTH_APP", - } - appID := "AUTH0" - connName := appName - conn := &management.FlowVaultConnection{ - AppID: &appID, - Name: &connName, - Setup: &setup, - } - if err := cli.api.FlowVaultConnection.CreateConnection(ctx, conn); err != nil { - return err - } - if conn.ID != nil { - vaultConnID = *conn.ID - } - return nil - }); err != nil { - return fmt.Errorf("failed to create vault connection: %w", err) - } - cli.renderer.Successf("Vault connection created") - cli.renderer.Detailf("%s", ansi.Faint(appName)) - cli.renderer.Newline() - - // Import the portal forms. - var forms portalFormIDs - if err := ansi.Waiting(func() error { - var err error - forms, err = createPortalForms(ctx, cli.api.HTTPClient, vaultConnID) - return err - }); err != nil { - return fmt.Errorf("failed to create forms: %w", err) - } - cli.renderer.Successf("Forms created") - cli.renderer.Detailf("%s", ansi.Faint("Profile update (Universal Portals)")) - cli.renderer.Detailf("%s", ansi.Faint("Marketing communication preferences (Universal Portals)")) - cli.renderer.Detailf("%s", ansi.Faint("Privacy settings (Universal Portals)")) - cli.renderer.Newline() - - // Create the portal. On slug conflict, re-prompt and retry this step only. - var portal portalResult - for { - err := ansi.Waiting(func() error { - var err error - portal, err = createPortal(ctx, cli.api.HTTPClient, slug, portalName, client.ClientID, client.ClientSecret, forms) - return err - }) - if err == nil { - break - } - var conflict *errAPIConflict - if !errors.As(err, &conflict) { - return fmt.Errorf("failed to create portal: %w", err) - } - cli.renderer.Warnf("Slug %q is already taken.", slug) - prevSlug := slug - slug = "" - if err := upPortalSlug.Ask(cmd, &slug, &prevSlug); err != nil { - return fmt.Errorf("failed to enter portal slug: %w", err) - } - } - cli.renderer.Successf("Portal %q created", portal.Name) - - portalURL := "https://" + domain + "/portals/" + portal.Slug - - cli.renderer.Newline() - cli.renderer.Infof("Portal: %s", ansi.Cyan(portalURL)) - cli.renderer.Newline() - - if cli.noInput { - return nil - } - - cli.renderer.Infof("%s to open the portal or %s to quit...", ansi.Green("Press Enter"), ansi.Red("^C")) - if _, err := fmt.Scanln(); err != nil { - return nil - } - - if err := browser.OpenURL(portalURL); err != nil { - cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", portalURL) - } - - return nil -} - -// portalResourceServer describes a resource server that Universal Portals requires. -type portalResourceServer struct { - name string - identifier string -} - -// portalResourceServers returns the two resource servers required by Universal Portals. -func portalResourceServers(tenant string) []portalResourceServer { - return []portalResourceServer{ - {name: "Auth0 My Account API", identifier: "https://" + tenant + "/me/"}, - {name: "Auth0 My Organization API", identifier: "https://" + tenant + "/my-org/"}, - } -} - -// resolvePortalDomain returns the domain for portal URLs. -// Uses the default custom domain if active; falls back to tenantDomain. -func resolvePortalDomain(ctx context.Context, api auth0.CustomDomainAPI, tenantDomain string) (domain string, isCustom bool) { - cd, err := api.ReadDefault(ctx) - if err == nil && cd.GetStatus() == "ready" { - return cd.GetDomain(), true - } - return tenantDomain, false -} - -// ensurePortalResourceServer creates a resource server idempotently. -// Returns (true, nil) when the server already existed, (false, nil) when created. -func ensurePortalResourceServer(ctx context.Context, api auth0.ResourceServerAPI, name, identifier string) (alreadyExisted bool, err error) { - skipConsent := true - tokenDialect := "rfc9068_profile" - - err = api.Create(ctx, &management.ResourceServer{ - Name: &name, - Identifier: &identifier, - SkipConsentForVerifiableFirstPartyClients: &skipConsent, - TokenDialect: &tokenDialect, - }) - if err == nil { - return false, nil - } - if mErr, ok := err.(management.Error); ok && mErr.Status() == http.StatusConflict { - return true, nil - } - return false, fmt.Errorf("failed to ensure resource server %q: %w", name, err) -} - -type portalClientResult struct { - ClientID string - ClientSecret string -} - -// createPortalClient creates the portal application client via the typed SDK. -// Backchannel logout is omitted when isCustomDomain is false because -// Auth0 domains are rejected by the payload validation. -func createPortalClient(ctx context.Context, api auth0.ClientAPI, name, domain, tenant string, isCustomDomain bool) (portalClientResult, error) { - myAccountScopes := myAccountAPIScopes - myOrgScopes := myOrgAPIScopes - - c := &management.Client{ - Name: auth0.String(name), - IsFirstParty: auth0.Bool(true), - AppType: auth0.String("regular_web"), - OIDCConformant: auth0.Bool(true), - TokenEndpointAuthMethod: auth0.String("client_secret_post"), - Callbacks: &[]string{"https://" + domain + "/portals/auth/callback"}, - AllowedLogoutURLs: &[]string{"https://" + domain}, - OrganizationRequireBehavior: auth0.String("no_prompt"), - OrganizationUsage: auth0.String("allow"), - GrantTypes: &portalGrantTypes, - RefreshToken: &management.ClientRefreshToken{ - ExpirationType: auth0.String("expiring"), - Leeway: auth0.Int(0), - InfiniteTokenLifetime: auth0.Bool(false), - InfiniteIdleTokenLifetime: auth0.Bool(true), - TokenLifetime: auth0.Int(86400), - IdleTokenLifetime: auth0.Int(86399), - RotationType: auth0.String("non-rotating"), - Policies: &[]management.ClientRefreshTokenPolicy{ - {Audience: auth0.String("https://" + tenant + "/me/"), Scope: &myAccountScopes}, - {Audience: auth0.String("https://" + tenant + "/my-org/"), Scope: &myOrgScopes}, - }, + return runUniversalPortals(cli, name, args) }, } - - if isCustomDomain { - initiators := []string{ - "idp-logout", "rp-logout", "session-expired", - "session-revoked", "account-deleted", "account-deactivated", - } - c.OIDCLogout = &management.OIDCLogout{ - BackChannelLogoutURLs: &[]string{"https://" + domain + "/portals/auth/backchannel-logout"}, - BackChannelLogoutInitiators: &management.BackChannelLogoutInitiators{ - Mode: auth0.String("custom"), - SelectedInitiators: &initiators, - }, - } - } - - if err := api.Create(ctx, c); err != nil { - return portalClientResult{}, err - } - return portalClientResult{ - ClientID: c.GetClientID(), - ClientSecret: c.GetClientSecret(), - }, nil } -// buildPortalGrants returns the three grants required by Universal Portals. -// Pure function: no I/O, fully testable. -func buildPortalGrants(clientID, tenant string) []*management.ClientGrant { - myAccountScopes := myAccountAPIScopes - myOrgScopes := myOrgAPIScopes - mgmtScopes := managementAPIScopes - - return []*management.ClientGrant{ - {ClientID: auth0.String(clientID), Audience: auth0.String("https://" + tenant + "/me/"), SubjectType: auth0.String("user"), Scope: &myAccountScopes}, - {ClientID: auth0.String(clientID), Audience: auth0.String("https://" + tenant + "/my-org/"), SubjectType: auth0.String("user"), Scope: &myOrgScopes}, - {ClientID: auth0.String(clientID), Audience: auth0.String("https://" + tenant + "/api/v2/"), SubjectType: auth0.String("client"), Scope: &mgmtScopes}, - } -} - -// createPortalGrant creates a single client grant via the typed SDK. -func createPortalGrant(ctx context.Context, api auth0.ClientGrantAPI, grant *management.ClientGrant) error { - return api.Create(ctx, grant) -} - -// errAPIConflict is returned by rawAPIPost when the server responds 409. -// Callers that want to handle conflicts (e.g. slug already taken) can use -// errors.As to detect and recover from this case. -type errAPIConflict struct{ message string } - -func (e *errAPIConflict) Error() string { return e.message } - -// rawAPIPost is a deep helper that hides the New/Do/decode HTTP pattern. -// Pass path as one or more segments — they are joined by the URI builder so -// slashes are not URL-encoded. Pass a non-nil result to decode the response. -func rawAPIPost(ctx context.Context, h auth0.HTTPClientAPI, payload, result any, path ...string) error { - req, err := h.NewRequest(ctx, http.MethodPost, h.URI(path...), payload) +func runUniversalPortals(cli *cli, command string, args []string) error { + node, err := exec.LookPath("node") if err != nil { - return err + return fmt.Errorf( + "node is required but was not found. Install Node.js %d.%d or newer and try again", + universalPortalsNodeMajor, universalPortalsNodeMinor, + ) } - resp, err := h.Do(req) - if err != nil { + if err := checkUniversalPortalsNodeVersion(node); err != nil { return err } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= http.StatusBadRequest { - var apiErr struct { - StatusCode int `json:"statusCode"` - Message string `json:"message"` - } - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if resp.StatusCode == http.StatusConflict { - return &errAPIConflict{message: apiErr.Message} - } - return fmt.Errorf("API error %d: %s", resp.StatusCode, apiErr.Message) - } - - if result != nil { - return json.NewDecoder(resp.Body).Decode(result) - } - return nil -} - -type portalFormResult struct { - Form struct { - ID string `json:"id"` - } `json:"form"` -} - -// buildCommunicationPreferencesFormPayload returns the form document for the -// "Marketing communication preferences" form with connID injected. -func buildCommunicationPreferencesFormPayload(connID string) map[string]any { - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Marketing communication preferences (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_4Td2", - "type": "STEP", - "coordinates": map[string]any{"x": 217, "y": -218}, - "config": map[string]any{ - "components": []any{ - map[string]any{ - "id": "communication_preferences", - "category": "FIELD", - "type": "CHOICE", - "required": false, - "sensitive": false, - "config": map[string]any{ - "multiple": true, - "options": []any{ - map[string]any{"label": "Product Announcements", "value": "Product Announcements"}, - map[string]any{"label": "Featured Content", "value": "Featured Content"}, - map[string]any{"label": "Digest News", "value": "Digest News"}, - map[string]any{"label": "Events", "value": "Events"}, - }, - }, - }, - map[string]any{ - "id": "next_button_US5N", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_etvH", - }, - }, - map[string]any{ - "id": "flow_etvH", - "type": "FLOW", - "coordinates": map[string]any{"x": 791, "y": -81}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_4Td2", "coordinates": map[string]any{"x": -25, "y": -98}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1194, "y": -59}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update preferences", - "actions": []any{ - map[string]any{ - "id": "update_user_HitP", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "app_metadata": map[string]any{ - "communication_preferences": "{{fields.communication_preferences}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} - -// buildPersonalInfoFormPayload returns the form document for the -// "Profile update" form with connID injected. -func buildPersonalInfoFormPayload(connID string) map[string]any { - textField := func(id, label string) map[string]any { - return map[string]any{ - "id": id, - "category": "FIELD", - "type": "TEXT", - "label": label, - "required": false, - "sensitive": false, - "config": map[string]any{"multiline": false}, - } - } - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Profile update (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_BL3V", - "type": "STEP", - "coordinates": map[string]any{"x": 346, "y": -221}, - "config": map[string]any{ - "components": []any{ - textField("full_name", "Full name"), - textField("job_title", "Job title"), - map[string]any{ - "id": "mobile_number", - "category": "FIELD", - "type": "TEL", - "label": "Mobile number", - "required": false, - "sensitive": false, - "config": map[string]any{"country_picker": true}, - }, - map[string]any{ - "id": "date_of_birth", - "category": "FIELD", - "type": "DATE", - "label": "Date of birth", - "required": false, - "sensitive": false, - "config": map[string]any{"format": "DATE"}, - }, - map[string]any{ - "id": "linkedin", - "category": "FIELD", - "type": "URL", - "label": "LinkedIn", - "required": false, - "sensitive": false, - }, - map[string]any{ - "id": "next_button_UmEY", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_DcHK", - }, - }, - map[string]any{ - "id": "flow_DcHK", - "type": "FLOW", - "coordinates": map[string]any{"x": 988, "y": 9}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_BL3V", "coordinates": map[string]any{"x": 0, "y": 0}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1367, "y": -7}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update profile", - "actions": []any{ - map[string]any{ - "id": "update_user_v4C0", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "user_metadata": map[string]any{ - "linkedin": "{{fields.linkedin}}", - "full_name": "{{fields.full_name}}", - "job_title": "{{fields.job_title}}", - "date_of_birth": "{{fields.date_of_birth}}", - "mobile_number": "{{fields.mobile_number.international_number}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} - -// buildPrivacySettingsFormPayload returns the form document for the -// "Privacy settings" form with connID injected. -func buildPrivacySettingsFormPayload(connID string) map[string]any { - boolField := func(id, label, hint string) map[string]any { - return map[string]any{ - "id": id, - "category": "FIELD", - "type": "BOOLEAN", - "label": label, - "hint": hint, - "required": true, - "sensitive": false, - "config": map[string]any{"default_value": false}, - } - } - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Privacy settings (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_y0Ri", - "type": "STEP", - "coordinates": map[string]any{"x": 500, "y": 0}, - "config": map[string]any{ - "components": []any{ - boolField("data_sharing", "Data sharing", `

Allow app usage data to improve features. Learn more.

`), - boolField("profile_visibility", "Profile visibility", `

Show my profile in search results. Learn more.

`), - boolField("location_tracking", "Location tracking", `

Allow location tracking for personalized recommendations. Learn more.

`), - boolField("ad_personalization", "Ad personalization", `

Use my data to personalize ads. Learn more.

`), - map[string]any{ - "id": "next_button_aB8e", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_AB1r", - }, - }, - map[string]any{ - "id": "flow_AB1r", - "type": "FLOW", - "coordinates": map[string]any{"x": 1043, "y": 261}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_y0Ri", "coordinates": map[string]any{"x": 202, "y": 247}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1445, "y": 247}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update privacy settings", - "actions": []any{ - map[string]any{ - "id": "update_user_uOZW", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "app_metadata": map[string]any{ - "data_sharing": "{{fields.data_sharing}}", - "location_tracking": "{{fields.location_tracking}}", - "ad_personalization": "{{fields.ad_personalization}}", - "profile_visibility": "{{fields.profile_visibility}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} - -// createPortalForms imports the portal forms and returns their IDs. -// Uses POST /api/v2/forms/import which accepts the full form document -// (version, form, flows, connections). The URI is built with two segments -// to avoid h.URI encoding the slash as %2F. -func createPortalForms(ctx context.Context, h auth0.HTTPClientAPI, vaultConnID string) (portalFormIDs, error) { - importForm := func(payload map[string]any) (string, error) { - var result portalFormResult - if err := rawAPIPost(ctx, h, payload, &result, "forms", "import"); err != nil { - return "", err - } - return result.Form.ID, nil - } - - personalInfoID, err := importForm(buildPersonalInfoFormPayload(vaultConnID)) + // The tool calls this same executable to reach the Management API. + executable, err := os.Executable() if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create personal info form: %w", err) + return fmt.Errorf("failed to find the path of the CLI: %w", err) } - commPrefID, err := importForm(buildCommunicationPreferencesFormPayload(vaultConnID)) - if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create communication preferences form: %w", err) - } + run := universalPortalsRunFor(command, args, cli.tenant, executable, node) + run.Stdin, run.Stdout, run.Stderr = os.Stdin, os.Stdout, os.Stderr - privacyID, err := importForm(buildPrivacySettingsFormPayload(vaultConnID)) + code, err := runUniversalPortalsScript(run) if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create privacy settings form: %w", err) + return err } - return portalFormIDs{ - PersonalInfo: personalInfoID, - CommunicationPreferences: commPrefID, - PrivacyConsent: privacyID, - }, nil -} - -type portalPayload struct { - Slug string `json:"slug"` - Name string `json:"name"` - Client portalClientRef `json:"client"` - Navigation *portalNavigation `json:"navigation,omitempty"` - Pages *portalPages `json:"pages,omitempty"` -} - -type portalClientRef struct { - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` -} - -type portalNavigation struct { - Sidebar portalSidebar `json:"sidebar"` -} - -type portalSidebar struct { - Components []portalComponent `json:"components"` -} - -type portalPages struct { - Default string `json:"default,omitempty"` - Standard []portalPage `json:"standard"` -} - -type portalPage struct { - Title string `json:"title"` - Slug string `json:"slug"` - Components []portalComponent `json:"components,omitempty"` -} - -// portalComponent covers both sidebar and page components. -// Config is map[string]any because component types have heterogeneous shapes. -type portalComponent struct { - Type string `json:"type"` - Config map[string]any `json:"config,omitempty"` -} - -// portalResult holds the fields read back from the create-portal response. -type portalResult struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -// toPortalSlug derives a URL-safe kebab-case slug from a portal name. -// Pure function: no I/O. -func toPortalSlug(name string) string { - var b strings.Builder - prevHyphen := false - for _, r := range strings.ToLower(name) { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - b.WriteRune(r) - prevHyphen = false - case b.Len() > 0 && !prevHyphen: - b.WriteByte('-') - prevHyphen = true - } - } - slug := strings.TrimRight(b.String(), "-") - if slug == "" { - return "my-portal" + // The tool has already shown its own message, so the CLI only passes on the exit code. + if code != 0 { + os.Exit(code) } - return slug -} -// portalFormIDs holds the IDs of the three forms required by the portal. -type portalFormIDs struct { - PersonalInfo string - PrivacyConsent string - CommunicationPreferences string + return nil } -// buildDefaultPortal returns the full portal payload matching the Universal Portals -// template: four pages with all sections, including form-backed ones. -// Pure function: no I/O. -func buildDefaultPortal(slug, name, clientID, clientSecret string, forms portalFormIDs) portalPayload { - section := func(title, description string, children ...portalComponent) portalComponent { - return portalComponent{ - Type: "page:component:auth0:structure:section", - Config: map[string]any{ - "title": title, - "description": description, - "variant": "card", - "children": children, - }, - } - } - builtin := func(componentType string) portalComponent { - return portalComponent{Type: componentType} - } - form := func(formID, completionMessage string) portalComponent { - return portalComponent{ - Type: "page:component:auth0:form", - Config: map[string]any{ - "form_id": formID, - "completion_message": completionMessage, - }, - } - } - navLink := func(label, to, icon string) portalComponent { - return portalComponent{ - Type: "sidebar:component:auth0:internal_link", - Config: map[string]any{"label": label, "to": to, "icon": icon}, - } - } +// universalPortalsRunFor describes one run of the tool. The tenant named by --tenant in the arguments +// wins over the one the CLI is logged in to. +func universalPortalsRunFor(command string, args []string, loggedInTenant, executable, node string) universalPortalsRun { + env := []string{"AUTH0_CLI_BIN=" + executable} - return portalPayload{ - Slug: slug, - Name: name, - Client: portalClientRef{ - TokenEndpointAuthMethod: "client_secret_post", - ClientID: clientID, - ClientSecret: clientSecret, - }, - Navigation: &portalNavigation{ - Sidebar: portalSidebar{ - Components: []portalComponent{ - navLink("Profile", "profile", "user"), - navLink("Security", "security", "shield"), - navLink("Organization", "organization", "building"), - navLink("Legal & privacy", "legal-privacy", "file-text"), - }, - }, - }, - Pages: &portalPages{ - Default: "profile", - Standard: []portalPage{ - { - Title: "Profile", - Slug: "profile", - Components: []portalComponent{ - section( - "Personal information", - "Basic info about you, like your name and contact details, that you use across services.", - form(forms.PersonalInfo, "Your personal information has been updated."), - ), - section( - "Passkeys", - "Use your fingerprint, face, or screen lock instead of a password to sign in quickly and more securely.", - builtin("page:component:auth0:my_account:passkey_management"), - ), - }, - }, - { - Title: "Security", - Slug: "security", - Components: []portalComponent{ - section( - "Multi-factor authentication", - "Add an extra layer of protection to your account by requiring a second verification step each time you sign in.", - builtin("page:component:auth0:my_account:mfa_management"), - ), - section( - "Sessions & devices", - "Review the devices and sessions that are currently signed in to your account.", - portalComponent{ - Type: "page:component:auth0:typography:rich_text", - Config: map[string]any{"content": "

Sessions & devices management coming soon.

"}, - }, - ), - }, - }, - { - Title: "Organization", - Slug: "organization", - Components: []portalComponent{ - section( - "Organization details", - "Update your organization's name and other details visible to its members.", - builtin("page:component:auth0:my_organization:details_edit"), - ), - }, - }, - { - Title: "Legal & privacy", - Slug: "legal-privacy", - Components: []portalComponent{ - section( - "Privacy & data consent", - "Control how your personal data is collected and used across our services.", - form(forms.PrivacyConsent, "Your privacy preferences have been saved."), - ), - section( - "Communication preferences", - "Choose which emails and notifications you'd like to receive from us.", - form(forms.CommunicationPreferences, "Your communication preferences have been updated."), - ), - }, - }, - }, - }, + tenant := universalPortalsTenantFlag(args) + if tenant == "" { + tenant = loggedInTenant } -} - -// createPortal POSTs a new portal with the full default page structure. -func createPortal(ctx context.Context, h auth0.HTTPClientAPI, slug, name, clientID, clientSecret string, forms portalFormIDs) (portalResult, error) { - payload := buildDefaultPortal(slug, name, clientID, clientSecret, forms) - var result portalResult - if err := rawAPIPost(ctx, h, payload, &result, "portals"); err != nil { - return portalResult{}, err + if tenant != "" { + env = append(env, "AUTH0_CLI_TENANT_DOMAIN="+tenant) } - return result, nil -} -// portalManageClientURL builds the Management Dashboard URL for the given client. -// Pure function: takes only the values it needs, no struct access. -func portalManageClientURL(tenantDomain, tenantName, clientID string) string { - parts := strings.Split(tenantDomain, ".") - region := "us" - if len(parts) > 3 { - region = parts[1] + return universalPortalsRun{ + Node: node, + Script: universalPortalsScript, + Args: append([]string{command}, args...), + Env: env, } - return fmt.Sprintf("%s/dashboard/%s/%s/applications/%s/settings", - deriveServiceURL("manage", tenantDomain), region, tenantName, clientID) } diff --git a/internal/cli/universal_portals_bundle_test.go b/internal/cli/universal_portals_bundle_test.go new file mode 100644 index 000000000..8e0a7bb27 --- /dev/null +++ b/internal/cli/universal_portals_bundle_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const universalPortalsSourceDir = "data/universal-portals/_source" + +// universalPortalsSourceHash is a hash of everything the embedded file is built from: the lockfile, +// the build script and every file under src. The tool's own tests compute the same value, and the +// file records it in its first lines. Paths use "/" and are sorted, and line endings are LF, so +// the value is the same on every machine. +func universalPortalsSourceHash(t *testing.T, root string) string { + t.Helper() + + files := []string{"package-lock.json", "scripts/bundle.mjs"} + err := filepath.WalkDir(filepath.Join(root, "src"), func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() { + return walkErr + } + relative, relErr := filepath.Rel(root, path) + files = append(files, filepath.ToSlash(relative)) + return relErr + }) + require.NoError(t, err) + sort.Strings(files) + + hash := sha256.New() + for _, file := range files { + content, readErr := os.ReadFile(filepath.Join(root, filepath.FromSlash(file))) + require.NoError(t, readErr) + text := strings.ReplaceAll(string(content), "\r\n", "\n") + hash.Write([]byte(file + "\x00" + text + "\x00")) + } + + return hex.EncodeToString(hash.Sum(nil)) +} + +// This pins the definition above. The tool's tests check the same value, written out by hand, so +// the two implementations cannot drift apart. +func TestUniversalPortalsSourceHashDefinition(t *testing.T) { + root := t.TempDir() + for name, text := range map[string]string{ + "package-lock.json": "{}", + "scripts/bundle.mjs": "x", + "src/a.ts": "a", + "src/deep/b.ts": "b", + "test/ignored.ts": "not part of the hash", + } { + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, name), []byte(text), 0o644)) + } + + want := sha256.Sum256([]byte("package-lock.json\x00{}\x00scripts/bundle.mjs\x00x\x00src/a.ts\x00a\x00src/deep/b.ts\x00b\x00")) + + assert.Equal(t, hex.EncodeToString(want[:]), universalPortalsSourceHash(t, root)) +} + +// The binary ships the embedded file, so a file that is older than its sources would ship old code. +func TestEmbeddedUniversalPortalsScriptMatchesItsSources(t *testing.T) { + if _, err := os.Stat(filepath.Join(universalPortalsSourceDir, "src")); errors.Is(err, fs.ErrNotExist) { + t.Skip("the sources are not in this checkout") + } + + head := string(universalPortalsScript) + if len(head) > 600 { + head = head[:600] + } + match := regexp.MustCompile(`(?m)^// source-sha256: ([0-9a-f]{64})$`).FindStringSubmatch(head) + require.NotNil(t, match, "the embedded file does not start with its source hash") + + assert.Equal(t, + match[1], + universalPortalsSourceHash(t, universalPortalsSourceDir), + `the sources changed after the embedded file was built: run "npm run bundle" in %s and commit the result`, + universalPortalsSourceDir, + ) +} + +// The plugin framework skips a plugin whose name matches a built-in command. Once the framework is on +// this branch, the embedded command must go, or the installed plugin is silently ignored. +func TestUniversalPortalsCommandIsRemovedWhenTheFrameworkArrives(t *testing.T) { + _, err := os.Stat("plugins.go") + + require.True(t, errors.Is(err, fs.ErrNotExist), + "plugins.go exists, so the plugin framework is on this branch. Remove the embedded universal-portals "+ + "command first: the list is at the top of universal_portals.go") +} diff --git a/internal/cli/universal_portals_embed.go b/internal/cli/universal_portals_embed.go new file mode 100644 index 000000000..74182686d --- /dev/null +++ b/internal/cli/universal_portals_embed.go @@ -0,0 +1,11 @@ +package cli + +import ( + _ "embed" +) + +// universalPortalsScript is the Universal Portals setup and audit tool, built into one file that +// Node.js can run. It is generated from the sources in data/universal-portals/_source. +// +//go:embed data/universal-portals/universal-portals.mjs +var universalPortalsScript []byte diff --git a/internal/cli/universal_portals_runner.go b/internal/cli/universal_portals_runner.go new file mode 100644 index 000000000..001755197 --- /dev/null +++ b/internal/cli/universal_portals_runner.go @@ -0,0 +1,160 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" +) + +// The oldest Node.js the tool runs on. +const ( + universalPortalsNodeMajor = 20 + universalPortalsNodeMinor = 12 +) + +var nodeVersionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)`) + +// universalPortalsRun describes one run of the embedded tool. +type universalPortalsRun struct { + // Node is the path of the node executable. + Node string + // Script is the text of the tool. + Script []byte + // Args follow the script path: the command (setup or audit) and its flags. + Args []string + // Env is added to the environment, as KEY=value. + Env []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// runUniversalPortalsScript writes the tool to a private temporary file, runs it with Node.js +// and returns its exit code. The tool talks to the Auth0 Management API by calling this CLI +// again, so the login and the token stay inside the CLI. +func runUniversalPortalsScript(run universalPortalsRun) (int, error) { + // The folder is private to the current user, and removed when the run ends. + dir, err := os.MkdirTemp("", "auth0-universal-portals-") + if err != nil { + return 0, fmt.Errorf("failed to create a temporary folder for the tool: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + path := filepath.Join(dir, "universal-portals.mjs") + if err := os.WriteFile(path, run.Script, 0o600); err != nil { + return 0, fmt.Errorf("failed to write the tool to a temporary file: %w", err) + } + + cmd := exec.Command(run.Node, append([]string{path}, run.Args...)...) + cmd.Env = universalPortalsEnv(os.Environ(), run.Env) + cmd.Stdin, cmd.Stdout, cmd.Stderr = run.Stdin, run.Stdout, run.Stderr + + // The root command exits as soon as it gets Ctrl+C, which would cut off the tool's last message. + // The terminal sends Ctrl+C to the tool too, so this process only has to wait for it. + signal.Reset(os.Interrupt, syscall.SIGTERM) + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start Node.js: %w", err) + } + + finished := make(chan struct{}) + go func() { + for { + select { + case received := <-signals: + // Only a SIGTERM sent to this process alone needs passing on. + if received == syscall.SIGTERM { + _ = cmd.Process.Signal(received) + } + case <-finished: + return + } + } + }() + + err = cmd.Wait() + close(finished) + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // A tool ended by a signal has no exit code, so use the one a shell reports: 128 plus the + // signal number, which is 130 for Ctrl+C. + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()), nil + } + return exitErr.ExitCode(), nil + } + if err != nil { + return 0, fmt.Errorf("node stopped unexpectedly: %w", err) + } + + return 0, nil +} + +// universalPortalsEnv adds extra to base. A proxy left over in the environment is dropped, so it +// cannot switch the tool to another way of calling the API. +func universalPortalsEnv(base, extra []string) []string { + env := make([]string, 0, len(base)+len(extra)) + for _, entry := range base { + if strings.HasPrefix(entry, "AUTH0_CLI_PROXY_URL=") || strings.HasPrefix(entry, "AUTH0_CLI_PROXY_TOKEN=") { + continue + } + env = append(env, entry) + } + + // When a name appears twice, the last value is used. + return append(env, extra...) +} + +// universalPortalsTenantFlag returns the value of --tenant in the arguments, or "". The flags +// belong to the tool, so the CLI only reads this one to tell the tool which tenant to use. +func universalPortalsTenantFlag(args []string) string { + tenant := "" + for i := 0; i < len(args); i++ { + switch { + case args[i] == "--tenant" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-"): + tenant = args[i+1] + i++ + case strings.HasPrefix(args[i], "--tenant="): + tenant = strings.TrimPrefix(args[i], "--tenant=") + } + } + + return tenant +} + +// checkUniversalPortalsNodeVersion fails if node is older than the tool supports. +func checkUniversalPortalsNodeVersion(node string) error { + output, err := exec.Command(node, "--version").Output() + if err != nil { + return fmt.Errorf("could not read the Node.js version: %w", err) + } + + version := strings.TrimSpace(string(output)) + match := nodeVersionPattern.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("could not read the Node.js version from %q", version) + } + + major, _ := strconv.Atoi(match[1]) + minor, _ := strconv.Atoi(match[2]) + if major < universalPortalsNodeMajor || (major == universalPortalsNodeMajor && minor < universalPortalsNodeMinor) { + return fmt.Errorf( + "node %s is too old: this command needs Node.js %d.%d or newer", + version, universalPortalsNodeMajor, universalPortalsNodeMinor, + ) + } + + return nil +} diff --git a/internal/cli/universal_portals_runner_test.go b/internal/cli/universal_portals_runner_test.go new file mode 100644 index 000000000..e1ef9cc27 --- /dev/null +++ b/internal/cli/universal_portals_runner_test.go @@ -0,0 +1,337 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeNode writes a stand-in for the node executable. It answers --version, and for a script it +// reports what it was given: the script path, the permissions of the script and its folder, the +// script's text, the arguments, the environment the tool relies on, and optionally its stdin. +func fakeNode(t *testing.T, version string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the fake node is a shell script") + } + + script := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +script="$1" +echo "script=$script" +echo "folder_mode=$(ls -ld "$(dirname "$script")" | cut -c1-10)" +echo "file_mode=$(ls -l "$script" | cut -c1-10)" +echo "content=$(cat "$script")" +shift +echo "args=$*" +echo "bin=$AUTH0_CLI_BIN" +echo "tenant=$AUTH0_CLI_TENANT_DOMAIN" +echo "proxy_url=${AUTH0_CLI_PROXY_URL:-unset}" +echo "proxy_token=${AUTH0_CLI_PROXY_TOKEN:-unset}" +if [ -n "$FAKE_NODE_READ_STDIN" ]; then read line; echo "stdin=$line"; fi +echo "to-stderr" >&2 +exit "${FAKE_NODE_EXIT:-0}" +` + path := filepath.Join(t.TempDir(), "node") + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + return path +} + +// reported turns the fake node's "key=value" lines into a map. +func reported(output string) map[string]string { + values := map[string]string{} + for _, line := range strings.Split(output, "\n") { + if key, value, found := strings.Cut(line, "="); found { + values[key] = value + } + } + return values +} + +func TestUniversalPortalsTenantFlag(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "no flag", args: []string{"setup", "--name", "Acme"}, want: ""}, + {name: "separate value", args: []string{"setup", "--tenant", "acme.us.auth0.com"}, want: "acme.us.auth0.com"}, + {name: "equals form", args: []string{"audit", "up_1", "--tenant=acme.eu.auth0.com"}, want: "acme.eu.auth0.com"}, + {name: "the last one wins", args: []string{"--tenant", "a.us.auth0.com", "--tenant", "b.us.auth0.com"}, want: "b.us.auth0.com"}, + {name: "no value at the end", args: []string{"setup", "--tenant"}, want: ""}, + {name: "a flag is not a value", args: []string{"setup", "--tenant", "--json"}, want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, universalPortalsTenantFlag(test.args)) + }) + } +} + +func TestCheckUniversalPortalsNodeVersion(t *testing.T) { + tests := []struct { + version string + wantErr string + }{ + {version: "v22.1.0"}, + {version: "v20.12.0"}, + {version: "v20.19.4"}, + {version: "v21.0.0"}, + {version: "v20.11.1", wantErr: "20.12"}, + {version: "v18.19.0", wantErr: "20.12"}, + {version: "not a version", wantErr: "could not read"}, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + err := checkUniversalPortalsNodeVersion(fakeNode(t, test.version)) + if test.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestRunUniversalPortalsScript(t *testing.T) { + t.Run("runs the script with the arguments and the environment the tool relies on", func(t *testing.T) { + // A proxy left in the environment must not switch the tool to another way of calling the API. + t.Setenv("AUTH0_CLI_PROXY_URL", "https://127.0.0.1:1") + t.Setenv("AUTH0_CLI_PROXY_TOKEN", "should-not-reach-the-tool") + var stdout, stderr bytes.Buffer + + code, err := runUniversalPortalsScript(universalPortalsRun{ + Node: fakeNode(t, "v22.1.0"), + Script: []byte("console.log('hi')"), + Args: []string{"setup", "--json"}, + Env: []string{"AUTH0_CLI_BIN=/opt/bin/auth0", "AUTH0_CLI_TENANT_DOMAIN=acme.us.auth0.com"}, + Stdout: &stdout, + Stderr: &stderr, + }) + + require.NoError(t, err) + assert.Equal(t, 0, code) + got := reported(stdout.String()) + assert.Equal(t, "setup --json", got["args"]) + assert.Equal(t, "/opt/bin/auth0", got["bin"]) + assert.Equal(t, "acme.us.auth0.com", got["tenant"]) + assert.Equal(t, "unset", got["proxy_url"]) + assert.Equal(t, "unset", got["proxy_token"]) + assert.Equal(t, "console.log('hi')", got["content"]) + assert.Contains(t, stderr.String(), "to-stderr", "the tool's stderr must reach the user") + }) + + t.Run("keeps the script private and removes it afterwards", func(t *testing.T) { + var stdout bytes.Buffer + + _, err := runUniversalPortalsScript(universalPortalsRun{ + Node: fakeNode(t, "v22.1.0"), + Script: []byte("x"), + Stdout: &stdout, + }) + + require.NoError(t, err) + got := reported(stdout.String()) + assert.Equal(t, "drwx------", got["folder_mode"], "only the current user may enter the folder") + assert.Equal(t, "-rw-------", got["file_mode"], "only the current user may read the script") + assert.NoDirExists(t, filepath.Dir(got["script"]), "the temporary folder must be gone after the run") + }) + + t.Run("hands the terminal's input to the tool", func(t *testing.T) { + t.Setenv("FAKE_NODE_READ_STDIN", "1") + var stdout bytes.Buffer + + _, err := runUniversalPortalsScript(universalPortalsRun{ + Node: fakeNode(t, "v22.1.0"), + Script: []byte("x"), + Stdin: strings.NewReader("an answer\n"), + Stdout: &stdout, + }) + + require.NoError(t, err) + assert.Equal(t, "an answer", reported(stdout.String())["stdin"]) + }) + + t.Run("returns the tool's exit code, so the CLI can exit with it", func(t *testing.T) { + t.Setenv("FAKE_NODE_EXIT", "7") + + code, err := runUniversalPortalsScript(universalPortalsRun{Node: fakeNode(t, "v22.1.0"), Script: []byte("x")}) + + require.NoError(t, err) + assert.Equal(t, 7, code) + }) + + t.Run("fails with a clear message when node cannot be started", func(t *testing.T) { + _, err := runUniversalPortalsScript(universalPortalsRun{ + Node: filepath.Join(t.TempDir(), "no-such-node"), + Script: []byte("x"), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to start Node.js") + }) + + t.Run("reports a tool that Ctrl+C stopped with the code a shell would", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("signals are not delivered the same way on Windows") + } + interrupted := filepath.Join(t.TempDir(), "node") + require.NoError(t, os.WriteFile(interrupted, []byte("#!/bin/sh\nkill -INT $$\nsleep 5\n"), 0o755)) + + code, err := runUniversalPortalsScript(universalPortalsRun{Node: interrupted, Script: []byte("x")}) + + require.NoError(t, err) + assert.Equal(t, 130, code, "128 plus the signal number, so scripts can tell it from a failure") + }) + + t.Run("keeps waiting for the tool when Ctrl+C reaches the CLI as well", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("signals are not delivered the same way on Windows") + } + // The terminal sends Ctrl+C to both processes. The CLI's own handler would exit at once and + // cut off the tool's last message, so the runner must outlive the signal and report the + // tool's exit code. + dir := t.TempDir() + started, released := filepath.Join(dir, "started"), filepath.Join(dir, "released") + slow := filepath.Join(dir, "node") + // The tool says it has started, then waits until the test has sent the signal (or gives up + // after about ten seconds, so a broken test cannot hang the run). + fakeTool := "#!/bin/sh\ntouch '" + started + "'\ni=0\n" + + "while [ ! -f '" + released + "' ] && [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done\nexit 3\n" + require.NoError(t, os.WriteFile(slow, []byte(fakeTool), 0o755)) + go func() { + for i := 0; i < 200; i++ { + if _, statErr := os.Stat(started); statErr == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + process, findErr := os.FindProcess(os.Getpid()) + if findErr == nil { + _ = process.Signal(syscall.SIGINT) + } + _ = os.WriteFile(released, nil, 0o600) + }() + + code, err := runUniversalPortalsScript(universalPortalsRun{Node: slow, Script: []byte("x")}) + + require.NoError(t, err) + assert.Equal(t, 3, code) + }) +} + +func TestEmbeddedUniversalPortalsScriptRuns(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + if checkUniversalPortalsNodeVersion(node) != nil { + t.Skip("node is older than the tool supports") + } + require.NotEmpty(t, universalPortalsScript, "the bundle is not embedded") + var stdout, stderr bytes.Buffer + + code, err := runUniversalPortalsScript(universalPortalsRun{ + Node: node, + Script: universalPortalsScript, + Args: []string{"--help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + + require.NoError(t, err) + assert.Equal(t, 0, code, stderr.String()) + assert.Contains(t, stdout.String(), "Usage: auth0 universal-portals setup") + assert.Contains(t, stdout.String(), "audit") +} + +func TestUniversalPortalsCommand(t *testing.T) { + cmd := universalPortalsCmd(&cli{}) + + assert.Equal(t, "universal-portals", cmd.Name()) + assert.Contains(t, cmd.Aliases, "up") + + names := []string{} + for _, sub := range cmd.Commands() { + names = append(names, sub.Name()) + // Every flag belongs to the tool, so the CLI must hand all arguments over unparsed. + assert.True(t, sub.DisableFlagParsing, "%s must pass its arguments through", sub.Name()) + assert.NotNil(t, sub.RunE, sub.Name()) + } + assert.ElementsMatch(t, []string{"setup", "audit"}, names) + assert.False(t, cmd.DisableFlagParsing, "the group itself still shows help") +} + +func TestUniversalPortalsRunFor(t *testing.T) { + tests := []struct { + name string + command string + args []string + tenant string + wantArgs []string + wantTenant string + }{ + { + name: "the logged-in tenant is used when no flag names one", + command: "setup", + args: []string{"--template", "business"}, + tenant: "acme.us.auth0.com", + wantArgs: []string{"setup", "--template", "business"}, + wantTenant: "acme.us.auth0.com", + }, + { + name: "--tenant in the arguments wins", + command: "audit", + args: []string{"up_1", "--tenant", "other.eu.auth0.com"}, + tenant: "acme.us.auth0.com", + wantArgs: []string{"audit", "up_1", "--tenant", "other.eu.auth0.com"}, + wantTenant: "other.eu.auth0.com", + }, + { + name: "no tenant at all leaves the choice to the tool", + command: "setup", + wantArgs: []string{"setup"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + run := universalPortalsRunFor(test.command, test.args, test.tenant, "/opt/bin/auth0", "/usr/bin/node") + + assert.Equal(t, test.wantArgs, run.Args) + assert.Equal(t, "/usr/bin/node", run.Node) + assert.NotEmpty(t, run.Script) + assert.Contains(t, run.Env, "AUTH0_CLI_BIN=/opt/bin/auth0") + if test.wantTenant == "" { + for _, entry := range run.Env { + assert.False(t, strings.HasPrefix(entry, "AUTH0_CLI_TENANT_DOMAIN="), entry) + } + return + } + assert.Contains(t, run.Env, "AUTH0_CLI_TENANT_DOMAIN="+test.wantTenant) + }) + } +} + +func TestRunUniversalPortalsWithoutNode(t *testing.T) { + // Nothing is on the PATH, so nothing can be started and the user is told what to install. + t.Setenv("PATH", t.TempDir()) + + err := runUniversalPortals(&cli{}, "setup", []string{"--json"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "node is required but was not found") + assert.Contains(t, err.Error(), "Node.js 20.12 or newer", "the message must name the version this command needs") +} diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 76134cc96..8b484fc2b 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -337,52 +337,6 @@ func openManageURL(cli *cli, tenant string, path string) { } } -// missingScopes returns the elements of required absent from granted. -func missingScopes(granted, required []string) []string { - have := make(map[string]struct{}, len(granted)) - for _, s := range granted { - have[s] = struct{}{} - } - var missing []string - for _, s := range required { - if _, ok := have[s]; !ok { - missing = append(missing, s) - } - } - return missing -} - -// scopesFromToken returns the granted scopes decoded from a JWT access token's -// payload, or nil if the token is malformed or carries no scope claim (callers -// then skip the pre-flight check and let the API surface a 403). -func scopesFromToken(tokenStr string) []string { - parts := strings.SplitN(tokenStr, ".", 3) - if len(parts) != 3 { - return nil - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil - } - var claims struct { - Scope string `json:"scope"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return nil - } - return strings.Fields(claims.Scope) -} - -// deriveServiceURL returns the base URL for a service (e.g. "manage") from the -// tenant domain, dropping the 2-letter region code that public envs carry. -func deriveServiceURL(service, tenantDomain string) string { - parts := strings.Split(tenantDomain, ".") - if len(parts) >= 4 && len(parts[1]) == 2 { - return "https://" + service + "." + strings.Join(parts[2:], ".") - } - return "https://" + service + "." + strings.Join(parts[1:], ".") -} - func formatManageTenantURL(tenant string, cfg *config.Config) string { if len(tenant) == 0 { return ""