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/api.ts b/internal/cli/data/universal-portals/_source/src/api.ts index 58e7f4de9..7e8773252 100644 --- a/internal/cli/data/universal-portals/_source/src/api.ts +++ b/internal/cli/data/universal-portals/_source/src/api.ts @@ -44,7 +44,8 @@ export function createApiClient(baseUrl: string, token: string, fetchImpl: Fetch export function toApiError(status: number, body: unknown): ApiError { const record = isRecord(body) ? body : {}; - const message = typeof record.message === "string" ? record.message : `HTTP ${status}`; + const text = typeof body === "string" ? body.trim() : ""; + const message = typeof record.message === "string" ? record.message : text || `HTTP ${status}`; const errorCode = typeof record.errorCode === "string" ? record.errorCode : undefined; return new ApiError(status, errorCode, message, body); } diff --git a/internal/cli/data/universal-portals/_source/src/args.ts b/internal/cli/data/universal-portals/_source/src/args.ts index 1e2b706a4..2ee9556a5 100644 --- a/internal/cli/data/universal-portals/_source/src/args.ts +++ b/internal/cli/data/universal-portals/_source/src/args.ts @@ -11,6 +11,8 @@ export class UsageError extends PluginError { export interface ParsedSetupArgs { command: "setup" | "audit" | "help"; + // With --help after a command name, the help shows only that command. + helpFor?: "setup" | "audit"; portalId?: string; strict: boolean; fix?: boolean; @@ -24,6 +26,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 +48,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" }, @@ -54,6 +59,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { if (values.help || first === undefined) { return { command: "help", + helpFor: first === "setup" || first === "audit" ? first : undefined, strict: false, tenant: globals.tenant, noColor: globals.noColor, @@ -101,6 +107,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { organization: values.organization, json: Boolean(values.json), noInput: Boolean(values["no-input"]), + dryRun: Boolean(values["dry-run"]), }; } @@ -113,8 +120,8 @@ export function missingFlags(args: ParsedSetupArgs): string[] { return missing; } -export function usage(): string { - return [ +export function usage(command?: "setup" | "audit"): string { + const setup = [ "Usage: auth0 universal-portals setup [flags]", "", "Creates an Auth0 Portal with its application, forms and pages.", @@ -126,9 +133,11 @@ 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.", - "", + ]; + const audit = [ "Usage: auth0 universal-portals audit [flags]", "", "Checks a portal and its application against the setup defaults.", @@ -139,7 +148,10 @@ export function usage(): string { " --strict Also exit 1 when there are warnings.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", - ].join("\n"); + ]; + if (command === "setup") return setup.join("\n"); + if (command === "audit") return audit.join("\n"); + return [...setup, "", ...audit].join("\n"); } function splitGlobalFlags(argv: string[]): { globals: { tenant?: string; noColor: boolean }; rest: string[] } { diff --git a/internal/cli/data/universal-portals/_source/src/audit-command.ts b/internal/cli/data/universal-portals/_source/src/audit-command.ts index 98120067c..36a12cd2a 100644 --- a/internal/cli/data/universal-portals/_source/src/audit-command.ts +++ b/internal/cli/data/universal-portals/_source/src/audit-command.ts @@ -30,23 +30,25 @@ export async function runAuditCommand( const fixable = fixableFindings(report.findings); const applyDeps = { api, ui, readSnapshot }; let result = report; + let fixesFailed = 0; if (args.fix) { // Scripts: apply the errors the CLI can fix, without asking. const errors = fixable.filter((finding) => finding.severity === "error"); if (errors.length > 0) { ui.section("Fix"); - await applyFixRounds(errors, applyDeps, async (exposed) => exposed); + fixesFailed = (await applyFixRounds(errors, applyDeps, async (exposed) => exposed)).failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (prompter && fixable.length > 0) { const selected = await chooseFixes(prompter, fixable, ui); if (selected.length > 0) { ui.section("Fix"); - await applyFixRounds(selected, applyDeps, async (exposed) => { + const applied = await applyFixRounds(selected, applyDeps, async (exposed) => { ui.detail("These errors appeared after the fixes above."); return chooseFixes(prompter, exposed, ui); }); + fixesFailed = applied.failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (fixable.length > 0) { @@ -54,7 +56,8 @@ export async function runAuditCommand( } if (args.json) io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return result.failed ? 1 : 0; + // A fix that failed fails the run, even when the final audit no longer sees the error. + return result.failed || fixesFailed > 0 ? 1 : 0; } catch (error) { if (error instanceof CancelledError && !args.json) { ui.warn("Cancelled. Fixes already applied stay in your tenant."); diff --git a/internal/cli/data/universal-portals/_source/src/audit/fix.ts b/internal/cli/data/universal-portals/_source/src/audit/fix.ts index a6671cbc8..349b6f2a0 100644 --- a/internal/cli/data/universal-portals/_source/src/audit/fix.ts +++ b/internal/cli/data/universal-portals/_source/src/audit/fix.ts @@ -1,4 +1,6 @@ -import type { ApiClient } from "../api.js"; +import { ApiClient, ApiError } from "../api.js"; +import { scopeFixes } from "../scope-fixes.js"; +import { parseScopeGap } from "../setup/preflight.js"; import type { Ui } from "../ui.js"; import { auditPortal, Finding } from "./checks.js"; import type { PortalSnapshot } from "./snapshot.js"; @@ -16,6 +18,8 @@ export interface ApplyResult { failed: number; // Fixes that were no longer needed by the time they ran. alreadyFixed: number; + // Scopes the failed fixes needed, so the fix for them is shown once at the end. + missingScopes: string[]; } // Only errors are fixed. Warnings may be intentional, so they stay in the @@ -32,7 +36,7 @@ export function findingKey(finding: Finding): string { export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise { const { ui } = deps; - const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 }; + const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; for (const finding of selected) { const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding)); @@ -50,6 +54,8 @@ export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise< } catch (error) { result.failed += 1; ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`); + const gap = error instanceof ApiError ? parseScopeGap(label, error) : undefined; + if (gap) result.missingScopes.push(...(gap.requirement === "any" ? gap.scopes.slice(0, 1) : gap.scopes)); } } return result; @@ -67,7 +73,7 @@ export async function applyFixRounds( deps: ApplyDeps, chooseNext: (fixable: Finding[]) => Promise, ): Promise { - const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 }; + const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; const attempted = new Set(); let pending = selected; @@ -77,9 +83,17 @@ export async function applyFixRounds( total.applied += result.applied; total.failed += result.failed; total.alreadyFixed += result.alreadyFixed; + total.missingScopes.push(...result.missingScopes); const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding))); pending = exposed.length > 0 ? await chooseNext(exposed) : []; } + + total.missingScopes = [...new Set(total.missingScopes)].sort(); + if (total.failed > 0) { + const scopes = total.missingScopes.length > 0 ? `, missing: ${total.missingScopes.join(", ")}` : ""; + deps.ui.error(`${total.failed} fix(es) failed${scopes}`); + if (total.missingScopes.length > 0) for (const fix of scopeFixes(total.missingScopes)) deps.ui.detail(fix); + } return total; } diff --git a/internal/cli/data/universal-portals/_source/src/cli-api.ts b/internal/cli/data/universal-portals/_source/src/cli-api.ts index f7b9d48d6..9c39fbbce 100644 --- a/internal/cli/data/universal-portals/_source/src/cli-api.ts +++ b/internal/cli/data/universal-portals/_source/src/cli-api.ts @@ -5,7 +5,7 @@ import { spawn } from "node:child_process"; -import { ApiClient, HttpMethod, toApiError } from "./api.js"; +import { ApiClient, ApiError, HttpMethod, toApiError } from "./api.js"; import { PluginError } from "./plugin-error.js"; export interface CommandResult { @@ -125,18 +125,23 @@ function explainFailure(stderr: string, what: string, tenant: string | undefined // An API error comes first: its body can quote any text, including a scope message. The body runs // to the end of the output, because the CLI prints it over several lines when it is long. const api = /(?; + 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/scope-fixes.ts b/internal/cli/data/universal-portals/_source/src/scope-fixes.ts index 2323f023c..ac5504bb3 100644 --- a/internal/cli/data/universal-portals/_source/src/scope-fixes.ts +++ b/internal/cli/data/universal-portals/_source/src/scope-fixes.ts @@ -1,9 +1,12 @@ -// What to do when the session lacks a scope. The advice depends on how the Auth0 CLI is signed in: -// a user login can ask for more scopes, while a client-credentials login (machine to machine) has -// only the scopes granted to its application, so they must be added there. -export function scopeFixes(scopes: string[]): string[] { +import { PLUGIN_SCOPES } from "./scopes.js"; + +// What to do when the session lacks a scope. A user login asks for every scope the tool uses, not +// only the missing ones, because `auth0 login --scopes` drops the extra scopes of the last login. +// A client-credentials login keeps its grant, so only the missing scopes need adding there. +export function scopeFixes(missing: string[]): string[] { + const login = [...new Set([...PLUGIN_SCOPES, ...missing])]; return [ - `Signed in as a user: auth0 login --scopes ${scopes.join(",")}`, - `Signed in with client credentials: add ${scopes.join(", ")} to the application's Management API grant`, + `Signed in as a user: auth0 login --scopes ${login.join(",")}`, + `Signed in with client credentials: add ${missing.join(", ")} to the application's Management API grant`, ]; } diff --git a/internal/cli/data/universal-portals/_source/src/scopes.ts b/internal/cli/data/universal-portals/_source/src/scopes.ts index 4a73e031f..90b353244 100644 --- a/internal/cli/data/universal-portals/_source/src/scopes.ts +++ b/internal/cli/data/universal-portals/_source/src/scopes.ts @@ -1,5 +1,5 @@ -// Management API scopes the plugin's session needs. The CLI proxy enforces this -// list through its scope gate, so a scope missing here fails at the proxy. +// Management API scopes the tool's session needs. The login command in scope-fixes.ts asks for all +// of them, because a user login keeps only the scopes of its own --scopes flag. export const PLUGIN_SCOPES: string[] = [ "read:clients", "create:clients", @@ -14,6 +14,9 @@ export const PLUGIN_SCOPES: string[] = [ "read:flows", "create:flows", "update:flows", + "read:flows_vault_connections", + "create:flows_vault_connections", + "update:flows_vault_connections", "read:forms", "create:forms", "read:portals", @@ -23,7 +26,11 @@ export const PLUGIN_SCOPES: string[] = [ "read:users", "read:organizations", "create:organizations", + "read:organization_members", + "create:organization_members", + "create:organization_connections", "read:connections", "read:roles", "create:roles", + "update:roles", ]; 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..f0fbafa34 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, + probeApi: tracked.api, }); + 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,22 @@ export async function runSetupCommand( } } +// The statuses in a dry run are what a real run would report. The permission check runs for real, +// because its probes are rejected and change nothing. +const DRY_RUN_NOTE = "Dry run: nothing is created or changed. Statuses show what a real run would do."; + +// 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/preflight.ts b/internal/cli/data/universal-portals/_source/src/setup/preflight.ts index f456d702a..3b3997394 100644 --- a/internal/cli/data/universal-portals/_source/src/setup/preflight.ts +++ b/internal/cli/data/universal-portals/_source/src/setup/preflight.ts @@ -1,8 +1,8 @@ import { ApiClient, ApiError, HttpMethod } from "../api.js"; import { PluginError } from "../plugin-error.js"; -// One request per capability setup needs. A 2xx or 400 means the scope is -// present. A 403 names the scope it wants, so every gap is reported at once. +// One request per capability setup needs. A 2xx or 400 (or a 404 for a fake target) means +// the scope is present. A 403 names the scope it wants, so every gap is reported at once. // The create probes send an empty body. Checked on the live API on 2026-10-23: // each one returned 400 for the missing required property and created nothing. export interface Probe { @@ -12,8 +12,17 @@ export interface Probe { body?: unknown; // Only used by the business template. businessOnly?: boolean; + // The path names a resource that does not exist, so a 404 also means the scope is present. + fakeTarget?: boolean; } +// IDs shaped like real ones, for probes on a single resource. Nothing has these IDs, so the +// probe can never change anything. +const FAKE_GRANT = "cgr_preflight0000000"; +const FAKE_VAULT = "ac_preflight0000000000000"; +const FAKE_ORGANIZATION = "org_preflight0000000"; +const FAKE_ROLE = "rol_preflight0000000"; + export const SETUP_PROBES: Probe[] = [ { name: "list resource servers", method: "GET", path: "resource-servers?per_page=1" }, { name: "create resource server", method: "POST", path: "resource-servers", body: {} }, @@ -21,8 +30,17 @@ export const SETUP_PROBES: Probe[] = [ { name: "create client", method: "POST", path: "clients", body: {} }, { name: "list client grants", method: "GET", path: "client-grants?per_page=1" }, { name: "create client grant", method: "POST", path: "client-grants", body: {} }, + // A rerun updates the grants and the vault connection it finds. + { name: "update client grant", method: "PATCH", path: `client-grants/${FAKE_GRANT}`, body: {}, fakeTarget: true }, { name: "list vault connections", method: "GET", path: "flows/vault/connections" }, { name: "create vault connection", method: "POST", path: "flows/vault/connections", body: {} }, + { + name: "update vault connection", + method: "PATCH", + path: `flows/vault/connections/${FAKE_VAULT}`, + body: {}, + fakeTarget: true, + }, { name: "list forms", method: "GET", path: "forms?per_page=1" }, { name: "import forms", method: "POST", path: "forms/import", body: {} }, { name: "list portals", method: "GET", path: "portals" }, @@ -34,6 +52,33 @@ export const SETUP_PROBES: Probe[] = [ businessOnly: true, }, { name: "list organizations", method: "GET", path: "organizations?per_page=1", businessOnly: true }, + // Business setup can create an organization and a role, then link the user and the connection. + { name: "create organization", method: "POST", path: "organizations", body: {}, businessOnly: true }, + { + name: "enable organization connection", + method: "POST", + path: `organizations/${FAKE_ORGANIZATION}/enabled_connections`, + body: {}, + businessOnly: true, + fakeTarget: true, + }, + { + name: "add organization member", + method: "POST", + path: `organizations/${FAKE_ORGANIZATION}/members`, + body: {}, + businessOnly: true, + fakeTarget: true, + }, + { name: "create role", method: "POST", path: "roles", body: {}, businessOnly: true }, + { + name: "add role permissions", + method: "POST", + path: `roles/${FAKE_ROLE}/permissions`, + body: {}, + businessOnly: true, + fakeTarget: true, + }, ]; export interface ScopeGap { @@ -83,6 +128,7 @@ export async function runPreflight( const { probe, error } = failure; if (!(error instanceof ApiError)) throw error; if (error.status === 400) continue; + if (error.status === 404 && probe.fakeTarget) continue; if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); const gap = parseScopeGap(probe.name, error); if (gap) { 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..5978b5c17 100644 --- a/internal/cli/data/universal-portals/_source/src/setup/run.ts +++ b/internal/cli/data/universal-portals/_source/src/setup/run.ts @@ -47,17 +47,30 @@ export class SlugRaceError extends PluginError { } } +// When any one of several scopes is enough, the first is the one to ask for. export class MissingScopesError extends PluginError { constructor( readonly scopes: string[], readonly choices: ScopeGap[], ) { + const needs = [...scopes, ...choices.map((choice) => `one of ${choice.scopes.join(" or ")}`)]; super( - scopes.length > 0 - ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` - : "The CLI session is missing a scope it needs.", + `The CLI session is missing ${needs.length} permission(s) setup needs: ${needs.join(", ")}. Nothing was created.`, "missing_scopes", - scopes.length > 0 ? scopeFixes(scopes) : undefined, + needs.length > 0 ? scopeFixes([...scopes, ...choices.map((choice) => choice.scopes[0])]) : undefined, + ); + } +} + +// A 403 that names no scope. The Management API sends one when Universal Portals is not enabled, +// and the CLI can drop the reason, so setup stops here rather than after creating resources. +export class PermissionCheckError extends PluginError { + constructor(probes: string[], tenantDomain: string) { + super( + `The Management API refused ${probes.map((probe) => `"${probe}"`).join(", ")} with HTTP 403 and gave no reason. ` + + `Nothing was created. Usually Universal Portals is not enabled for ${tenantDomain}, or the session is missing a permission.`, + "permission_check_failed", + ["Check that Universal Portals is enabled for this tenant.", ...scopeFixes([])], ); } } @@ -76,6 +89,9 @@ export interface SetupDeps { prompt: Prompter; interactive: boolean; tenantDomain: string; + // Sends the permission check. A dry run passes the real client here, because the probes + // change nothing. Defaults to api. + probeApi?: ApiClient; } export interface SetupResult { @@ -217,20 +233,25 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise< async function checkPermissions(probes: Probe[], deps: SetupDeps): Promise { deps.ui.section("Permissions"); - const result = await deps.ui.task("Checking permissions", () => runPreflight(deps.api, probes, deps.tenantDomain)); - for (const issue of result.unexpected) { - deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message} (HTTP ${issue.status})`); - } + const api = deps.probeApi ?? deps.api; + const result = await deps.ui.task("Checking permissions", () => runPreflight(api, probes, deps.tenantDomain)); + const refused = result.unexpected.filter((issue) => issue.status === 403); + if (refused.length > 0) throw new PermissionCheckError(refused.map((issue) => issue.probe), deps.tenantDomain); + const required = requiredMissingScopes(result); const choices = choiceGaps(result); - if (required.length === 0 && choices.length === 0) { - deps.ui.result("The CLI session has every permission setup needs"); - return; + // The error lists the scopes and the fixes once, through reportError. + if (required.length > 0 || choices.length > 0) throw new MissingScopesError(required, choices); + + for (const issue of result.unexpected) { + const status = issue.message.includes(String(issue.status)) ? "" : ` (HTTP ${issue.status})`; + deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message}${status}`); } - deps.ui.error("The CLI session is missing permissions setup needs"); - for (const scope of required) deps.ui.detail(scope); - for (const choice of choices) deps.ui.detail(`one of: ${choice.scopes.join(", ")}`); - throw new MissingScopesError(required, choices); + deps.ui.result( + result.unexpected.length > 0 + ? "No permission is missing, but the checks above could not be verified" + : "The CLI session has every permission setup needs", + ); } function resourceServersFor(template: TemplateId, tenantDomain: string): { name: string; identifier: string }[] { diff --git a/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts b/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts index 0c8700f16..aaf02895b 100644 --- a/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts +++ b/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { runAudit } from "../src/audit/run.js"; -import { applyFixes, findingKey, fixableFindings } from "../src/audit/fix.js"; +import { applyFixes, applyFixRounds, findingKey, fixableFindings } from "../src/audit/fix.js"; import { auditPortal } from "../src/audit/checks.js"; import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; import { runSetup } from "../src/setup/run.js"; @@ -119,3 +119,20 @@ test("warnings are reported but never offered for fixing", async () => { assert.equal(fixableFindings(findings).length, 0, "warnings are never fixable"); assert.ok(findings.some((finding) => finding.id === "P017" && finding.severity === "warning")); }); + +test("fixes that fail for a missing scope are summed up once, with the login command", async () => { + const tenant = new FakeTenant(); + const { portalId } = await createPortal(tenant); + tenant.portals.get(portalId)!.pages.default = undefined; + tenant.deny(`PATCH portals/${portalId}`, "Insufficient scope, expected any of: update:portals"); + + const captured = captureOutput(); + const readSnapshot = () => fetchPortalSnapshot(tenant, portalId, DOMAIN); + const errors = fixableFindings(auditPortal(await readSnapshot())).filter((finding) => finding.severity === "error"); + const result = await applyFixRounds(errors, { api: tenant, ui: new Ui(captured.stream, false, false), readSnapshot }, async () => []); + + assert.ok(result.failed > 0); + assert.deepEqual(result.missingScopes, ["update:portals"]); + assert.match(captured.text(), /fix\(es\) failed, missing: update:portals/); + assert.match(captured.text(), /auth0 login --scopes \S*update:portals/); +}); diff --git a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts index b2d06eb1f..c5a8840ab 100644 --- a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts @@ -106,7 +106,7 @@ test("a 404 becomes an ApiError with the status, code and message from the API", assert.ok(error instanceof ApiError); assert.equal(error.status, 404); assert.equal(error.errorCode, "inexistent_client"); - assert.equal(error.message, "The client does not exist"); + assert.equal(error.message, "auth0 api GET clients/x returned HTTP 404: The client does not exist"); }); test("a 400 and a 409 keep their status and error code", async () => { @@ -137,7 +137,7 @@ test("an API error body printed over several lines keeps its status, code and me const error = await failure(`\n=== acme.us.auth0.com error\n\n ▸ 404: API request failed: ${body}.\n\n`); assert.ok(error instanceof ApiError); - assert.deepEqual([error.status, error.errorCode, error.message], [404, "inexistent_portal", "The portal does not exist"]); + assert.deepEqual([error.status, error.errorCode, error.message], [404, "inexistent_portal", "auth0 api GET clients/x returned HTTP 404: The portal does not exist"]); }); test("a number that only ends in three digits is not read as a status", async () => { @@ -152,7 +152,23 @@ test("a missing scope becomes the 403 the preflight already understands", async assert.ok(error instanceof ApiError); assert.equal(error.status, 403); assert.equal(error.errorCode, "insufficient_scope"); - assert.equal(error.message, "Insufficient scope, expected any of: read:portals"); + assert.equal(error.message, "auth0 api GET clients/x returned HTTP 403: Insufficient scope, expected any of: read:portals"); +}); + +test("a missing scope the CLI does not name is still a 403 about scopes", async () => { + const error = await failure("\n=== acme.us.auth0.com error\n\n ▸ Request failed because access token lacks scope: .\n\n"); + + assert.ok(error instanceof ApiError); + assert.deepEqual([error.status, error.errorCode], [403, "insufficient_scope"]); + assert.match(error.message, /The Auth0 CLI did not say which/); +}); + +test("a 403 with no JSON body keeps the CLI's text and names the request", async () => { + const error = await failure("\n=== acme.us.auth0.com error\n\n ▸ 403: API request failed: Forbidden.\n\n"); + + assert.ok(error instanceof ApiError); + assert.equal(error.status, 403); + assert.equal(error.message, "auth0 api GET clients/x returned HTTP 403: Forbidden."); }); test("an expired session or an unknown tenant asks the user to log in", async () => { 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..d957fefaf 100644 --- a/internal/cli/data/universal-portals/_source/test/cli.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -14,8 +14,13 @@ import { scopeFixes } from "../src/scope-fixes.js"; import { toPortalSlug } from "../src/slug.js"; import type { Prompter } from "../src/prompt.js"; import { captureOutput } from "./capture-output.js"; +import { SETUP_PROBES } from "../src/setup/preflight.js"; import { FakeTenant } from "./fake-tenant.js"; +// The writes a dry run may send: the permission probes, which the API rejects and which change nothing. +const PROBE_WRITES = new Set(SETUP_PROBES.filter((probe) => probe.method !== "GET").map((probe) => `${probe.method} ${probe.path}`)); +const writesBeyondProbes = (calls: string[]) => calls.filter((call) => !call.startsWith("GET ") && !PROBE_WRITES.has(call)); + const DOMAIN = "acme.us.auth0.com"; const ENV = { AUTH0_CLI_PROXY_URL: "https://127.0.0.1:4433", @@ -82,6 +87,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"/); @@ -99,6 +109,8 @@ test("audit takes exactly one portal ID, plus --organization and --strict", () = test("no command prints help", () => { assert.equal(parseSetupArgs([]).command, "help"); assert.equal(parseSetupArgs(["--help"]).command, "help"); + assert.equal(parseSetupArgs(["setup", "--help"]).helpFor, "setup"); + assert.equal(parseSetupArgs(["audit", "-h"]).helpFor, "audit"); }); test("missingFlags names what a non-interactive run still needs", () => { @@ -244,7 +256,7 @@ test("audit tells the user how to get a missing scope, for a user login and a cl const code = await main(["audit", portalId], ENV, io); assert.equal(code, 1); - assert.match(stderr(), /Signed in as a user: auth0 login --scopes read:portals/); + assert.match(stderr(), /Signed in as a user: auth0 login --scopes \S*read:portals/); assert.match(stderr(), /Signed in with client credentials: add read:portals to the application's Management API grant/); }); @@ -381,6 +393,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(writesBeyondProbes(tenant.calls), [], "only the permission probes, which change nothing"); + 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(writesBeyondProbes(tenant.calls), [], "only the permission probes, which change nothing"); +}); + +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"); @@ -436,3 +500,25 @@ test("a --tenant that differs from the CLI's tenant stops the run before any API assert.match(stderr(), /does not match the tenant/); assert.equal(tenant.calls.length, 0); }); + +test("each command's --help shows only that command", async () => { + for (const [command, other] of [["setup", "audit"], ["audit", "setup"]]) { + const { io, stdout } = captureIo(fetchFor(new FakeTenant())); + assert.equal(await main([command, "--help"], ENV, io), 0); + assert.match(stdout(), new RegExp(`Usage: auth0 universal-portals ${command}`)); + assert.doesNotMatch(stdout(), new RegExp(`Usage: auth0 universal-portals ${other}`)); + } +}); + +test("AUTH0_AGENT_MODE prints the result as JSON on stdout without --json", async () => { + const tenant = new FakeTenant(); + const { io, stdout } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--dry-run", "--template", "consumer", "--name", "Acme Account"], + { ...ENV, AUTH0_AGENT_MODE: "true" }, + io, + ); + + assert.equal(code, 0); + assert.equal(JSON.parse(stdout()).dryRun, true); +}); diff --git a/internal/cli/data/universal-portals/_source/test/errors.test.ts b/internal/cli/data/universal-portals/_source/test/errors.test.ts index b81a58fed..2ac0a209d 100644 --- a/internal/cli/data/universal-portals/_source/test/errors.test.ts +++ b/internal/cli/data/universal-portals/_source/test/errors.test.ts @@ -16,12 +16,7 @@ test("each error reports its own code, and the fixes it names", () => { assert.deepEqual(describeError(new BusinessAccessError("no user", ["Rerun with --user."])).details, { fixes: ["Rerun with --user."], }); - assert.deepEqual(describeError(new MissingScopesError(["read:clients"], [])).details, { - fixes: [ - "Signed in as a user: auth0 login --scopes read:clients", - "Signed in with client credentials: add read:clients to the application's Management API grant", - ], - }); + assert.deepEqual(describeError(new MissingScopesError(["read:clients"], [])).details, { fixes: scopeFixes(["read:clients"]) }); }); test("an error with no fixes leaves fixes out of the JSON", () => { diff --git a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts index 5e81d5432..4ab00b588 100644 --- a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts +++ b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts @@ -144,7 +144,8 @@ export class FakeTenant implements ApiClient { return undefined; } if (method === "PATCH" && parts[0] === "client-grants" && parts.length === 2) { - Object.assign(this.grants.get(parts[1]) ?? {}, body); + if (!this.grants.has(parts[1])) throw toApiError(404, { message: "The grant does not exist" }); + Object.assign(this.grants.get(parts[1])!, body); return this.grants.get(parts[1]); } diff --git a/internal/cli/data/universal-portals/_source/test/preflight.test.ts b/internal/cli/data/universal-portals/_source/test/preflight.test.ts index 90f133733..c0e37288a 100644 --- a/internal/cli/data/universal-portals/_source/test/preflight.test.ts +++ b/internal/cli/data/universal-portals/_source/test/preflight.test.ts @@ -91,6 +91,20 @@ test("a 403 without a scope message is kept as unexpected, not as a scope gap", assert.equal(result.unexpected[0].status, 403); }); +test("a 404 means the scope is present only for a probe on a fake resource", async () => { + const probes: Probe[] = [ + { name: "update client grant", method: "PATCH", path: "client-grants/cgr_fake", body: {}, fakeTarget: true }, + { name: "list portals", method: "GET", path: "portals" }, + ]; + const api = tenantWith({ + "PATCH client-grants/cgr_fake": { status: 404, body: { message: "The grant does not exist" } }, + "GET portals": { status: 404, body: { message: "Not Found" } }, + }); + const result = await runPreflight(api, probes, "acme.us.auth0.com"); + + assert.deepEqual(result.unexpected.map((issue) => issue.probe), ["list portals"]); +}); + test("parseScopeGap ignores errors that are not 403", () => { const error = new ApiError(500, undefined, "expected any of: create:clients", {}); assert.equal(parseScopeGap("create client", error), undefined); 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..885afa797 100644 --- a/internal/cli/data/universal-portals/_source/test/run.test.ts +++ b/internal/cli/data/universal-portals/_source/test/run.test.ts @@ -4,12 +4,18 @@ import { test } from "node:test"; import { CancelledError, type Prompter } from "../src/prompt.js"; import { Ui } from "../src/ui.js"; import { BusinessAccessError } from "../src/setup/business-types.js"; -import { MissingScopesError, runSetup, SetupDeps, SetupOptions } from "../src/setup/run.js"; +import { MissingScopesError, PermissionCheckError, 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 { SETUP_PROBES } from "../src/setup/preflight.js"; import { FakeTenant } from "./fake-tenant.js"; import { captureOutput } from "./capture-output.js"; +// The writes a dry run may send: the permission probes, which the API rejects and which change nothing. +const PROBE_WRITES = new Set(SETUP_PROBES.filter((probe) => probe.method !== "GET").map((probe) => `${probe.method} ${probe.path}`)); +const writesBeyondProbes = (calls: string[]) => calls.filter((call) => !call.startsWith("GET ") && !PROBE_WRITES.has(call)); + const DOMAIN = "acme.us.auth0.com"; function harness( @@ -200,16 +206,53 @@ test("a taken slug is re-prompted when a terminal is available", async () => { test("missing scopes stop setup before anything is created and name what to grant", async () => { const tenant = new FakeTenant(); tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); - tenant.deny("POST forms/import", "Insufficient scope, expected all of: create:forms,create:flows"); + tenant.deny( + "POST forms/import", + "Insufficient scope, expected all of: create:forms,create:flows,create:flows_vault_connections,read:flows_vault_connections", + ); await assert.rejects(runSetup(CONSUMER, harness(tenant).deps), (error: unknown) => { assert.ok(error instanceof MissingScopesError); - assert.deepEqual(error.scopes, ["create:flows", "create:forms", "create:portals"]); + assert.deepEqual(error.scopes, [ + "create:flows", + "create:flows_vault_connections", + "create:forms", + "create:portals", + "read:flows_vault_connections", + ]); return true; }); assert.equal(tenant.clients.size, 0, "preflight must run before any create"); }); +test("a 403 that names no scope stops setup before anything is created", async () => { + const tenant = new FakeTenant(); + tenant.failNext("GET portals", 403); + const { deps, output } = harness(tenant); + + await assert.rejects(runSetup(CONSUMER, deps), (error: unknown) => { + assert.ok(error instanceof PermissionCheckError); + assert.match(error.message, /"list portals" with HTTP 403.*Nothing was created.*not enabled for acme\.us\.auth0\.com/); + return true; + }); + assert.equal(tenant.clients.size + tenant.resourceServers.size, 0, "nothing is created after the refusal"); + assert.doesNotMatch(output(), /every permission/, "a refused check is never reported as a pass"); +}); + +test("missing scopes are listed once, by the error, with one login command", async () => { + const tenant = new FakeTenant(); + tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); + const { deps, output } = harness(tenant); + + await assert.rejects(runSetup(CONSUMER, deps), (error: unknown) => { + assert.ok(error instanceof MissingScopesError); + assert.match(error.message, /missing 1 permission\(s\) setup needs: create:portals\. Nothing was created\./); + assert.equal(error.fixes?.filter((fix) => fix.includes("auth0 login --scopes")).length, 1); + return true; + }); + assert.doesNotMatch(output(), /create:portals/, "the scopes are not printed before the error reports them"); +}); + test("a custom domain is used for the portal URL and the back-channel logout", async () => { const tenant = new FakeTenant(); tenant.customDomain = { domain: "auth.acme.com", status: "ready" }; @@ -394,3 +437,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, probeApi: tenant }); + 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(writesBeyondProbes(tenant.calls), [], "a dry run sends no write beyond the permission probes"); + 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 go to the tenant, not into the plan", + ); +}); + +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, probeApi: tenant }); + const result = await runSetup(CONSUMER, deps); + + assert.equal(result.application.status, "reused"); + assert.equal(result.portal.status, "created"); + assert.deepEqual(writesBeyondProbes(tenant.calls.slice(callsBefore)), []); + + 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, probeApi: tenant }); + 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(writesBeyondProbes(tenant.calls), [], "a dry run sends no write beyond the permission probes"); + assert.equal(tenant.portals.size, 0); + assert.ok(plan.requests().some((request) => request.path === "resource-servers")); +}); diff --git a/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts b/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts index 56f240e3a..a3e83f64a 100644 --- a/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts +++ b/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts @@ -2,10 +2,24 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { scopeFixes } from "../src/scope-fixes.js"; +import { PLUGIN_SCOPES } from "../src/scopes.js"; -test("a missing scope has one fix for a user login and one for a client-credentials login", () => { - assert.deepEqual(scopeFixes(["read:portals", "create:portals"]), [ - "Signed in as a user: auth0 login --scopes read:portals,create:portals", +test("a user login asks for every scope the tool uses, so it does not drop the ones granted before", () => { + const [login] = scopeFixes(["read:portals"]); + const scopes = login.replace("Signed in as a user: auth0 login --scopes ", "").split(","); + + assert.deepEqual(scopes, PLUGIN_SCOPES); +}); + +test("a scope outside the tool's list is still asked for", () => { + const [login] = scopeFixes(["read:something_new"]); + + assert.ok(login.endsWith(",read:something_new")); +}); + +test("a client-credentials login only needs the missing scopes added to its grant", () => { + assert.equal( + scopeFixes(["read:portals", "create:portals"])[1], "Signed in with client credentials: add read:portals, create:portals to the application's Management API grant", - ]); + ); }); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index ae35bd3e5..780bb23f6 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: 52f998e5b79a1293b85e19b7f1c66facbd057186637fd35d3ef516f9eef6967d 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" } @@ -137,6 +138,7 @@ function parseSetupArgs(argv) { if (values.help || first === void 0) { return { command: "help", + helpFor: first === "setup" || first === "audit" ? first : void 0, strict: false, tenant: globals.tenant, noColor: globals.noColor, @@ -181,7 +183,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) { @@ -191,8 +194,8 @@ function missingFlags(args) { if (args.template === "business" && !args.user) missing.push("--user"); return missing; } -function usage() { - return [ +function usage(command) { + const setup = [ "Usage: auth0 universal-portals setup [flags]", "", "Creates an Auth0 Portal with its application, forms and pages.", @@ -204,9 +207,11 @@ 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.", - "", + " -h, --help Show this help." + ]; + const audit = [ "Usage: auth0 universal-portals audit [flags]", "", "Checks a portal and its application against the setup defaults.", @@ -217,7 +222,10 @@ function usage() { " --strict Also exit 1 when there are warnings.", " --json Print the result as JSON on stdout.", " -h, --help Show this help." - ].join("\n"); + ]; + if (command === "setup") return setup.join("\n"); + if (command === "audit") return audit.join("\n"); + return [...setup, "", ...audit].join("\n"); } function splitGlobalFlags(argv) { const globals = { noColor: false }; @@ -1747,7 +1755,8 @@ function createApiClient(baseUrl, token, fetchImpl = fetch) { } function toApiError(status, body) { const record = isRecord(body) ? body : {}; - const message = typeof record.message === "string" ? record.message : `HTTP ${status}`; + const text2 = typeof body === "string" ? body.trim() : ""; + const message = typeof record.message === "string" ? record.message : text2 || `HTTP ${status}`; const errorCode = typeof record.errorCode === "string" ? record.errorCode : void 0; return new ApiError(status, errorCode, message, body); } @@ -1867,21 +1876,29 @@ function parseOutput(stdout2, what) { } function explainFailure(stderr, what, tenant) { const api = /(? { + try { + await api.request(probe.method, probe.path, probe.body); + return void 0; + } catch (error) { + return { probe, error }; + } + }) + ); + for (const failure of failures) { + if (!failure) continue; + const { probe, error } = failure; + if (!(error instanceof ApiError)) throw error; + if (error.status === 400) continue; + if (error.status === 404 && probe.fakeTarget) continue; + if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); + const gap = parseScopeGap(probe.name, error); + if (gap) { + gaps.push(gap); + } else { + unexpected.push({ probe: probe.name, status: error.status, message: error.message }); + } + } + return { gaps, unexpected }; +} +function parseScopeGap(probe, error) { + if (error.status !== 403) return void 0; + const match = /expected (any|all) of:\s*(.+)$/.exec(error.message); + if (!match) return void 0; + const requirement = match[1] === "all" ? "all" : "any"; + const scopes = match[2].split(",").map((scope) => scope.trim()).filter(Boolean); + return { probe, requirement, scopes }; +} +function requiredMissingScopes(result) { + const required = result.gaps.filter((gap) => gap.requirement === "all" || gap.scopes.length === 1); + return [...new Set(required.flatMap((gap) => gap.scopes))].sort(); +} +function choiceGaps(result) { + return result.gaps.filter((gap) => gap.requirement === "any" && gap.scopes.length > 1); +} + // src/templates/icons.ts var SIDEBAR_ICONS = { profile: "user", @@ -3032,7 +3211,7 @@ function findingKey(finding) { } async function applyFixes(selected, deps) { const { ui } = deps; - const result = { applied: 0, failed: 0, alreadyFixed: 0 }; + const result = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; for (const finding of selected) { const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding)); if (!fresh?.remedy?.apply) { @@ -3048,13 +3227,15 @@ async function applyFixes(selected, deps) { } catch (error) { result.failed += 1; ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`); + const gap = error instanceof ApiError ? parseScopeGap(label, error) : void 0; + if (gap) result.missingScopes.push(...gap.requirement === "any" ? gap.scopes.slice(0, 1) : gap.scopes); } } return result; } var MAX_FIX_ROUNDS = 3; async function applyFixRounds(selected, deps, chooseNext) { - const total = { applied: 0, failed: 0, alreadyFixed: 0 }; + const total = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; const attempted = /* @__PURE__ */ new Set(); let pending = selected; for (let round = 0; round < MAX_FIX_ROUNDS && pending.length > 0; round += 1) { @@ -3063,9 +3244,16 @@ async function applyFixRounds(selected, deps, chooseNext) { total.applied += result.applied; total.failed += result.failed; total.alreadyFixed += result.alreadyFixed; + total.missingScopes.push(...result.missingScopes); const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding))); pending = exposed.length > 0 ? await chooseNext(exposed) : []; } + total.missingScopes = [...new Set(total.missingScopes)].sort(); + if (total.failed > 0) { + const scopes = total.missingScopes.length > 0 ? `, missing: ${total.missingScopes.join(", ")}` : ""; + deps.ui.error(`${total.failed} fix(es) failed${scopes}`); + if (total.missingScopes.length > 0) for (const fix of scopeFixes(total.missingScopes)) deps.ui.detail(fix); + } return total; } @@ -3111,90 +3299,6 @@ async function runAudit(options, deps) { }; } -// src/scope-fixes.ts -function scopeFixes(scopes) { - return [ - `Signed in as a user: auth0 login --scopes ${scopes.join(",")}`, - `Signed in with client credentials: add ${scopes.join(", ")} to the application's Management API grant` - ]; -} - -// src/setup/preflight.ts -var SETUP_PROBES = [ - { name: "list resource servers", method: "GET", path: "resource-servers?per_page=1" }, - { name: "create resource server", method: "POST", path: "resource-servers", body: {} }, - { name: "list clients", method: "GET", path: "clients?per_page=1" }, - { name: "create client", method: "POST", path: "clients", body: {} }, - { name: "list client grants", method: "GET", path: "client-grants?per_page=1" }, - { name: "create client grant", method: "POST", path: "client-grants", body: {} }, - { name: "list vault connections", method: "GET", path: "flows/vault/connections" }, - { name: "create vault connection", method: "POST", path: "flows/vault/connections", body: {} }, - { name: "list forms", method: "GET", path: "forms?per_page=1" }, - { name: "import forms", method: "POST", path: "forms/import", body: {} }, - { name: "list portals", method: "GET", path: "portals" }, - { name: "create portal", method: "POST", path: "portals", body: {} }, - { - name: "look up users by email", - method: "GET", - path: "users?q=email%3A%22preflight%40example.invalid%22&search_engine=v3", - businessOnly: true - }, - { name: "list organizations", method: "GET", path: "organizations?per_page=1", businessOnly: true } -]; -var FeatureNotEnabledError = class extends PluginError { - constructor(tenantDomain) { - super( - `Universal Portals is not enabled for ${tenantDomain}. Ask Auth0 to enable it for this tenant, then run the command again.`, - "feature_not_enabled" - ); - this.tenantDomain = tenantDomain; - } - tenantDomain; -}; -async function runPreflight(api, probes, tenantDomain) { - const gaps = []; - const unexpected = []; - const failures = await Promise.all( - probes.map(async (probe) => { - try { - await api.request(probe.method, probe.path, probe.body); - return void 0; - } catch (error) { - return { probe, error }; - } - }) - ); - for (const failure of failures) { - if (!failure) continue; - const { probe, error } = failure; - if (!(error instanceof ApiError)) throw error; - if (error.status === 400) continue; - if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); - const gap = parseScopeGap(probe.name, error); - if (gap) { - gaps.push(gap); - } else { - unexpected.push({ probe: probe.name, status: error.status, message: error.message }); - } - } - return { gaps, unexpected }; -} -function parseScopeGap(probe, error) { - if (error.status !== 403) return void 0; - const match = /expected (any|all) of:\s*(.+)$/.exec(error.message); - if (!match) return void 0; - const requirement = match[1] === "all" ? "all" : "any"; - const scopes = match[2].split(",").map((scope) => scope.trim()).filter(Boolean); - return { probe, requirement, scopes }; -} -function requiredMissingScopes(result) { - const required = result.gaps.filter((gap) => gap.requirement === "all" || gap.scopes.length === 1); - return [...new Set(required.flatMap((gap) => gap.scopes))].sort(); -} -function choiceGaps(result) { - return result.gaps.filter((gap) => gap.requirement === "any" && gap.scopes.length > 1); -} - // src/errors.ts function reportError(error, json, io, ui) { const { code, message, details } = describeError(error); @@ -3235,21 +3339,23 @@ async function runAuditCommand(args, env, io, ui, prompter) { const fixable = fixableFindings(report.findings); const applyDeps = { api, ui, readSnapshot }; let result = report; + let fixesFailed = 0; if (args.fix) { const errors = fixable.filter((finding) => finding.severity === "error"); if (errors.length > 0) { ui.section("Fix"); - await applyFixRounds(errors, applyDeps, async (exposed) => exposed); + fixesFailed = (await applyFixRounds(errors, applyDeps, async (exposed) => exposed)).failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (prompter && fixable.length > 0) { const selected = await chooseFixes(prompter, fixable, ui); if (selected.length > 0) { ui.section("Fix"); - await applyFixRounds(selected, applyDeps, async (exposed) => { + const applied = await applyFixRounds(selected, applyDeps, async (exposed) => { ui.detail("These errors appeared after the fixes above."); return chooseFixes(prompter, exposed, ui); }); + fixesFailed = applied.failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (fixable.length > 0) { @@ -3257,7 +3363,7 @@ async function runAuditCommand(args, env, io, ui, prompter) { } if (args.json) io.stdout.write(`${JSON.stringify(result, null, 2)} `); - return result.failed ? 1 : 0; + return result.failed || fixesFailed > 0 ? 1 : 0; } catch (error) { if (error instanceof CancelledError && !args.json) { ui.warn("Cancelled. Fixes already applied stay in your tenant."); @@ -3304,6 +3410,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("."); @@ -3888,10 +4036,11 @@ var SlugRaceError = class extends PluginError { }; var MissingScopesError = class extends PluginError { constructor(scopes, choices) { + const needs = [...scopes, ...choices.map((choice) => `one of ${choice.scopes.join(" or ")}`)]; super( - scopes.length > 0 ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` : "The CLI session is missing a scope it needs.", + `The CLI session is missing ${needs.length} permission(s) setup needs: ${needs.join(", ")}. Nothing was created.`, "missing_scopes", - scopes.length > 0 ? scopeFixes(scopes) : void 0 + needs.length > 0 ? scopeFixes([...scopes, ...choices.map((choice) => choice.scopes[0])]) : void 0 ); this.scopes = scopes; this.choices = choices; @@ -3899,6 +4048,15 @@ var MissingScopesError = class extends PluginError { scopes; choices; }; +var PermissionCheckError = class extends PluginError { + constructor(probes, tenantDomain) { + super( + `The Management API refused ${probes.map((probe) => `"${probe}"`).join(", ")} with HTTP 403 and gave no reason. Nothing was created. Usually Universal Portals is not enabled for ${tenantDomain}, or the session is missing a permission.`, + "permission_check_failed", + ["Check that Universal Portals is enabled for this tenant.", ...scopeFixes([])] + ); + } +}; async function runSetup(options, deps) { const resources = []; const { ui, api, tenantDomain } = deps; @@ -4011,20 +4169,20 @@ async function runSetup(options, deps) { } async function checkPermissions(probes, deps) { deps.ui.section("Permissions"); - const result = await deps.ui.task("Checking permissions", () => runPreflight(deps.api, probes, deps.tenantDomain)); - for (const issue of result.unexpected) { - deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message} (HTTP ${issue.status})`); - } + const api = deps.probeApi ?? deps.api; + const result = await deps.ui.task("Checking permissions", () => runPreflight(api, probes, deps.tenantDomain)); + const refused = result.unexpected.filter((issue) => issue.status === 403); + if (refused.length > 0) throw new PermissionCheckError(refused.map((issue) => issue.probe), deps.tenantDomain); const required = requiredMissingScopes(result); const choices = choiceGaps(result); - if (required.length === 0 && choices.length === 0) { - deps.ui.result("The CLI session has every permission setup needs"); - return; + if (required.length > 0 || choices.length > 0) throw new MissingScopesError(required, choices); + for (const issue of result.unexpected) { + const status = issue.message.includes(String(issue.status)) ? "" : ` (HTTP ${issue.status})`; + deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message}${status}`); } - deps.ui.error("The CLI session is missing permissions setup needs"); - for (const scope of required) deps.ui.detail(scope); - for (const choice of choices) deps.ui.detail(`one of: ${choice.scopes.join(", ")}`); - throw new MissingScopesError(required, choices); + deps.ui.result( + result.unexpected.length > 0 ? "No permission is missing, but the checks above could not be verified" : "The CLI session has every permission setup needs" + ); } function resourceServersFor(template, tenantDomain) { const account = { name: "Auth0 My Account API", identifier: myAccountAudience(tenantDomain) }; @@ -4091,16 +4249,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, + probeApi: tracked.api }); + if (planned) return reportPlan(result, planned.requests(), args.json, io, ui); if (args.json) { io.stdout.write(`${JSON.stringify(result, null, 2)} `); @@ -4123,6 +4285,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."; +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); @@ -4346,11 +4519,12 @@ async function main(argv, env, io) { return error instanceof UsageError ? 2 : 1; } if (args.command === "help") { - io.stdout.write(`${usage()} + io.stdout.write(`${usage(args.helpFor)} `); return 0; } const agentMode = args.json || env.AUTH0_AGENT_MODE === "1" || env.AUTH0_AGENT_MODE === "true"; + if (agentMode) args = { ...args, json: true }; const interactive = !args.noInput && !agentMode && io.isTTY; const ui = new Ui( io.stderr, diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index f4c3ecfa4..38180ec53 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -1,1048 +1,136 @@ package cli import ( - "context" - "encoding/json" - "errors" "fmt" - "net/http" - "strings" + "os" + "os/exec" + "slices" - "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.", - } - - cmd.SetUsageTemplate(namespaceUsageTemplate()) - cmd.AddCommand(universalPortalsSetupCmd(cli)) - - 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"`, - 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() + Short: "Set up and audit Universal Portals", + Long: `Set up and audit Auth0 Universal Portals. - // 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) +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.`, } - 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 + // Flag parsing is off for the tool's commands, so the login check would miss --tenant and + // --no-input, and would run for --help, which the tool prints without a tenant. + cmd.PersistentPreRunE = func(command *cobra.Command, args []string) error { + if slices.Contains(args, "--help") || slices.Contains(args, "-h") { + cli.executedCommandPath = command.CommandPath() + return nil } - var conflict *errAPIConflict - if !errors.As(err, &conflict) { - return fmt.Errorf("failed to create portal: %w", err) + if tenant := universalPortalsTenantFlag(args); tenant != "" { + cli.tenant = tenant } - 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) + cli.noInput = cli.noInput || slices.Contains(args, "--no-input") + return command.Root().PersistentPreRunE(command, args) } - 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) -} + cmd.SetUsageTemplate(namespaceUsageTemplate()) + 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`), + ) -type portalClientResult struct { - ClientID string - ClientSecret string + return cmd } -// 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}, - }, +// 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 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..83ca1e2cd --- /dev/null +++ b/internal/cli/universal_portals_runner_test.go @@ -0,0 +1,381 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/spf13/cobra" + "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 TestUniversalPortalsLoginCheck(t *testing.T) { + tests := []struct { + name string + args []string + wantChecked bool + wantTenant string + wantNoInput bool + }{ + {name: "help skips it", args: []string{"--help"}}, + {name: "short help skips it", args: []string{"up_1", "-h"}}, + {name: "it uses the default tenant", args: []string{"--name", "Acme"}, wantChecked: true, wantTenant: "default.us.auth0.com"}, + { + name: "it sees --tenant and --no-input", + args: []string{"up_1", "--tenant", "other.eu.auth0.com", "--no-input"}, + wantChecked: true, + wantTenant: "other.eu.auth0.com", + wantNoInput: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := &cli{tenant: "default.us.auth0.com"} + checked := false + root := &cobra.Command{Use: "auth0", PersistentPreRunE: func(*cobra.Command, []string) error { + checked = true + return nil + }} + root.AddCommand(universalPortalsCmd(c)) + setup, _, err := root.Find([]string{"universal-portals", "setup"}) + require.NoError(t, err) + + require.NoError(t, setup.Parent().PersistentPreRunE(setup, test.args)) + + assert.Equal(t, test.wantChecked, checked) + if test.wantChecked { + assert.Equal(t, test.wantTenant, c.tenant) + assert.Equal(t, test.wantNoInput, c.noInput) + } + }) + } +} + +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 ""