From 893bbd3a7a4bfbad13fc0ce19ec138238b2d765e Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 15:46:06 +0100 Subject: [PATCH] chore(portals): import the universal-portals source Adds the TypeScript source of the Universal Portals setup and audit tool, with its tests, under internal/cli/data/universal-portals/_source. Go tooling skips the folder because of the leading underscore. Nothing builds, embeds or runs it yet. Later changes bundle it into a single file and run that file from the CLI. Co-Authored-By: Claude --- .../data/universal-portals/_source/.gitignore | 2 + .../_source/package-lock.json | 151 ++++++ .../universal-portals/_source/package.json | 30 ++ .../_source/scripts/preview-audit.mjs | 164 ++++++ .../_source/scripts/preview-setup.mjs | 75 +++ .../data/universal-portals/_source/src/api.ts | 88 ++++ .../universal-portals/_source/src/args.ts | 167 ++++++ .../_source/src/audit-command.ts | 98 ++++ .../_source/src/audit/checks.ts | 460 ++++++++++++++++ .../_source/src/audit/fix.ts | 85 +++ .../_source/src/audit/remedies.ts | 92 ++++ .../_source/src/audit/run.ts | 64 +++ .../_source/src/audit/snapshot.ts | 166 ++++++ .../universal-portals/_source/src/browser.ts | 23 + .../_source/src/clack-prompter.ts | 50 ++ .../data/universal-portals/_source/src/cli.ts | 67 +++ .../universal-portals/_source/src/context.ts | 42 ++ .../_source/src/dashboard.ts | 53 ++ .../universal-portals/_source/src/errors.ts | 27 + .../universal-portals/_source/src/forms.ts | 204 +++++++ .../_source/src/plugin-error.ts | 12 + .../_source/src/portal-model.ts | 120 +++++ .../universal-portals/_source/src/prompt.ts | 34 ++ .../universal-portals/_source/src/scopes.ts | 29 + .../_source/src/setup-command.ts | 137 +++++ .../src/setup/business-organization.ts | 91 ++++ .../_source/src/setup/business-role.ts | 118 +++++ .../_source/src/setup/business-types.ts | 53 ++ .../_source/src/setup/business.ts | 214 ++++++++ .../_source/src/setup/host.ts | 20 + .../_source/src/setup/payload.ts | 164 ++++++ .../_source/src/setup/preflight.ts | 107 ++++ .../_source/src/setup/resources.ts | 305 +++++++++++ .../_source/src/setup/run.ts | 273 ++++++++++ .../_source/src/setup/vault.ts | 47 ++ .../universal-portals/_source/src/slug.ts | 11 + .../_source/src/templates/business.ts | 100 ++++ .../_source/src/templates/consumer.ts | 92 ++++ .../_source/src/templates/icons.ts | 12 + .../_source/src/templates/security.ts | 32 ++ .../_source/src/templates/types.ts | 18 + .../data/universal-portals/_source/src/ui.ts | 186 +++++++ .../_source/src/write-tracker.ts | 17 + .../_source/test/api.test.ts | 102 ++++ .../_source/test/audit-checks.test.ts | 281 ++++++++++ .../_source/test/audit-fix.test.ts | 121 +++++ .../_source/test/audit-remedies.test.ts | 128 +++++ .../_source/test/audit-run.test.ts | 184 +++++++ .../_source/test/browser.test.ts | 18 + .../_source/test/business.test.ts | 497 ++++++++++++++++++ .../_source/test/capture-output.ts | 21 + .../_source/test/clack-prompter.test.ts | 99 ++++ .../_source/test/cli.test.ts | 404 ++++++++++++++ .../_source/test/dashboard.test.ts | 27 + .../_source/test/errors.test.ts | 31 ++ .../_source/test/fake-tenant.ts | 300 +++++++++++ .../_source/test/fixtures/consumer-setup.json | 83 +++ .../_source/test/forms.test.ts | 53 ++ .../_source/test/lockfile.test.ts | 20 + .../_source/test/portal-model.test.ts | 22 + .../_source/test/preflight.test.ts | 97 ++++ .../_source/test/resources.test.ts | 199 +++++++ .../_source/test/run.test.ts | 396 ++++++++++++++ .../_source/test/scopes.test.ts | 64 +++ .../_source/test/templates.test.ts | 131 +++++ .../universal-portals/_source/test/ui.test.ts | 123 +++++ .../universal-portals/_source/tsconfig.json | 13 + 67 files changed, 7714 insertions(+) create mode 100644 internal/cli/data/universal-portals/_source/.gitignore create mode 100644 internal/cli/data/universal-portals/_source/package-lock.json create mode 100644 internal/cli/data/universal-portals/_source/package.json create mode 100644 internal/cli/data/universal-portals/_source/scripts/preview-audit.mjs create mode 100644 internal/cli/data/universal-portals/_source/scripts/preview-setup.mjs create mode 100644 internal/cli/data/universal-portals/_source/src/api.ts create mode 100644 internal/cli/data/universal-portals/_source/src/args.ts create mode 100644 internal/cli/data/universal-portals/_source/src/audit-command.ts create mode 100644 internal/cli/data/universal-portals/_source/src/audit/checks.ts create mode 100644 internal/cli/data/universal-portals/_source/src/audit/fix.ts create mode 100644 internal/cli/data/universal-portals/_source/src/audit/remedies.ts create mode 100644 internal/cli/data/universal-portals/_source/src/audit/run.ts create mode 100644 internal/cli/data/universal-portals/_source/src/audit/snapshot.ts create mode 100644 internal/cli/data/universal-portals/_source/src/browser.ts create mode 100644 internal/cli/data/universal-portals/_source/src/clack-prompter.ts create mode 100644 internal/cli/data/universal-portals/_source/src/cli.ts create mode 100644 internal/cli/data/universal-portals/_source/src/context.ts create mode 100644 internal/cli/data/universal-portals/_source/src/dashboard.ts create mode 100644 internal/cli/data/universal-portals/_source/src/errors.ts create mode 100644 internal/cli/data/universal-portals/_source/src/forms.ts create mode 100644 internal/cli/data/universal-portals/_source/src/plugin-error.ts create mode 100644 internal/cli/data/universal-portals/_source/src/portal-model.ts create mode 100644 internal/cli/data/universal-portals/_source/src/prompt.ts create mode 100644 internal/cli/data/universal-portals/_source/src/scopes.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup-command.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/business-organization.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/business-role.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/business-types.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/business.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/host.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/payload.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/preflight.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/resources.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/run.ts create mode 100644 internal/cli/data/universal-portals/_source/src/setup/vault.ts create mode 100644 internal/cli/data/universal-portals/_source/src/slug.ts create mode 100644 internal/cli/data/universal-portals/_source/src/templates/business.ts create mode 100644 internal/cli/data/universal-portals/_source/src/templates/consumer.ts create mode 100644 internal/cli/data/universal-portals/_source/src/templates/icons.ts create mode 100644 internal/cli/data/universal-portals/_source/src/templates/security.ts create mode 100644 internal/cli/data/universal-portals/_source/src/templates/types.ts create mode 100644 internal/cli/data/universal-portals/_source/src/ui.ts create mode 100644 internal/cli/data/universal-portals/_source/src/write-tracker.ts create mode 100644 internal/cli/data/universal-portals/_source/test/api.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/audit-checks.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/audit-fix.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/audit-remedies.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/audit-run.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/browser.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/business.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/capture-output.ts create mode 100644 internal/cli/data/universal-portals/_source/test/clack-prompter.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/cli.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/dashboard.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/errors.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/fake-tenant.ts create mode 100644 internal/cli/data/universal-portals/_source/test/fixtures/consumer-setup.json create mode 100644 internal/cli/data/universal-portals/_source/test/forms.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/lockfile.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/portal-model.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/preflight.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/resources.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/run.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/scopes.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/templates.test.ts create mode 100644 internal/cli/data/universal-portals/_source/test/ui.test.ts create mode 100644 internal/cli/data/universal-portals/_source/tsconfig.json diff --git a/internal/cli/data/universal-portals/_source/.gitignore b/internal/cli/data/universal-portals/_source/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/internal/cli/data/universal-portals/_source/package-lock.json b/internal/cli/data/universal-portals/_source/package-lock.json new file mode 100644 index 000000000..7b811c5bd --- /dev/null +++ b/internal/cli/data/universal-portals/_source/package-lock.json @@ -0,0 +1,151 @@ +{ + "name": "universal-portals", + "version": "0.0.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "universal-portals", + "version": "0.0.0-dev", + "dependencies": { + "@clack/prompts": "1.8.1", + "auth0": "4.29.0" + }, + "bin": { + "universal-portals": "dist/src/cli.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/auth0": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.29.0.tgz", + "integrity": "sha512-sTwC+579zoHthOFJLspVkghpaS9iAbA8U4ttirvZot4zvIG6EP0tpWUukVr9gzGXHUZcA+UdlKbHQwRSF8KCjg==", + "license": "MIT", + "dependencies": { + "jose": "^4.13.2", + "undici-types": "^6.15.0", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + } + } +} diff --git a/internal/cli/data/universal-portals/_source/package.json b/internal/cli/data/universal-portals/_source/package.json new file mode 100644 index 000000000..6097933fe --- /dev/null +++ b/internal/cli/data/universal-portals/_source/package.json @@ -0,0 +1,30 @@ +{ + "name": "universal-portals", + "version": "0.0.0-dev", + "private": true, + "type": "module", + "bin": { + "universal-portals": "./dist/src/cli.js" + }, + "files": [ + "dist/src" + ], + "scripts": { + "build": "tsc -p tsconfig.json && chmod +x dist/src/cli.js", + "test": "rm -rf dist && npm run build && node --test dist/test/*.test.js", + "preview": "npm run preview:setup", + "preview:setup": "npm run build >/dev/null && node scripts/preview-setup.mjs", + "preview:audit": "npm run build >/dev/null && node scripts/preview-audit.mjs" + }, + "engines": { + "node": ">=20.12" + }, + "dependencies": { + "@clack/prompts": "1.8.1", + "auth0": "4.29.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.0.0" + } +} diff --git a/internal/cli/data/universal-portals/_source/scripts/preview-audit.mjs b/internal/cli/data/universal-portals/_source/scripts/preview-audit.mjs new file mode 100644 index 000000000..b3ee74a57 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/scripts/preview-audit.mjs @@ -0,0 +1,164 @@ +// Dev-only preview of the audit experience. It sets up a healthy portal in the +// in-memory fake tenant, breaks it in the chosen way, then runs the real audit +// command. Nothing touches a real tenant. +// +// npm run preview:audit menu, with random breaks +// npm run preview:audit -- --fix apply the errors without asking +// npm run preview:audit -- --scenario NAME a named set of breaks +// Scenarios: home, rotation, policy, grant, nav, connections, all +import { FakeTenant } from "../dist/test/fake-tenant.js"; +import { runSetup } from "../dist/src/setup/run.js"; +import { main } from "../dist/src/cli.js"; +import { Ui } from "../dist/src/ui.js"; + +const TENANT = "acme.us.auth0.com"; +const LATENCY_MS = Number(process.env.PREVIEW_LATENCY_MS ?? 300); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +if (!process.stdin.isTTY) { + console.error("The audit preview asks questions, so run it in a terminal."); + process.exit(1); +} + +const args = process.argv.slice(2); +const scenarioName = args.includes("--scenario") ? args[args.indexOf("--scenario") + 1] : undefined; + +// Each break is one setup mistake, and the finding it should produce. +const BREAKS = { + home: { + finding: "P001 home page", + apply: ({ portal }) => (portal.pages.default = undefined), + }, + rotation: { + finding: "P013 refresh token rotation", + apply: ({ app }) => (app.refresh_token = { ...app.refresh_token, rotation_type: "rotating" }), + }, + policy: { + finding: "P020 refresh token policy", + apply: ({ app }) => (app.refresh_token = { ...app.refresh_token, policies: [] }), + }, + grant: { + finding: "P019 client grant", + apply: ({ tenant, app }) => { + for (const [id, grant] of tenant.grants) { + if (grant.client_id === app.client_id && grant.audience.endsWith("/me/")) tenant.grants.delete(id); + } + }, + }, + nav: { + finding: "P002 home page in navigation", + apply: ({ portal }) => { + portal.navigation.sidebar.components = portal.navigation.sidebar.components.filter( + (item) => item.config.page_slug !== "profile", + ); + }, + }, + connections: { + finding: "P021 connections", + apply: ({ tenant }) => tenant.connections.clear(), + }, + callback: { + finding: "P012 callback url", + apply: ({ app }) => (app.callbacks = []), + }, + client_credentials: { + finding: "P010 client credentials", + apply: ({ app }) => (app.grant_types = [...app.grant_types, "client_credentials"]), + }, + ip_header: { + finding: "P017 token endpoint IP header", + apply: ({ app }) => (app.is_token_endpoint_ip_header_trusted = false), + }, + empty_page: { + finding: "P006 empty page", + apply: ({ portal }) => { + portal.pages.standard.push({ title: "Billing", slug: "billing", components: [] }); + portal.navigation.sidebar.components.push({ + type: "sidebar:component:auth0:internal_link:v2", + config: { label: "Billing", page_slug: "billing", icon: "file" }, + }); + }, + }, +}; + +const SCENARIOS = { + home: ["home"], + rotation: ["rotation"], + policy: ["policy"], + grant: ["grant"], + nav: ["nav"], + connections: ["connections"], + all: Object.keys(BREAKS), +}; + +function chooseBreaks() { + if (scenarioName) { + const names = SCENARIOS[scenarioName]; + if (!names) { + console.error(`Unknown scenario "${scenarioName}". Use one of: ${Object.keys(SCENARIOS).join(", ")}.`); + process.exit(1); + } + return names; + } + // Random by default: at least four different breaks, up to all of them, so + // every run looks different. + const all = Object.keys(BREAKS); + const names = all.sort(() => Math.random() - 0.5); + const count = 4 + Math.floor(Math.random() * (all.length - 4 + 1)); + return names.slice(0, count); +} + +const tenant = new FakeTenant(); +tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); +const quietPrompt = { + ask: async (_q, d) => d ?? "", + confirm: async (_q, d) => d, + choose: async (_q, o) => o[0].value, + checklist: async () => { + throw new Error("unexpected question"); + }, +}; +const setup = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: new Ui(() => undefined, false, true), prompt: quietPrompt, interactive: false, tenantDomain: TENANT }, +); + +const breaks = chooseBreaks(); +const context = { + tenant, + app: tenant.clients.get(setup.application.clientId), + portal: tenant.portals.get(setup.portal.id), +}; +for (const name of breaks) BREAKS[name].apply(context); +console.error(`(preview: broke ${breaks.length} thing(s): ${breaks.map((name) => BREAKS[name].finding).join("; ")})`); +tenant.calls.length = 0; + +const fetchImpl = async (url, init) => { + await sleep(LATENCY_MS); + const parsed = new URL(url); + const path = parsed.pathname.replace(/^\/api\/v2\//, "") + parsed.search; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + try { + const result = await tenant.request(init.method, path, body); + return new Response(result === undefined ? "" : JSON.stringify(result), { status: 200 }); + } catch (error) { + return new Response(JSON.stringify(error.body), { status: error.status }); + } +}; + +const env = { + AUTH0_CLI_PROXY_URL: "https://127.0.0.1:4433", + AUTH0_CLI_PROXY_TOKEN: "preview", + AUTH0_CLI_TENANT_DOMAIN: TENANT, +}; +const io = { + stdout: process.stdout, + stderr: process.stderr, + stdinStream: process.stdin, + isTTY: true, + fetchImpl, +}; + +const cliArgs = ["audit", setup.portal.id, ...(args.includes("--fix") ? ["--fix"] : [])]; +const code = await main(cliArgs, env, io); +console.error(`(preview: exit ${code})`); diff --git a/internal/cli/data/universal-portals/_source/scripts/preview-setup.mjs b/internal/cli/data/universal-portals/_source/scripts/preview-setup.mjs new file mode 100644 index 000000000..4b9075ad9 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/scripts/preview-setup.mjs @@ -0,0 +1,75 @@ +// Dev-only preview of the setup experience. It runs the real CLI entry point, so +// the questions, the progress and the result are exactly what the CLI shows. The +// setup runs against the in-memory fake tenant with latency on each call, and seeds +// some resources so the output mixes created and already-exists results. It makes +// no API calls and opens no browser. +// +// Run it in a terminal: npm run preview +import { FakeTenant } from "../dist/test/fake-tenant.js"; +import { FORM_NAMES } from "../dist/src/forms.js"; +import { main } from "../dist/src/cli.js"; + +const TENANT = "acme.us.auth0.com"; +const LATENCY_MS = Number(process.env.PREVIEW_LATENCY_MS ?? 400); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +if (!process.stdin.isTTY) { + console.error("The preview asks questions, so run it in a terminal."); + process.exit(1); +} + +const tenant = new FakeTenant(); +tenant.resourceServers.set("rs_seed", { + id: "rs_seed", + name: "Auth0 My Account API", + identifier: `https://${TENANT}/me/`, +}); +tenant.forms.set("ap_0000000000000000000001", { id: "ap_0000000000000000000001", name: FORM_NAMES.personalInfo, document: {} }); + +// Business data: two users share an email, so the picker shows up, and only one +// of them belongs to an organization that has a connection and a role. +tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); +tenant.users.set("auth0|000000000000000000000001", { user_id: "auth0|000000000000000000000001", email: "jane@acme.com", name: "Jane Doe" }); +tenant.users.set("google-oauth2|100000000000000000001", { user_id: "google-oauth2|100000000000000000001", email: "jane@acme.com", name: "Jane Doe" }); +tenant.organizations.set("org_PreviewAcmeCorp1", { id: "org_PreviewAcmeCorp1", name: "acme-corp", display_name: "Acme Corp" }); +tenant.members.set("org_PreviewAcmeCorp1", new Set(["google-oauth2|100000000000000000001"])); +tenant.orgConnections.set("org_PreviewAcmeCorp1", ["con_db"]); +tenant.roles.set("rol_PreviewAdmin0001", { id: "rol_PreviewAdmin0001", name: "Organization - Admin" }); +tenant.orgRoles.set("org_PreviewAcmeCorp1:google-oauth2|100000000000000000001", ["rol_PreviewAdmin0001"]); + +// A member with no role, and an organization with no enabled connection. +tenant.users.set("auth0|000000000000000000000002", { user_id: "auth0|000000000000000000000002", email: "ben@acme.com", name: "Ben Ortiz" }); +tenant.members.get("org_PreviewAcmeCorp1").add("auth0|000000000000000000000002"); +tenant.organizations.set("org_PreviewAcmeLabs1", { id: "org_PreviewAcmeLabs1", name: "acme-labs", display_name: "Acme Labs" }); +tenant.users.set("auth0|000000000000000000000003", { user_id: "auth0|000000000000000000000003", email: "sam@acme.com", name: "Sam Lee" }); +tenant.members.set("org_PreviewAcmeLabs1", new Set(["auth0|000000000000000000000003"])); + +const fetchImpl = async (url, init) => { + await sleep(LATENCY_MS); + const parsed = new URL(url); + const path = parsed.pathname.replace(/^\/api\/v2\//, "") + parsed.search; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + try { + const result = await tenant.request(init.method, path, body); + return new Response(result === undefined ? "" : JSON.stringify(result), { status: 200 }); + } catch (error) { + return new Response(JSON.stringify(error.body), { status: error.status }); + } +}; + +const env = { + AUTH0_CLI_PROXY_URL: "https://127.0.0.1:4433", + AUTH0_CLI_PROXY_TOKEN: "preview", + AUTH0_CLI_TENANT_DOMAIN: TENANT, +}; +const io = { + stdout: process.stdout, + stderr: process.stderr, + stdinStream: process.stdin, + isTTY: true, + fetchImpl, + openBrowser: (url) => console.error(`(preview: the browser is not opened: ${url})`), +}; + +const code = await main(["setup"], env, io); +console.error(`(preview: exit ${code})`); diff --git a/internal/cli/data/universal-portals/_source/src/api.ts b/internal/cli/data/universal-portals/_source/src/api.ts new file mode 100644 index 000000000..e0a66f24e --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/api.ts @@ -0,0 +1,88 @@ +// Management API client. Requests go through the CLI's loopback proxy, which +// swaps the proxy token for the real access token, so this module never holds it. + +export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE"; + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly errorCode: string | undefined, + message: string, + readonly body: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export interface ApiClient { + request(method: HttpMethod, path: string, body?: unknown): Promise; +} + +export type FetchLike = (url: string, init: RequestInit) => Promise; + +export function createApiClient(baseUrl: string, token: string, fetchImpl: FetchLike = fetch): ApiClient { + const root = `${baseUrl.replace(/\/$/, "")}/api/v2/`; + return { + async request(method: HttpMethod, path: string, body?: unknown): Promise { + const headers: Record = { authorization: `Bearer ${token}`, accept: "application/json" }; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + const response = await fetchImpl(root + path, init); + const text = await response.text(); + const parsed = text ? safeJson(text) : undefined; + if (!response.ok) { + throw toApiError(response.status, parsed); + } + return parsed as T; + }, + }; +} + +export function toApiError(status: number, body: unknown): ApiError { + const record = isRecord(body) ? body : {}; + const message = typeof record.message === "string" ? record.message : `HTTP ${status}`; + const errorCode = typeof record.errorCode === "string" ? record.errorCode : undefined; + return new ApiError(status, errorCode, message, body); +} + +// Management API list endpoints return either an array or an object keyed by +// the resource name, depending on the endpoint and the query parameters. +export function listItems(body: unknown, key: string): T[] { + if (Array.isArray(body)) return body as T[]; + if (isRecord(body) && Array.isArray(body[key])) return body[key] as T[]; + return []; +} + +// The page size and the most pages one listing may read. The cap stops an endpoint +// that ignores paging from looping forever. +const PAGE_SIZE = 100; +const MAX_PAGES = 100; + +// Reads every page of a list endpoint. A short page is the last one, so the total is +// not needed, and endpoints that reject extra query parameters are not affected. +export async function listAll(api: ApiClient, path: string, key: string): Promise { + const separator = path.includes("?") ? "&" : "?"; + const items: T[] = []; + for (let page = 0; page < MAX_PAGES; page += 1) { + const batch = listItems(await api.request("GET", `${path}${separator}page=${page}&per_page=${PAGE_SIZE}`), key); + items.push(...batch); + if (batch.length < PAGE_SIZE) return items; + } + throw new Error(`${path} returned more than ${MAX_PAGES * PAGE_SIZE} items. The listing did not stop paging.`); +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/internal/cli/data/universal-portals/_source/src/args.ts b/internal/cli/data/universal-portals/_source/src/args.ts new file mode 100644 index 000000000..1e2b706a4 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/args.ts @@ -0,0 +1,167 @@ +import { parseArgs } from "node:util"; + +import { PluginError } from "./plugin-error.js"; +import type { TemplateId } from "./templates/types.js"; + +export class UsageError extends PluginError { + constructor(message: string) { + super(message, "usage"); + } +} + +export interface ParsedSetupArgs { + command: "setup" | "audit" | "help"; + portalId?: string; + strict: boolean; + fix?: boolean; + // Set by the CLI's --tenant flag, which it forwards to the plugin. + tenant?: string; + noColor: boolean; + template?: TemplateId; + name?: string; + slug?: string; + user?: string; + organization?: string; + json: boolean; + noInput: boolean; +} + +// Parses the arguments the plugin receives from the CLI. Interactive questions +// are asked by the caller for anything missing, so this only reports what the +// caller supplied. Global flags the CLI owns are accepted here; --tenant is +// returned so the caller can check it. +export function parseSetupArgs(argv: string[]): ParsedSetupArgs { + const { globals, rest } = splitGlobalFlags(argv); + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + strict: true, + options: { + template: { type: "string" }, + name: { type: "string", short: "n" }, + slug: { type: "string", short: "s" }, + user: { type: "string", short: "u" }, + organization: { type: "string", short: "o" }, + json: { type: "boolean" }, + "no-input": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + strict: { type: "boolean" }, + fix: { type: "boolean" }, + }, + }); + + const [first] = positionals; + if (values.help || first === undefined) { + return { + command: "help", + strict: false, + tenant: globals.tenant, + noColor: globals.noColor, + json: Boolean(values.json), + noInput: Boolean(values["no-input"]), + }; + } + if (first === "audit") { + if (positionals.length !== 2) { + throw new UsageError("Usage: auth0 universal-portals audit [--organization ] [--strict]"); + } + return { + command: "audit", + portalId: positionals[1], + tenant: globals.tenant, + noColor: globals.noColor, + organization: values.organization, + strict: Boolean(values.strict), + fix: Boolean(values.fix), + json: Boolean(values.json), + noInput: Boolean(values["no-input"]), + }; + } + if (first !== "setup") { + throw new UsageError(`Unknown command "${first}". Run: auth0 universal-portals --help`); + } + if (positionals.length > 1) { + throw new UsageError(`Unexpected arguments: ${positionals.slice(1).join(" ")}`); + } + + const template = values.template; + if (template !== undefined && template !== "consumer" && template !== "business") { + throw new UsageError(`--template must be "consumer" or "business", not "${template}".`); + } + + return { + command: "setup", + strict: false, + tenant: globals.tenant, + noColor: globals.noColor, + template, + name: values.name, + slug: values.slug, + user: values.user, + organization: values.organization, + json: Boolean(values.json), + noInput: Boolean(values["no-input"]), + }; +} + +// Without a terminal, every answer has to come from a flag. +export function missingFlags(args: ParsedSetupArgs): string[] { + const missing: string[] = []; + if (!args.template) missing.push("--template"); + if (!args.name) missing.push("--name"); + if (args.template === "business" && !args.user) missing.push("--user"); + return missing; +} + +export function usage(): string { + return [ + "Usage: auth0 universal-portals setup [flags]", + "", + "Creates an Auth0 Portal with its application, forms and pages.", + "", + "Flags:", + " --template Portal template. Asked when omitted.", + " -n, --name Portal name. Asked when omitted.", + " -s, --slug URL slug. Defaults to a slug of the name.", + " -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.", + " --json Print the result as JSON on stdout.", + " -h, --help Show this help.", + "", + "Usage: auth0 universal-portals audit [flags]", + "", + "Checks a portal and its application against the setup defaults.", + "", + "Flags:", + " -o, --organization Business only: the organization the portal opens in.", + " --fix Apply the errors the CLI can fix, without asking.", + " --strict Also exit 1 when there are warnings.", + " --json Print the result as JSON on stdout.", + " -h, --help Show this help.", + ].join("\n"); +} + +function splitGlobalFlags(argv: string[]): { globals: { tenant?: string; noColor: boolean }; rest: string[] } { + const globals: { tenant?: string; noColor: boolean } = { noColor: false }; + const rest: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--no-color") { + globals.noColor = true; + continue; + } + if (arg === "--debug") continue; + if (arg === "--tenant") { + globals.tenant = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--tenant=")) { + globals.tenant = arg.slice("--tenant=".length); + continue; + } + rest.push(arg); + } + return { globals, rest }; +} diff --git a/internal/cli/data/universal-portals/_source/src/audit-command.ts b/internal/cli/data/universal-portals/_source/src/audit-command.ts new file mode 100644 index 000000000..d6c145880 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/audit-command.ts @@ -0,0 +1,98 @@ +import { createApiClient } from "./api.js"; +import type { ParsedSetupArgs } from "./args.js"; +import { CancelledError, type Prompter } from "./prompt.js"; +import { checkTenantFlag, readContext } from "./context.js"; +import { applyFixRounds, fixableFindings } from "./audit/fix.js"; +import type { Finding } from "./audit/checks.js"; +import { AuditOptions, AuditResult, runAudit } from "./audit/run.js"; +import { fetchPortalSnapshot } from "./audit/snapshot.js"; +import type { ApiClient } from "./api.js"; +import type { IoStreams } from "./cli.js"; +import { reportError } from "./errors.js"; +import type { Ui } from "./ui.js"; + +export async function runAuditCommand( + args: ParsedSetupArgs, + env: NodeJS.ProcessEnv, + io: IoStreams, + ui: Ui, + prompter: Prompter | undefined, +): Promise { + try { + const context = readContext(env); + checkTenantFlag(args.tenant, context); + const api = createApiClient(context.proxyUrl, context.proxyToken, io.fetchImpl); + const portalId = args.portalId ?? ""; + const organizationId = args.organization; + const readSnapshot = () => fetchPortalSnapshot(api, portalId, context.tenantDomain, { organizationId }); + const options = { portalId, organizationId, strict: args.strict }; + + const report = await runAudit(options, { api, ui, tenantDomain: context.tenantDomain }); + const fixable = fixableFindings(report.findings); + const applyDeps = { api, ui, readSnapshot }; + let result = report; + + 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); + 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) => { + ui.detail("These errors appeared after the fixes above."); + return chooseFixes(prompter, exposed, ui); + }); + result = await finalAudit(options, ui, api, context.tenantDomain); + } + } else if (fixable.length > 0) { + ui.detail("Run with --fix to apply the errors the CLI can fix."); + } + + if (args.json) io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return result.failed ? 1 : 0; + } catch (error) { + if (error instanceof CancelledError && !args.json) { + ui.warn("Cancelled. Fixes already applied stay in your tenant."); + ui.cancelled("Audit cancelled."); + return 130; + } + return reportError(error, args.json, io, ui); + } +} + +// Asks what to do with the findings: fix every error, choose the fixes, or +// skip. Errors start checked in the checklist, and recommendations do not. +async function chooseFixes(prompter: Prompter, errors: Finding[], ui: Ui): Promise { + ui.detail("Warnings are not changed. They may be intentional."); + const choice = await prompter.choose<"all" | "choose" | "skip">("What do you want to fix?", [ + { label: "Fix all errors", hint: "recommended", value: "all" }, + { label: "Choose errors to fix", hint: "every error starts checked", value: "choose" }, + { label: "Skip", hint: "leave the portal as it is", value: "skip" }, + ]); + if (choice === "skip") return []; + if (choice === "all") return errors; + return prompter.checklist( + "Errors to fix", + errors.map((finding) => ({ + label: `${finding.id} ${finding.remedy?.summary ?? finding.message}`, + value: finding, + checked: true, + })), + ); +} + +async function finalAudit( + options: AuditOptions, + ui: Ui, + api: ApiClient, + tenantDomain: string, +): Promise { + ui.section("Checking the portal again"); + return runAudit({ ...options, showBanner: false }, { api, ui, tenantDomain }); +} diff --git a/internal/cli/data/universal-portals/_source/src/audit/checks.ts b/internal/cli/data/universal-portals/_source/src/audit/checks.ts new file mode 100644 index 000000000..1a9e308b6 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/audit/checks.ts @@ -0,0 +1,460 @@ +import { + MY_ACCOUNT_SCOPES, + MY_ORGANIZATION_SCOPES, + myAccountAudience, + myOrganizationAudience, + REFRESH_TOKEN_LIFETIMES, +} from "../setup/payload.js"; +import { addSidebarLink, createPortalApplication, isLegacyLink, patchPages, patchRefresh, toV2Link } from "./remedies.js"; +import { hostOf } from "./snapshot.js"; +import type { ApiClient } from "../api.js"; +import type { ComponentShape, PortalSnapshot } from "./snapshot.js"; + +// Each check reads the snapshot and returns findings. Errors break the portal +// or a component, warnings are degraded or against the setup guide. +export type Severity = "error" | "warning"; + +// How to fix a finding. `summary` is what the fix does. `command` is the +// equivalent manual command. `apply` is set when the CLI can make the change. +export interface Remedy { + summary: string; + command?: string; + apply?: (api: ApiClient) => Promise; +} + +export interface Finding { + id: string; + severity: Severity; + check: string; + message: string; + // What the finding is about (a page, form or API). Checks that can repeat an ID + // set it, so a fix matches its finding by ID and subject, never by message text. + subject?: string; + remedy?: Remedy; +} + +export function auditPortal(snapshot: PortalSnapshot): Finding[] { + const findings: Finding[] = []; + const add = (finding: Finding) => findings.push(finding); + const portalId = snapshot.portal.id; + const pages = snapshot.portal.pages?.standard ?? []; + const pageSlugs = new Set(pages.map((page) => page.slug)); + const sidebar = snapshot.portal.navigation?.sidebar?.components ?? []; + const sidebarPages = new Set(sidebar.map((item) => String(item.config?.page_slug ?? ""))); + const usedComponents = allComponents(pages); + const usesAccount = usedComponents.some((c) => c.type.includes("my_account")); + const usesOrganization = usedComponents.some((c) => c.type.includes("my_organization")); + + // Pages and navigation + const home = snapshot.portal.pages?.default; + if (!home || !pageSlugs.has(home)) { + const first = pages[0]; + add({ + id: "P001", + severity: "error", + check: "home page", + message: home + ? `The home page "${home}" does not match any page, so users land on an error.` + : "No home page is set, so users land on an error.", + remedy: first + ? { + summary: `Set "${first.title}" as the home page`, + command: `auth0 api patch "portals/${portalId}" --data '{"pages":{"default":"${first.slug}","standard":[...]}}'`, + apply: (api) => patchPages(api, portalId, snapshot, first.slug), + } + : { + summary: "Add a page and set it as the home page", + command: `auth0 api patch "portals/${portalId}" --data '{"pages":{"default":"","standard":[...]}}'`, + }, + }); + } else if (!sidebarPages.has(home)) { + const page = pages.find((candidate) => candidate.slug === home); + add({ + id: "P002", + severity: "warning", + check: "home page in navigation", + message: `The home page "${home}" is not in the sidebar, so users cannot get back to it.`, + remedy: { + summary: `Add "${page?.title ?? home}" to the sidebar`, + apply: (api) => addSidebarLink(api, portalId, snapshot, home, page?.title ?? home, "house"), + }, + }); + } + + // One finding for the whole sidebar, since one fix converts all of its links. + const legacyLinks = sidebar.filter(isLegacyLink); + if (legacyLinks.length > 0) { + add({ + id: "P003", + severity: "error", + check: "navigation shape", + message: `${legacyLinks.length} sidebar link(s) use the legacy shape.`, + remedy: { + summary: "Convert the sidebar links to the :v2 link types", + apply: (api) => + api.request("PATCH", `portals/${encodeURIComponent(portalId)}`, { + navigation: { sidebar: { components: sidebar.map(toV2Link) } }, + }), + }, + }); + } + + for (const item of sidebar) { + const target = item.config?.page_slug; + if (typeof target === "string" && !pageSlugs.has(target)) { + add({ + id: "P004", + subject: target, + severity: "error", + check: "sidebar link target", + message: `A sidebar link points to the page "${target}", which does not exist.`, + remedy: { summary: "Point the link at an existing page, or remove it" }, + }); + } + } + + for (const page of pages) { + if (!sidebarPages.has(page.slug)) { + add({ + id: "P005", + subject: page.slug, + severity: "warning", + check: "page in navigation", + message: `The page "${page.title}" is not in the sidebar, so users cannot reach it.`, + remedy: { + summary: `Add "${page.title}" to the sidebar`, + apply: (api) => addSidebarLink(api, portalId, snapshot, page.slug, page.title, "file"), + }, + }); + } + if ((page.components ?? []).length === 0) { + add({ + id: "P006", + subject: page.slug, + severity: "warning", + check: "empty page", + message: `The page "${page.title}" has no components.`, + remedy: { summary: "Add components to the page, or remove it" }, + }); + } + } + + for (const formId of Object.keys(snapshot.forms)) { + if (!snapshot.forms[formId]) { + add({ + id: "P007", + subject: formId, + severity: "error", + check: "form exists", + message: `A form on the portal (${formId}) does not exist in the tenant, so its component fails.`, + remedy: { summary: "Recreate the form, or remove the component from the page" }, + }); + } + } + + if (snapshot.portal.custom_code?.head && !snapshot.customDomain) { + add({ + id: "P008", + severity: "warning", + check: "custom code", + message: "Custom code is set, but the tenant has no ready custom domain, so it is never injected.", + remedy: { summary: "Set up a custom domain, or remove the custom code" }, + }); + } + + // Application + const app = snapshot.application; + if (!app) { + add({ + id: "P009", + severity: "error", + check: "application", + message: "The portal has no application linked to it.", + remedy: { + summary: "Create the portal application with the portal defaults and link it to the portal", + apply: (api) => createPortalApplication(api, snapshot, usesOrganization), + }, + }); + return findings; + } + const clientId = app.client_id; + + if (app.grant_types?.includes("client_credentials")) { + add({ + id: "P010", + severity: "error", + check: "client credentials", + message: "The portal application has the client_credentials grant, which the setup guide says must be off.", + remedy: { + summary: "Turn off the client_credentials grant", + apply: (api) => + api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { + grant_types: (app.grant_types ?? []).filter((grant) => grant !== "client_credentials"), + }), + }, + }); + } + + if (app.require_proof_of_possession) { + add({ + id: "P023", + severity: "error", + check: "DPoP", + message: "DPoP is enabled on the portal application. Portals are confidential clients and do not use DPoP.", + remedy: { + summary: "Turn DPoP off on the portal application", + apply: (api) => + api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { require_proof_of_possession: false }), + }, + }); + } + + // One finding for all Management API grants, since one fix removes them all. + const managementGrants = snapshot.grants.filter((candidate) => candidate.audience.endsWith("/api/v2/")); + if (managementGrants.length > 0) { + const ids = managementGrants.map((grant) => grant.id).filter((id): id is string => Boolean(id)); + add({ + id: "P011", + severity: "error", + check: "management api grant", + message: "The portal application has a Management API grant, which the setup guide says must not be there.", + remedy: { + summary: "Remove the Management API grant from the portal application", + apply: ids.length === managementGrants.length + ? async (api) => { + for (const id of ids) await api.request("DELETE", `client-grants/${encodeURIComponent(id)}`); + } + : undefined, + command: ids.length === managementGrants.length ? undefined : "auth0 api get \"client-grants?client_id=" + clientId + "\"", + }, + }); + } + + const callback = `https://${hostOf(snapshot)}/portals/auth/callback`; + if (!app.callbacks?.includes(callback)) { + add({ + id: "P012", + severity: "error", + check: "callback url", + message: `The callback URL ${callback} is missing, so sign-in to the portal fails.`, + remedy: { + summary: "Add the portal callback URL", + apply: (api) => + api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { + callbacks: [...(app.callbacks ?? []), callback], + }), + }, + }); + } + + const refresh = app.refresh_token; + if (refresh?.rotation_type && refresh.rotation_type !== "non-rotating") { + add({ + id: "P013", + severity: "error", + check: "refresh token rotation", + message: "Refresh token rotation is on, and portals do not support it. Components fail intermittently.", + remedy: { + summary: "Turn refresh token rotation off", + apply: (api) => patchRefresh(api, app, { rotation_type: "non-rotating" }), + }, + }); + } + if (refresh?.infinite_idle_token_lifetime) { + add({ + id: "P014", + severity: "error", + check: "refresh token idle lifetime", + message: "The refresh token has no idle expiry, so the portal session never ends by inactivity.", + remedy: { + summary: `Set the idle lifetime to ${REFRESH_TOKEN_LIFETIMES.idleSeconds} seconds`, + apply: (api) => patchRefresh(api, app, { infinite_idle_token_lifetime: false, idle_token_lifetime: REFRESH_TOKEN_LIFETIMES.idleSeconds }), + }, + }); + } else if ( + refresh && + (refresh.idle_token_lifetime !== REFRESH_TOKEN_LIFETIMES.idleSeconds || + refresh.token_lifetime !== REFRESH_TOKEN_LIFETIMES.absoluteSeconds) + ) { + add({ + id: "P015", + severity: "warning", + check: "refresh token lifetimes", + message: `The refresh token lifetimes differ from the guide (${REFRESH_TOKEN_LIFETIMES.idleSeconds}s idle, ${REFRESH_TOKEN_LIFETIMES.absoluteSeconds}s total).`, + remedy: { + summary: "Set the lifetimes to the guide's values", + apply: (api) => + patchRefresh(api, app, { + idle_token_lifetime: REFRESH_TOKEN_LIFETIMES.idleSeconds, + token_lifetime: REFRESH_TOKEN_LIFETIMES.absoluteSeconds, + }), + }, + }); + } + + if (app.organization_usage !== "allow" && usesOrganization) { + add({ + id: "P016", + severity: "error", + check: "organization usage", + message: "The portal has organization components, but the application does not allow organizations.", + remedy: { + summary: 'Allow organizations on the application', + apply: (api) => api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { organization_usage: "allow" }), + }, + }); + } + + if (app.is_token_endpoint_ip_header_trusted === false) { + add({ + id: "P017", + severity: "warning", + check: "token endpoint IP header", + message: "The guide says to trust the token endpoint IP header, and it is off.", + remedy: { + summary: "Trust the token endpoint IP header", + apply: (api) => + api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { is_token_endpoint_ip_header_trusted: true }), + }, + }); + } + + // Resources the components call. A component without its API grant or its + // refresh-token policy fails when the portal loads it. + const needed: { audience: string; name: string; scope: string[]; used: boolean }[] = [ + { audience: myAccountAudience(snapshot.tenantDomain), name: "My Account API", scope: MY_ACCOUNT_SCOPES, used: usesAccount }, + { + audience: myOrganizationAudience(snapshot.tenantDomain), + name: "My Organization API", + scope: MY_ORGANIZATION_SCOPES, + used: usesOrganization, + }, + ]; + for (const api of needed.filter((entry) => entry.used)) { + const usedBy = componentsOn(usedComponents, api.name) + .map((component) => componentLabel(component)) + .join(", "); + if (!snapshot.resourceServers.includes(api.audience)) { + add({ + id: "P018", + subject: api.audience, + severity: "error", + check: "resource server", + message: `The components (${usedBy}) need the ${api.name}, but the tenant has no resource server for ${api.audience}.`, + remedy: { + summary: `Create the ${api.name} resource server`, + apply: (client) => + client.request("POST", "resource-servers", { + name: `Auth0 ${api.name}`, + identifier: api.audience, + skip_consent_for_verifiable_first_party_clients: true, + token_dialect: "rfc9068_profile", + }), + }, + }); + } + if (!snapshot.grants.some((grant) => grant.audience === api.audience)) { + add({ + id: "P019", + subject: api.audience, + severity: "error", + check: "client grant", + message: `The components (${usedBy}) need the ${api.name}, but the application has no client grant for it.`, + remedy: { + summary: `Grant the application access to the ${api.name}`, + apply: (client) => + client.request("POST", "client-grants", { + client_id: clientId, + audience: api.audience, + scope: api.scope, + subject_type: "user", + }), + }, + }); + } + if (!refresh?.policies?.some((policy) => policy.audience === api.audience)) { + add({ + id: "P020", + subject: api.audience, + severity: "error", + check: "refresh token policy", + message: `The components (${usedBy}) need the ${api.name}, but the refresh token has no policy for it, so the portal cannot get its tokens.`, + remedy: { + summary: `Add the ${api.name} to the multi-resource refresh token`, + apply: (client) => + patchRefresh(client, app, { + policies: [...(refresh?.policies ?? []), { audience: api.audience, scope: api.scope }], + }), + }, + }); + } + } + + if (snapshot.applicationConnections.length === 0) { + add({ + id: "P021", + severity: "error", + check: "connections", + message: "No connection is enabled for the application, so nobody can sign in.", + remedy: { + summary: "Enable a connection for the application, or promote one to the tenant level", + command: `auth0 api patch "connections//clients" --data '[{"client_id":"${clientId}","status":true}]'`, + }, + }); + } + + if (snapshot.organization) { + const shared = snapshot.organization.connections.some((id) => + snapshot.applicationConnections.some((connection) => connection.id === id), + ); + if (!shared) { + add({ + id: "P022", + severity: "error", + check: "organization connections", + message: `Organization "${snapshot.organization.name}" has no connection in common with the application, so its members cannot sign in.`, + remedy: { + summary: "Enable a connection that the application and the organization share", + command: `auth0 api post "organizations/${snapshot.organization.id}/enabled_connections" --data '{"connection_id":"","assign_membership_on_login":false}'`, + }, + }); + } + } + + if (!app.session_transfer?.allowed_authentication_methods?.length) { + add({ + id: "P024", + severity: "warning", + check: "session transfer", + message: "Session transfer is not enabled, so a native app cannot open the portal with a session.", + remedy: { + summary: "Allow session transfer on the portal application", + command: `auth0 api patch "clients/${clientId}" --data '{"session_transfer":{"allowed_authentication_methods":["query"],"enforce_device_binding":"ip"}}'`, + }, + }); + } + + return findings; +} + +function componentsOn(components: ComponentShape[], apiName: string): ComponentShape[] { + const needle = apiName === "My Account API" ? "my_account" : "my_organization"; + return components.filter((component) => component.type.includes(needle)); +} + +function componentLabel(component: ComponentShape): string { + const name = component.type.split(":").pop() ?? component.type; + return name.replace(/_/g, " "); +} + +function allComponents(pages: { components?: ComponentShape[] }[]): ComponentShape[] { + const all: ComponentShape[] = []; + const visit = (components: ComponentShape[] = []) => { + for (const component of components) { + all.push(component); + visit(component.config?.children); + } + }; + for (const page of pages) visit(page.components); + return all; +} diff --git a/internal/cli/data/universal-portals/_source/src/audit/fix.ts b/internal/cli/data/universal-portals/_source/src/audit/fix.ts new file mode 100644 index 000000000..a6671cbc8 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/audit/fix.ts @@ -0,0 +1,85 @@ +import type { ApiClient } from "../api.js"; +import type { Ui } from "../ui.js"; +import { auditPortal, Finding } from "./checks.js"; +import type { PortalSnapshot } from "./snapshot.js"; + +export interface ApplyDeps { + api: ApiClient; + ui: Ui; + // Reads the portal again. Each fix reads it first, so a fix never works on a + // stale copy and never overwrites a change made by an earlier fix. + readSnapshot: () => Promise; +} + +export interface ApplyResult { + applied: number; + failed: number; + // Fixes that were no longer needed by the time they ran. + alreadyFixed: number; +} + +// Only errors are fixed. Warnings may be intentional, so they stay in the +// report and are never changed by the CLI. +export function fixableFindings(findings: Finding[]): Finding[] { + return findings.filter((finding) => finding.severity === "error" && finding.remedy?.apply); +} + +// The ID and subject identify a finding. The message can change when the portal +// changes (a count, a slug), so it never takes part in matching. +export function findingKey(finding: Finding): string { + return `${finding.id}:${finding.subject ?? ""}`; +} + +export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise { + const { ui } = deps; + const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 }; + + for (const finding of selected) { + const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding)); + if (!fresh?.remedy?.apply) { + result.alreadyFixed += 1; + ui.success(`${finding.id} ${finding.check}`, "already fixed"); + continue; + } + // Each fix shows the finding it resolves, with a spinner while it runs. + const label = `${fresh.id} ${fresh.check}`; + try { + await ui.task(`Fixing ${fresh.check}`, () => fresh.remedy!.apply!(deps.api)); + result.applied += 1; + ui.fixed(label, fresh.remedy.summary); + } catch (error) { + result.failed += 1; + ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return result; +} + +// A fix can expose errors the first audit could not see: a new application has no +// client grant, so linking it makes P019 visible. After each pass, chooseNext picks +// which of the newly visible errors to fix next (none ends the run). A finding is +// attempted once, and passes stop after MAX_FIX_ROUNDS so a fix that keeps breaking +// another cannot loop. +const MAX_FIX_ROUNDS = 3; + +export async function applyFixRounds( + selected: Finding[], + deps: ApplyDeps, + chooseNext: (fixable: Finding[]) => Promise, +): Promise { + const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 }; + const attempted = new Set(); + let pending = selected; + + for (let round = 0; round < MAX_FIX_ROUNDS && pending.length > 0; round += 1) { + for (const finding of pending) attempted.add(findingKey(finding)); + const result = await applyFixes(pending, deps); + total.applied += result.applied; + total.failed += result.failed; + total.alreadyFixed += result.alreadyFixed; + + const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding))); + pending = exposed.length > 0 ? await chooseNext(exposed) : []; + } + return total; +} diff --git a/internal/cli/data/universal-portals/_source/src/audit/remedies.ts b/internal/cli/data/universal-portals/_source/src/audit/remedies.ts new file mode 100644 index 000000000..30fc18d16 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/audit/remedies.ts @@ -0,0 +1,92 @@ +import type { ApiClient } from "../api.js"; +import { ensurePortalClient } from "../setup/resources.js"; +import { PORTAL_APP_SUFFIX, REFRESH_TOKEN_LIFETIMES } from "../setup/payload.js"; +import { ApplicationShape, PortalSnapshot, SidebarShape, hostOf } from "./snapshot.js"; + +// The fixes the audit can apply. Each one changes the portal or its application in +// the tenant. Detection stays in checks.ts. + +// Legacy sidebar links used "to" for pages and "url" for external links, or had no :v2 type. +export function isLegacyLink(item: SidebarShape): boolean { + return !item.type.endsWith(":v2") || item.config?.to !== undefined || item.config?.url !== undefined; +} + +// Converts the legacy sidebar links to the :v2 types. +export function toV2Link(item: SidebarShape): SidebarShape { + if (!isLegacyLink(item)) return item; + const external = item.config?.url !== undefined || item.config?.href !== undefined || item.type.includes("external"); + const config: Record = { label: item.config?.label, icon: item.config?.icon }; + if (external) { + config.href = item.config?.href ?? item.config?.url; + return { type: "sidebar:component:auth0:external_link:v2", config }; + } + config.page_slug = item.config?.page_slug ?? item.config?.to; + return { type: "sidebar:component:auth0:internal_link:v2", config }; +} + +// Creates the application with the same defaults setup uses (or reuses the one an +// earlier run created), then links it to the portal. The template is business when +// the portal has organization components. The secret is only returned on create, so +// the link has to follow in the same step, and a rerun finds the application by name. +export async function createPortalApplication(api: ApiClient, snapshot: PortalSnapshot, usesOrganization: boolean): Promise { + const client = await ensurePortalClient(api, { + name: `${snapshot.portal.name ?? snapshot.portal.slug} ${PORTAL_APP_SUFFIX}`, + slug: snapshot.portal.slug, + template: usesOrganization ? "business" : "consumer", + tenantDomain: snapshot.tenantDomain, + portalHost: hostOf(snapshot), + isCustomDomain: snapshot.customDomain?.status === "ready", + }); + return api.request("PATCH", `portals/${encodeURIComponent(snapshot.portal.id)}`, { + client: { + client_id: client.value.clientId, + client_secret: client.value.clientSecret, + token_endpoint_auth_method: "client_secret_post", + }, + }); +} + +// The refresh token has to be sent whole, with its rotation type, so the +// current settings are merged with the change. +export function patchRefresh(api: ApiClient, app: ApplicationShape, changes: Record): Promise { + const current = app.refresh_token ?? {}; + return api.request("PATCH", `clients/${encodeURIComponent(app.client_id)}`, { + refresh_token: { + rotation_type: current.rotation_type ?? "non-rotating", + expiration_type: "expiring", + token_lifetime: current.token_lifetime ?? REFRESH_TOKEN_LIFETIMES.absoluteSeconds, + idle_token_lifetime: current.idle_token_lifetime ?? REFRESH_TOKEN_LIFETIMES.idleSeconds, + policies: current.policies ?? [], + ...changes, + }, + }); +} + +// The portal's pages are replaced as a whole, so the page list is sent with +// the new home page. +export function patchPages(api: ApiClient, portalId: string, snapshot: PortalSnapshot, home: string): Promise { + return api.request("PATCH", `portals/${encodeURIComponent(portalId)}`, { + pages: { default: home, standard: snapshot.portal.pages?.standard ?? [] }, + }); +} + +export function addSidebarLink( + api: ApiClient, + portalId: string, + snapshot: PortalSnapshot, + pageSlug: string, + label: string, + icon: string, +): Promise { + const components = snapshot.portal.navigation?.sidebar?.components ?? []; + return api.request("PATCH", `portals/${encodeURIComponent(portalId)}`, { + navigation: { + sidebar: { + components: [ + ...components, + { type: "sidebar:component:auth0:internal_link:v2", config: { label, page_slug: pageSlug, icon } }, + ], + }, + }, + }); +} diff --git a/internal/cli/data/universal-portals/_source/src/audit/run.ts b/internal/cli/data/universal-portals/_source/src/audit/run.ts new file mode 100644 index 000000000..f7c7ac75f --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/audit/run.ts @@ -0,0 +1,64 @@ +import { ApiClient } from "../api.js"; +import { auditPortal, Finding } from "./checks.js"; +import { fetchPortalSnapshot } from "./snapshot.js"; +import type { Ui } from "../ui.js"; + +export interface AuditOptions { + portalId: string; + organizationId?: string; + strict: boolean; + // The banner is for the first audit only; the check after the fixes continues it. + showBanner?: boolean; +} + +export interface AuditResult { + portalId: string; + findings: Finding[]; + errors: number; + warnings: number; + failed: boolean; +} + +export async function runAudit(options: AuditOptions, deps: { api: ApiClient; ui: Ui; tenantDomain: string }): Promise { + const { api, ui, tenantDomain } = deps; + if (options.showBanner !== false) ui.banner("Universal Portals audit", tenantDomain); + + const snapshot = await ui.task("Reading the portal", () => + fetchPortalSnapshot(api, options.portalId, tenantDomain, { organizationId: options.organizationId }), + ); + const findings = auditPortal(snapshot); + const errors = findings.filter((finding) => finding.severity === "error").length; + const warnings = findings.length - errors; + + ui.section(`Portal "${snapshot.portal.slug}"`); + if (findings.length === 0) { + ui.success("No problems found"); + } else { + ui.table( + ["", "ID", "Check", "Fix"], + findings.map((finding) => [ + finding.severity === "error" ? "✖" : "▲", + finding.id, + finding.check, + finding.remedy?.summary ?? finding.message, + ]), + ); + for (const finding of findings.filter((item) => item.remedy?.command && !item.remedy.apply)) { + ui.detail(`${finding.id} manually: ${finding.remedy?.command}`); + } + } + + ui.newline(); + const summary = `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`; + if (errors > 0) ui.error(summary); + else if (warnings > 0) ui.warn(summary); + else ui.success(summary); + + return { + portalId: options.portalId, + findings, + errors, + warnings, + failed: errors > 0 || (options.strict && warnings > 0), + }; +} diff --git a/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts b/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts new file mode 100644 index 000000000..d41c25f5c --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts @@ -0,0 +1,166 @@ +import { ApiClient, ApiError, listAll } from "../api.js"; + +// Everything the audit needs about one portal, read from the tenant. The checks +// work on this snapshot only, so they can be tested without a tenant. +export interface PortalSnapshot { + tenantDomain: string; + portal: PortalShape; + application?: ApplicationShape; + grants: { id?: string; audience: string; scope?: string[] }[]; + resourceServers: string[]; + // Form ID to whether the form exists in the tenant. + forms: Record; + // Connections enabled on the application. + applicationConnections: { id: string; name?: string }[]; + customDomain?: { domain: string; status: string }; + organization?: { id: string; name: string; connections: string[] }; +} + +export interface PortalShape { + id: string; + slug: string; + name?: string; + client?: { client_id: string }; + navigation?: { sidebar?: { components?: SidebarShape[] } }; + pages?: { default?: string; standard?: PageShape[] }; + custom_code?: { head?: string }; +} + +export interface SidebarShape { + type: string; + config?: Record; +} + +export interface PageShape { + title: string; + slug: string; + components?: ComponentShape[]; +} + +export interface ComponentShape { + type: string; + config?: { children?: ComponentShape[]; form_id?: string; [key: string]: unknown }; +} + +export interface ApplicationShape { + client_id: string; + name?: string; + app_type?: string; + grant_types?: string[]; + callbacks?: string[]; + allowed_logout_urls?: string[]; + organization_usage?: string; + is_token_endpoint_ip_header_trusted?: boolean; + require_proof_of_possession?: boolean; + session_transfer?: { allowed_authentication_methods?: string[] | null }; + refresh_token?: { + rotation_type?: string; + idle_token_lifetime?: number; + token_lifetime?: number; + infinite_idle_token_lifetime?: boolean; + policies?: { audience: string; scope?: string[] }[]; + }; +} + +export interface SnapshotOptions { + organizationId?: string; +} + +export async function fetchPortalSnapshot( + api: ApiClient, + portalId: string, + tenantDomain: string, + options: SnapshotOptions = {}, +): Promise { + const portal = await api.request("GET", `portals/${encodeURIComponent(portalId)}`); + + let application: ApplicationShape | undefined; + let grants: PortalSnapshot["grants"] = []; + let applicationConnections: PortalSnapshot["applicationConnections"] = []; + const clientId = portal.client?.client_id; + if (clientId) application = await readApplication(api, clientId); + if (application && clientId) { + grants = await listAll(api, `client-grants?client_id=${encodeURIComponent(clientId)}`, "client_grants"); + applicationConnections = await listAll<{ id: string; name?: string }>(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections"); + } + + const resourceServers = (await listAll<{ identifier: string }>(api, "resource-servers", "resource_servers")).map((server) => server.identifier); + + const forms: Record = {}; + for (const formId of formIdsOf(portal)) { + forms[formId] = await formExists(api, formId); + } + + const customDomain = await readCustomDomain(api); + const organization = options.organizationId ? await readOrganization(api, options.organizationId) : undefined; + + return { + tenantDomain, + portal, + application, + grants, + resourceServers, + forms, + applicationConnections, + customDomain, + organization, + }; +} + +export function formIdsOf(portal: PortalShape): string[] { + const ids: string[] = []; + const visit = (components: ComponentShape[] = []) => { + for (const component of components) { + if (component.type === "page:component:auth0:form" && component.config?.form_id) { + ids.push(component.config.form_id); + } + visit(component.config?.children); + } + }; + for (const page of portal.pages?.standard ?? []) visit(page.components); + return [...new Set(ids)]; +} + +// A portal can still point at an application that was deleted. That is a finding +// (P009), not a failure of the audit, so a 404 here means no application. +async function readApplication(api: ApiClient, clientId: string): Promise { + try { + return await api.request("GET", `clients/${encodeURIComponent(clientId)}`); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return undefined; + throw error; + } +} + +// The API answers 400 for an ID it cannot match to a form (the ID fails its schema), +// which is the same finding as a deleted form (P007), so both mean the form is missing. +async function formExists(api: ApiClient, formId: string): Promise { + try { + await api.request("GET", `forms/${encodeURIComponent(formId)}`); + return true; + } catch (error) { + if (error instanceof ApiError && (error.status === 404 || error.status === 400)) return false; + throw error; + } +} + +export async function readCustomDomain(api: ApiClient): Promise { + try { + const custom = await api.request<{ domain: string; status: string }>("GET", "custom-domains/default"); + return custom; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return undefined; + throw error; + } +} + +async function readOrganization(api: ApiClient, organizationId: string): Promise { + const org = await api.request<{ id: string; name: string }>("GET", `organizations/${encodeURIComponent(organizationId)}`); + const connections = (await listAll<{ connection_id: string }>(api, `organizations/${encodeURIComponent(organizationId)}/enabled_connections`, "enabled_connections")).map((connection) => connection.connection_id); + return { id: org.id, name: org.name, connections }; +} + +// The host the portal is served from: the custom domain when it is ready. +export function hostOf(snapshot: PortalSnapshot): string { + return snapshot.customDomain?.status === "ready" ? snapshot.customDomain.domain : snapshot.tenantDomain; +} diff --git a/internal/cli/data/universal-portals/_source/src/browser.ts b/internal/cli/data/universal-portals/_source/src/browser.ts new file mode 100644 index 000000000..8190988f0 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/browser.ts @@ -0,0 +1,23 @@ +import { spawn } from "node:child_process"; + +export interface BrowserCommand { + command: string; + args: string[]; +} + +// The command that opens a URL in the default browser on each platform. The +// core CLI uses the same approach. +export function browserCommand(url: string, platform: NodeJS.Platform): BrowserCommand { + if (platform === "darwin") return { command: "open", args: [url] }; + if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] }; + return { command: "xdg-open", args: [url] }; +} + +// Opening the browser is a convenience, so a failure is reported to the caller +// and never fails the setup. +export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): void { + const { command, args } = browserCommand(url, platform); + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.on("error", () => undefined); + child.unref(); +} diff --git a/internal/cli/data/universal-portals/_source/src/clack-prompter.ts b/internal/cli/data/universal-portals/_source/src/clack-prompter.ts new file mode 100644 index 000000000..d4a8b8b8d --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/clack-prompter.ts @@ -0,0 +1,50 @@ +import { confirm, isCancel, multiselect, select, text } from "@clack/prompts"; +import { CancelledError, type ChecklistItem, type Choice, type Prompter } from "./prompt.js"; + +// The interactive questions, drawn with Clack. Input and output are injected so +// tests can answer them without a terminal. +export function clackPrompter(input: NodeJS.ReadStream, output: NodeJS.WriteStream): Prompter { + return { + async ask(question, defaultValue, example) { + const answer = await text({ message: question, defaultValue, placeholder: defaultValue ?? example, input, output }); + return unwrap(answer).trim() || defaultValue || ""; + }, + + async confirm(question, defaultValue) { + return unwrap(await confirm({ message: question, initialValue: defaultValue, input, output })); + }, + + async checklist(question: string, items: ChecklistItem[]): Promise { + const selected = unwrap( + await multiselect({ + message: question, + options: items.map((item, position) => ({ value: position, label: item.label, hint: item.hint })), + initialValues: items.flatMap((item, position) => (item.checked ? [position] : [])), + required: false, + input, + output, + }), + ); + return selected.map((position) => items[position].value); + }, + + async choose(question: string, options: Choice[]): Promise { + // Clack compares option values by identity, so the option index is the value. + const index = unwrap( + await select({ + message: question, + options: options.map((option, position) => ({ value: position, label: option.label, hint: option.hint })), + input, + output, + }), + ); + return options[index].value; + }, + }; +} + +// Clack answers with a cancel symbol instead of a value when the user presses Ctrl+C. +function unwrap(answer: T): Exclude { + if (isCancel(answer)) throw new CancelledError(); + return answer as Exclude; +} diff --git a/internal/cli/data/universal-portals/_source/src/cli.ts b/internal/cli/data/universal-portals/_source/src/cli.ts new file mode 100644 index 000000000..e999d3d6b --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/cli.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +import { parseSetupArgs, ParsedSetupArgs, UsageError, usage } from "./args.js"; +import type { FetchLike } from "./api.js"; +import { clackPrompter } from "./clack-prompter.js"; +import type { Prompter } from "./prompt.js"; +import { runAuditCommand } from "./audit-command.js"; +import { describeError } from "./errors.js"; +import { runSetupCommand } from "./setup-command.js"; +import { colorEnabled, Ui } from "./ui.js"; + +export interface IoStreams { + stdout: { write(text: string): void }; + stderr: NodeJS.WriteStream; + stdinStream: NodeJS.ReadStream; + isTTY: boolean; + fetchImpl?: FetchLike; + // Answers the questions. Defaults to the terminal when the run is interactive. + prompter?: Prompter; + // Opens the portal after setup. Defaults to the system browser; previews replace it. + openBrowser?: (url: string) => void; +} + +export async function main(argv: string[], env: NodeJS.ProcessEnv, io: IoStreams): Promise { + let args: ParsedSetupArgs; + try { + args = parseSetupArgs(argv); + } catch (error) { + io.stderr.write(`${describeError(error).message}\n`); + return error instanceof UsageError ? 2 : 1; + } + + if (args.command === "help") { + io.stdout.write(`${usage()}\n`); + return 0; + } + + const agentMode = args.json || env.AUTH0_AGENT_MODE === "1" || env.AUTH0_AGENT_MODE === "true"; + const interactive = !args.noInput && !agentMode && io.isTTY; + const ui = new Ui( + io.stderr, + colorEnabled(env, io.isTTY, args.noColor) && !agentMode, + agentMode, + io.isTTY && !agentMode, + ); + + // Undefined when the run cannot ask: the commands then fall back to flags and plain output. + const prompter = interactive ? (io.prompter ?? clackPrompter(io.stdinStream, io.stderr)) : undefined; + if (args.command === "audit") return runAuditCommand(args, env, io, ui, prompter); + return runSetupCommand(args, env, io, ui, interactive, prompter); +} + +// npx starts the plugin through a symlink in node_modules/.bin, so compare the +// resolved path with this module's URL, not the path as typed. +const entryPoint = process.argv[1] ? realpathSync(process.argv[1]) : undefined; +if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { + main(process.argv.slice(2), process.env, { + stdout: process.stdout, + stderr: process.stderr, + stdinStream: process.stdin, + isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY), + }).then((code) => { + process.exitCode = code; + }); +} diff --git a/internal/cli/data/universal-portals/_source/src/context.ts b/internal/cli/data/universal-portals/_source/src/context.ts new file mode 100644 index 000000000..b996597aa --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/context.ts @@ -0,0 +1,42 @@ +// The only module that reads the plugin runtime environment. The CLI sets +// these variables when it starts the plugin. + +import { UsageError } from "./args.js"; + +export interface PluginContext { + proxyUrl: string; + proxyToken: string; + tenantDomain: string; +} + +// The --tenant flag must name the tenant the CLI is running against. The optional +// consequence says what the user can rely on, such as that nothing was written. +export function checkTenantFlag(requested: string | undefined, context: PluginContext, consequence = ""): void { + if (requested && requested !== context.tenantDomain) { + throw new UsageError( + `--tenant ${requested} does not match the tenant the CLI is running against (${context.tenantDomain}).${consequence}`, + ); + } +} + +export function readContext(env: NodeJS.ProcessEnv): PluginContext { + const proxyUrl = env.AUTH0_CLI_PROXY_URL; + const proxyToken = env.AUTH0_CLI_PROXY_TOKEN; + const tenantDomain = env.AUTH0_CLI_TENANT_DOMAIN; + + const missing = [ + ["AUTH0_CLI_PROXY_URL", proxyUrl], + ["AUTH0_CLI_PROXY_TOKEN", proxyToken], + ["AUTH0_CLI_TENANT_DOMAIN", tenantDomain], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + + if (missing.length > 0 || !proxyUrl || !proxyToken || !tenantDomain) { + throw new Error( + `Run this plugin through the Auth0 CLI (auth0 universal-portals). Missing: ${missing.join(", ")}`, + ); + } + + return { proxyUrl, proxyToken, tenantDomain }; +} diff --git a/internal/cli/data/universal-portals/_source/src/dashboard.ts b/internal/cli/data/universal-portals/_source/src/dashboard.ts new file mode 100644 index 000000000..25722dd2f --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/dashboard.ts @@ -0,0 +1,53 @@ +// Management Dashboard links for the resources setup creates, so the output can +// link each ID to the page where it can be reviewed. + +export interface TenantLocation { + base: string; + region: string; + name: string; +} + +// For example "acme.us.auth0.com" -> https://manage.auth0.com, region "us", +// name "acme". A tenant without a region code is treated as "us". +export function tenantLocation(tenantDomain: string): TenantLocation { + const parts = tenantDomain.split("."); + const hasRegion = parts.length >= 4 && parts[1].length === 2; + const serviceDomain = hasRegion ? parts.slice(2) : parts.slice(1); + return { + base: `https://manage.${serviceDomain.join(".")}`, + region: hasRegion ? parts[1] : "us", + name: parts[0], + }; +} + +function dashboardRoot(tenantDomain: string): string { + const { base, region, name } = tenantLocation(tenantDomain); + return `${base}/dashboard/${region}/${name}`; +} + +export function applicationUrl(tenantDomain: string, clientId: string): string { + return `${dashboardRoot(tenantDomain)}/applications/${clientId}/settings`; +} + +export function portalEditUrl(tenantDomain: string, portalId: string): string { + return `${dashboardRoot(tenantDomain)}/universal-portals/${portalId}/edit`; +} + +// Forms and flows live in the Auth0 Forms app, under the tenant's path. +function formsRoot(tenantDomain: string): string { + const { region, name } = tenantLocation(tenantDomain); + return `https://forms.auth0.com/tenants/${region}/${name}`; +} + +export function formEditUrl(tenantDomain: string, formId: string): string { + return `${formsRoot(tenantDomain)}/forms/${formId}/edit`; +} + +export function flowEditUrl(tenantDomain: string, flowId: string): string { + return `${formsRoot(tenantDomain)}/flows/${flowId}/edit`; +} + +// Vault connections are listed per app type, so the link opens the AUTH0 list. +export function vaultAppsUrl(tenantDomain: string): string { + return `${formsRoot(tenantDomain)}/vault/apps/AUTH0/edit`; +} diff --git a/internal/cli/data/universal-portals/_source/src/errors.ts b/internal/cli/data/universal-portals/_source/src/errors.ts new file mode 100644 index 000000000..6cf419951 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/errors.ts @@ -0,0 +1,27 @@ +import { ApiError } from "./api.js"; +import { UsageError } from "./args.js"; +import type { IoStreams } from "./cli.js"; +import { PluginError } from "./plugin-error.js"; +import type { Ui } from "./ui.js"; + +export function reportError(error: unknown, json: boolean, io: IoStreams, ui: Ui): number { + const { code, message, details } = describeError(error); + if (json) { + io.stderr.write(`${JSON.stringify({ error: code, message, ...details })}\n`); + return 1; + } + ui.error(message); + for (const fix of details.fixes ?? []) ui.detail(fix); + return error instanceof UsageError ? 2 : 1; +} + +export function describeError(error: unknown): { code: string; message: string; details: { fixes?: string[]; status?: number } } { + if (error instanceof PluginError) { + return { code: error.code, message: error.message, details: error.fixes ? { fixes: error.fixes } : {} }; + } + if (error instanceof ApiError) { + return { code: error.errorCode ?? "api_error", message: error.message, details: { status: error.status } }; + } + if (error instanceof Error) return { code: "setup_failed", message: error.message, details: {} }; + return { code: "setup_failed", message: String(error), details: {} }; +} diff --git a/internal/cli/data/universal-portals/_source/src/forms.ts b/internal/cli/data/universal-portals/_source/src/forms.ts new file mode 100644 index 000000000..03ade12f5 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/forms.ts @@ -0,0 +1,204 @@ +// Form documents for the Forms that Auth0 Portals pages embed. Each form +// writes to the signed-in user through the forms vault connection. + +export type FormKey = "personalInfo" | "privacyConsent" | "communicationPreferences"; + +export interface FormDefinition { + key: FormKey; + name: string; + document: Record; +} + +export const FORM_NAMES: Record = { + personalInfo: "Profile update (Universal Portals)", + privacyConsent: "Privacy settings (Universal Portals)", + communicationPreferences: "Marketing communication preferences (Universal Portals)", +}; + +export function buildPortalForms(vaultConnectionId: string): FormDefinition[] { + return [ + { key: "personalInfo", name: FORM_NAMES.personalInfo, document: personalInfoDocument(vaultConnectionId) }, + { + key: "communicationPreferences", + name: FORM_NAMES.communicationPreferences, + document: communicationPreferencesDocument(vaultConnectionId), + }, + { key: "privacyConsent", name: FORM_NAMES.privacyConsent, document: privacyDocument(vaultConnectionId) }, + ]; +} + +function field(id: string, label: string, type: string, extra: Record = {}): Record { + return { id, category: "FIELD", type, label, required: false, sensitive: false, ...extra }; +} + +function nextButton(id: string, text: string): Record { + return { id, category: "BLOCK", type: "NEXT_BUTTON", config: { text } }; +} + +function formDocument( + name: string, + step: { id: string; coordinates: Record; components: Record[]; flowNodeId: string }, + start: Record, + ending: Record, + flowName: string, + actions: Record[], + connectionId: string, +): Record { + return { + version: "4.0.0", + form: { + name, + languages: { primary: "en" }, + nodes: [ + { + id: step.id, + type: "STEP", + coordinates: step.coordinates, + config: { components: step.components, next_node: step.flowNodeId }, + }, + { + id: step.flowNodeId, + type: "FLOW", + coordinates: { x: 988, y: 9 }, + config: { flow_id: "#FLOW-1#", next_node: "$ending" }, + }, + ], + start, + ending, + }, + flows: { "#FLOW-1#": { name: flowName, actions } }, + connections: { "#CONN-1#": { id: connectionId } }, + }; +} + +function updateUserAction(id: string, changes: Record): Record { + return { + id, + type: "AUTH0", + action: "UPDATE_USER", + allow_failure: false, + mask_output: false, + params: { + connection_id: "#CONN-1#", + user_id: "{{context.user.user_id}}", + changes, + }, + }; +} + +function personalInfoDocument(connectionId: string): Record { + return formDocument( + FORM_NAMES.personalInfo, + { + id: "step_BL3V", + coordinates: { x: 346, y: -221 }, + flowNodeId: "flow_DcHK", + components: [ + field("full_name", "Full name", "TEXT", { config: { multiline: false } }), + field("job_title", "Job title", "TEXT", { config: { multiline: false } }), + field("mobile_number", "Mobile number", "TEL", { config: { country_picker: true } }), + field("date_of_birth", "Date of birth", "DATE", { config: { format: "DATE" } }), + field("linkedin", "LinkedIn", "URL"), + nextButton("next_button_UmEY", "Update"), + ], + }, + { next_node: "step_BL3V", coordinates: { x: 0, y: 0 } }, + { resume_flow: true, coordinates: { x: 1367, y: -7 } }, + "Update profile", + [ + updateUserAction("update_user_v4C0", { + user_metadata: { + 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}}", + }, + }), + ], + connectionId, + ); +} + +function communicationPreferencesDocument(connectionId: string): Record { + return formDocument( + FORM_NAMES.communicationPreferences, + { + id: "step_4Td2", + coordinates: { x: 217, y: -218 }, + flowNodeId: "flow_etvH", + components: [ + field("communication_preferences", "Communication preferences", "CHOICE", { + config: { + multiple: true, + options: [ + { label: "Product Announcements", value: "Product Announcements" }, + { label: "Featured Content", value: "Featured Content" }, + { label: "Digest News", value: "Digest News" }, + { label: "Events", value: "Events" }, + ], + }, + }), + nextButton("next_button_US5N", "Update"), + ], + }, + { next_node: "step_4Td2", coordinates: { x: -25, y: -98 } }, + { resume_flow: true, coordinates: { x: 1194, y: -59 } }, + "Update preferences", + [ + updateUserAction("update_user_HitP", { + app_metadata: { communication_preferences: "{{fields.communication_preferences}}" }, + }), + ], + connectionId, + ); +} + +function privacyDocument(connectionId: string): Record { + const consentField = (id: string, label: string, hint: string): Record => ({ + id, + category: "FIELD", + type: "BOOLEAN", + label, + hint, + required: true, + sensitive: false, + config: { default_value: false }, + }); + const learnMore = (text: string): string => + `

${text} Learn more.

`; + + return formDocument( + FORM_NAMES.privacyConsent, + { + id: "step_y0Ri", + coordinates: { x: 500, y: 0 }, + flowNodeId: "flow_AB1r", + components: [ + consentField("data_sharing", "Data sharing", learnMore("Allow app usage data to improve features.")), + consentField("profile_visibility", "Profile visibility", learnMore("Show my profile in search results.")), + consentField( + "location_tracking", + "Location tracking", + learnMore("Allow location tracking for personalized recommendations."), + ), + consentField("ad_personalization", "Ad personalization", learnMore("Use my data to personalize ads.")), + nextButton("next_button_aB8e", "Update"), + ], + }, + { next_node: "step_y0Ri", coordinates: { x: 202, y: 247 } }, + { resume_flow: true, coordinates: { x: 1445, y: 247 } }, + "Update privacy settings", + [ + updateUserAction("update_user_uOZW", { + app_metadata: { + data_sharing: "{{fields.data_sharing}}", + location_tracking: "{{fields.location_tracking}}", + ad_personalization: "{{fields.ad_personalization}}", + profile_visibility: "{{fields.profile_visibility}}", + }, + }), + ], + connectionId, + ); +} diff --git a/internal/cli/data/universal-portals/_source/src/plugin-error.ts b/internal/cli/data/universal-portals/_source/src/plugin-error.ts new file mode 100644 index 000000000..f4652ebed --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/plugin-error.ts @@ -0,0 +1,12 @@ +// An error the CLI reports with a stable code. The code is what --json prints as +// "error", and fixes are the commands or steps that resolve it. +export class PluginError extends Error { + constructor( + message: string, + readonly code: string, + readonly fixes?: string[], + ) { + super(message); + this.name = new.target.name; + } +} diff --git a/internal/cli/data/universal-portals/_source/src/portal-model.ts b/internal/cli/data/universal-portals/_source/src/portal-model.ts new file mode 100644 index 000000000..d7f2fe8dd --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/portal-model.ts @@ -0,0 +1,120 @@ +// Shapes of the Auth0 Portals Management API payloads used by setup +// and, later, audit. Written by hand from the public API behavior. + +export const PORTAL_SLUG_PATTERN = /^[a-z][a-z0-9-]{0,99}$/; + +export const LIMITS = { + portalName: { min: 1, max: 150 }, + pageTitle: { min: 1, max: 150 }, + sectionTitle: { max: 50 }, + sectionDescription: { max: 200 }, + sectionChildren: { max: 20 }, + headingTitle: { max: 50 }, + headingDescription: { max: 200 }, + richTextContent: { max: 10000 }, + imageSrc: { max: 500 }, + imageHeight: { min: 1, max: 500 }, + separatorText: { max: 50 }, + formId: { max: 50 }, + completionMessage: { max: 200 }, + sidebarLabel: { max: 50 }, + pageSlug: { max: 50 }, + externalHref: { max: 200 }, + icon: { max: 50 }, + customCodeHead: { max: 10000 }, + clientSecret: { max: 256 }, +} as const; + +export function isValidPortalSlug(slug: string): boolean { + return PORTAL_SLUG_PATTERN.test(slug); +} + +export type SectionVariant = "card" | "none"; +export type SeparatorVariant = "solid" | "dashed" | "none"; +export type ImagePosition = "left" | "center" | "right"; + +export interface FormComponent { + type: "page:component:auth0:form"; + config: { form_id: string; completion_message: string }; +} + +export interface HeadingComponent { + type: "page:component:auth0:typography:heading"; + config: { title: string; description?: string }; +} + +export interface RichTextComponent { + type: "page:component:auth0:typography:rich_text"; + config: { content: string }; +} + +export interface ImageComponent { + type: "page:component:auth0:media:image"; + config?: { src: string; position?: ImagePosition; height?: number }; +} + +export interface SeparatorComponent { + type: "page:component:auth0:structure:separator"; + config?: { variant?: SeparatorVariant; text?: string }; +} + +// Universal Components: the portal renders them itself and they call the +// My Account or My Organization API. None of them take a config. +export interface UniversalComponent { + type: + | "page:component:auth0:my_account:mfa_management" + | "page:component:auth0:my_account:passkey_management" + | "page:component:auth0:my_organization:details_edit" + | "page:component:auth0:my_organization:domain_table"; +} + +export type LeafComponent = + | FormComponent + | HeadingComponent + | RichTextComponent + | ImageComponent + | SeparatorComponent + | UniversalComponent; + +export interface SectionComponent { + type: "page:component:auth0:structure:section"; + config: { + title?: string; + description?: string; + variant: SectionVariant; + children: LeafComponent[]; + }; +} + +export type PageComponent = LeafComponent | SectionComponent; + +export interface PortalPage { + title: string; + slug: string; + components?: PageComponent[]; +} + +export interface SidebarInternalLink { + type: "sidebar:component:auth0:internal_link:v2"; + config: { label: string; page_slug: string; icon?: string }; +} + +export interface SidebarExternalLink { + type: "sidebar:component:auth0:external_link:v2"; + config: { label: string; href: string; icon?: string }; +} + +export type SidebarComponent = SidebarInternalLink | SidebarExternalLink; + +export interface CreatePortalRequest { + slug: string; + name: string; + client: { + token_endpoint_auth_method: "client_secret_post"; + client_id: string; + client_secret: string; + }; + navigation?: { sidebar?: { components?: SidebarComponent[] } }; + pages?: { default?: string; standard?: PortalPage[] }; + custom_code?: { head?: string }; +} diff --git a/internal/cli/data/universal-portals/_source/src/prompt.ts b/internal/cli/data/universal-portals/_source/src/prompt.ts new file mode 100644 index 000000000..1ad50f67e --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/prompt.ts @@ -0,0 +1,34 @@ +// The question types the setup and audit flows depend on. The flows take a +// Prompter, so tests can answer questions without a terminal. The terminal +// implementation is in clack-prompter.ts. +export interface Choice { + label: string; + hint?: string; + value: T; +} + +export interface ChecklistItem { + label: string; + hint?: string; + value: T; + checked: boolean; +} + +export interface Prompter { + // The default is returned on Enter and shown as the placeholder. Without a default, + // the example is shown as the placeholder only. + ask(question: string, defaultValue?: string, example?: string): Promise; + confirm(question: string, defaultValue: boolean): Promise; + choose(question: string, options: Choice[]): Promise; + // Returns the values of the items the user keeps selected. Items start selected when + // they say so. + checklist(question: string, items: ChecklistItem[]): Promise; +} + +// Thrown when the user presses Ctrl+C at a question. +export class CancelledError extends Error { + constructor() { + super("Cancelled."); + this.name = "CancelledError"; + } +} diff --git a/internal/cli/data/universal-portals/_source/src/scopes.ts b/internal/cli/data/universal-portals/_source/src/scopes.ts new file mode 100644 index 000000000..4a73e031f --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/scopes.ts @@ -0,0 +1,29 @@ +// 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. +export const PLUGIN_SCOPES: string[] = [ + "read:clients", + "create:clients", + "update:clients", + "read:client_keys", + "read:client_grants", + "create:client_grants", + "update:client_grants", + "delete:client_grants", + "read:resource_servers", + "create:resource_servers", + "read:flows", + "create:flows", + "update:flows", + "read:forms", + "create:forms", + "read:portals", + "create:portals", + "update:portals", + "read:custom_domains", + "read:users", + "read:organizations", + "create:organizations", + "read:connections", + "read:roles", + "create: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 new file mode 100644 index 000000000..74c8afc6a --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup-command.ts @@ -0,0 +1,137 @@ +import { createApiClient } from "./api.js"; +import { missingFlags, ParsedSetupArgs, UsageError } from "./args.js"; +import { checkTenantFlag, readContext } from "./context.js"; +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 { reportError } from "./errors.js"; +import { runSetup, SetupOptions, SlugRaceError } from "./setup/run.js"; +import { toPortalSlug } from "./slug.js"; +import { trackWrites } from "./write-tracker.js"; +import type { Ui } from "./ui.js"; + +export async function runSetupCommand( + args: ParsedSetupArgs, + env: NodeJS.ProcessEnv, + io: IoStreams, + ui: Ui, + interactive: boolean, + prompter: Prompter | undefined, +): Promise { + try { + const context = readContext(env); + checkTenantFlag(args.tenant, context, " Nothing was created."); + if (!interactive) { + const missing = missingFlags(args); + if (missing.length > 0) { + throw new UsageError(`Missing required flags for a non-interactive run: ${missing.join(", ")}`); + } + } + const tracked = trackWrites(createApiClient(context.proxyUrl, context.proxyToken, io.fetchImpl)); + + ui.banner("Universal Portals setup", context.tenantDomain); + try { + const options = prompter + ? await askForMissing(args, prompter, context.tenantDomain) + : optionsFromArgs(args); + const result = await runSetup(options, { + api: tracked.api, + ui, + prompt: prompter ?? unansweredPrompter(), + interactive, + tenantDomain: context.tenantDomain, + }); + if (args.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; + } + ui.ready(result.portal.url); + if (prompter && (await declinesToOpen(prompter))) return 0; + if (prompter) (io.openBrowser ?? openBrowser)(result.portal.url); + return 0; + } catch (error) { + if (error instanceof CancelledError && !args.json) return reportCancelled(ui, tracked.wrote()); + // Resources created before the failure stay, and the rerun reuses them. A slug race + // is not resumed by a rerun with the same name, so it keeps its own message. + if (!args.json && tracked.wrote() && !(error instanceof SlugRaceError)) { + const code = reportError(error, false, io, ui); + ui.detail(RESUME_HINT); + return code; + } + throw error; + } + } catch (error) { + return reportError(error, args.json, io, ui); + } +} + +// 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 { + try { + return !(await prompter.confirm("Open the portal in your browser?", true)); + } catch (error) { + if (error instanceof CancelledError) return true; + throw error; + } +} + +// Ctrl+C exits with 130. Business setup writes an organization before its later +// questions, so the message says what stays in the tenant when a write happened. +const RESUME_HINT = "Run the same command again to resume. Existing resources are reused."; + +function reportCancelled(ui: Ui, wrote: boolean): number { + if (wrote) { + ui.warn("Cancelled. Nothing was rolled back: what was already created is still in your tenant."); + ui.warn(RESUME_HINT); + } else { + ui.warn("Cancelled. Nothing was created."); + } + ui.cancelled("Setup cancelled."); + return 130; +} + +function optionsFromArgs(args: ParsedSetupArgs): SetupOptions { + const name = args.name ?? ""; + return { + template: args.template ?? "consumer", + name, + slug: args.slug ?? toPortalSlug(name), + userQuery: args.user, + organization: args.organization, + }; +} + +export async function askForMissing(args: ParsedSetupArgs, prompt: Prompter, tenantDomain: string): Promise { + const template = + args.template ?? + (await prompt.choose("Which kind of portal do you want to set up?", [ + { + label: "Consumer portal", + hint: "for end-users to manage their account in a self-service way", + value: "consumer", + }, + { + label: "Business portal", + hint: "for organization administrators to manage their organization in a self-service way", + value: "business", + }, + ])); + const name = args.name ?? (await prompt.ask("Portal name", "My Portal")); + const slug = args.slug ?? (await prompt.ask("URL slug", toPortalSlug(name))); + // The test user is asked before anything is created, unless it was passed as a flag. + return { template, name, slug, userQuery: args.user, organization: args.organization }; +} + +function unansweredPrompter(): Prompter { + const refuse = (): never => { + throw new UsageError("A question was needed but this run cannot prompt. Pass the flag instead."); + }; + return { + ask: async () => refuse(), + confirm: async () => refuse(), + choose: async () => refuse(), + checklist: async () => refuse(), + }; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/business-organization.ts b/internal/cli/data/universal-portals/_source/src/setup/business-organization.ts new file mode 100644 index 000000000..1375df6b3 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/business-organization.ts @@ -0,0 +1,91 @@ +import { ApiClient, ApiError, listAll } from "../api.js"; +import { BusinessAccessError, BusinessDeps, OrganizationRecord, userLabel, UserRecord } from "./business-types.js"; + +// Organization names are identifiers: lowercase letters, numbers, hyphens and +// underscores, and unique in the tenant. The display name is free text. +const ORG_NAME_PATTERN = /^[a-z0-9_-]{1,50}$/; + +function suggestOrgName(displayName: string): string { + return displayName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50); +} + +// Creates an organization, enables a database connection on it, and adds the +// user. Each write is confirmed first. A taken name asks again, as the portal +// slug does, with a suggestion that is free to use. +export async function createOrganization(user: UserRecord, deps: BusinessDeps): Promise { + const displayName = (await deps.prompt.ask("Organization display name", undefined, "Acme Corp")).trim(); + if (!displayName) throw new BusinessAccessError("The organization needs a display name.", []); + + let suggestion = suggestOrgName(displayName); + for (;;) { + const orgName = await askOrgName(deps, suggestion); + + const confirmed = await deps.prompt.confirm( + `Create organization "${displayName}" (${orgName}), enable a database connection on it, and add ${userLabel(user)}?`, + true, + ); + if (!confirmed) { + throw new BusinessAccessError("No organization was created.", [ + `Create one: auth0 orgs create --name ${orgName} --display "${displayName}"`, + ]); + } + + let created: OrganizationRecord; + try { + created = await deps.api.request("POST", "organizations", { + name: orgName, + display_name: displayName, + }); + } catch (error) { + // Only a taken name asks again. Later failures happen after the organization + // exists, so asking for a new name would create a second one. + if (!(error instanceof ApiError) || error.status !== 409) throw error; + deps.ui.warn(`The organization name "${orgName}" is already used in this tenant.`); + suggestion = `${orgName}-2`.slice(0, 50); + continue; + } + deps.ui.success(`Created organization "${displayName}"`); + + const connection = await chooseDatabaseConnection(deps); + await deps.api.request("POST", `organizations/${encodeURIComponent(created.id)}/enabled_connections`, { + connection_id: connection, + assign_membership_on_login: false, + }); + await deps.api.request("POST", `organizations/${encodeURIComponent(created.id)}/members`, { + members: [user.user_id], + }); + deps.ui.success(`Added ${userLabel(user)} to ${displayName}`); + return created; + } +} + +async function askOrgName(deps: BusinessDeps, suggestion: string): Promise { + for (;;) { + const answer = (await deps.prompt.ask("Organization name", suggestion)).trim(); + if (ORG_NAME_PATTERN.test(answer)) return answer; + deps.ui.error("Use lowercase letters, numbers, hyphens and underscores, up to 50 characters."); + } +} + +async function chooseDatabaseConnection(deps: BusinessDeps): Promise { + const connections = (await listAll<{ id: string; name: string; strategy: string }>(deps.api, "connections", "connections")).filter((connection) => connection.strategy === "auth0"); + if (connections.length === 0) { + throw new BusinessAccessError("This tenant has no database connection to sign in with.", [ + "Create one in the dashboard, then rerun setup.", + ]); + } + if (connections.length === 1) return connections[0].id; + return deps.prompt.choose( + "Database connection for the organization", + connections.map((connection) => ({ label: connection.name, value: connection.id })), + ); +} + +export async function requireEnabledConnection(organization: OrganizationRecord, api: ApiClient): Promise { + const connections = await listAll<{ connection_id: string }>(api, `organizations/${encodeURIComponent(organization.id)}/enabled_connections`, "enabled_connections"); + if (connections.length === 0) { + throw new BusinessAccessError(`Organization "${organization.name}" has no enabled connections, so members cannot sign in.`, [ + `Enable one: auth0 api post "organizations/${organization.id}/enabled_connections" --data '{"connection_id":"","assign_membership_on_login":false}'`, + ]); + } +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/business-role.ts b/internal/cli/data/universal-portals/_source/src/setup/business-role.ts new file mode 100644 index 000000000..8c0b6e496 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/business-role.ts @@ -0,0 +1,118 @@ +import { listAll } from "../api.js"; +import type { Choice } from "../prompt.js"; +import { BusinessDeps, OrganizationRecord, userLabel, UserRecord } from "./business-types.js"; +import { MY_ORGANIZATION_SCOPES, myOrganizationAudience } from "./payload.js"; + +// Returns whether the user's organization roles, together, grant every scope the +// organization components need. Missing scopes are a warning, not a stop: the portal +// still signs the user in, but those components will not work for them. +export async function ensureOrganizationRole( + user: UserRecord, + organization: OrganizationRecord, + deps: BusinessDeps, +): Promise { + const roles = await listAll<{ id: string; name: string }>( + deps.api, + `organizations/${encodeURIComponent(organization.id)}/members/${encodeURIComponent(user.user_id)}/roles`, + "roles", + ); + const granted = new Set(); + for (const role of roles) { + for (const scope of await permissionsOf(role.id, deps)) granted.add(scope); + } + const missing = MY_ORGANIZATION_SCOPES.filter((scope) => !granted.has(scope)); + if (missing.length === 0) return true; + + const missingReads = missing.filter((scope) => scope.startsWith("read:")); + const missingWrites = missing.filter((scope) => !scope.startsWith("read:")); + if (missingReads.length > 0) { + deps.ui.warn( + `${userLabel(user)} lacks read permissions for the organization components in "${organization.name}": ${missingReads.join(", ")}. Those components will not open for them.`, + ); + } + if (missingWrites.length > 0) { + deps.ui.warn( + `${userLabel(user)} lacks write permissions for the organization components in "${organization.name}": ${missingWrites.join(", ")}. The components open, but changes cannot be saved.`, + ); + } + if (!deps.interactive) return false; + + const candidates = await rolesGranting(missing, deps); + let role: TenantRole; + if (candidates.length === 0) { + const create = await deps.prompt.confirm( + `No role in this tenant has these permissions. Create "${PORTAL_ROLE_NAME}" and assign it to ${userLabel(user)}?`, + true, + ); + if (!create) return false; + role = await createPortalRole(deps); + } else { + const assign = await deps.prompt.confirm("Assign an organization role now?", true); + if (!assign) return false; + + const picked = await deps.prompt.choose("Role to assign", [ + ...candidates.map((candidate): Choice => ({ label: candidate.name, value: candidate })), + { label: `Create "${PORTAL_ROLE_NAME}"`, hint: "only the permissions the portal needs", value: "create" }, + ]); + role = picked === "create" ? await createPortalRole(deps) : picked; + } + await deps.api.request( + "POST", + `organizations/${encodeURIComponent(organization.id)}/members/${encodeURIComponent(user.user_id)}/roles`, + { roles: [role.id] }, + ); + deps.ui.success(`Assigned role ${role.name} to ${userLabel(user)}`); + return true; +} + +// The role the CLI creates when no tenant role has the permissions. It carries only the +// organization permissions of the portal components, nothing more. An existing role with +// the same name is reused, and only the missing permissions are added to it. +export const PORTAL_ROLE_NAME = "Universal Portals - Admin"; + +interface TenantRole { + id: string; + name: string; +} + +async function createPortalRole(deps: BusinessDeps): Promise { + const tenantRoles = await listAll(deps.api, "roles", "roles"); + const role = + tenantRoles.find((candidate) => candidate.name === PORTAL_ROLE_NAME) ?? + (await deps.api.request("POST", "roles", { + name: PORTAL_ROLE_NAME, + description: "The organization permissions the Universal Portals components need.", + })); + + const granted = await permissionsOf(role.id, deps); + const missing = MY_ORGANIZATION_SCOPES.filter((scope) => !granted.includes(scope)); + if (missing.length > 0) { + await deps.api.request("POST", `roles/${encodeURIComponent(role.id)}/permissions`, { + permissions: missing.map((permission_name) => ({ + permission_name, + resource_server_identifier: myOrganizationAudience(deps.tenantDomain), + })), + }); + } + return role; +} + +async function permissionsOf(roleId: string, deps: BusinessDeps): Promise { + const permissions = await listAll<{ permission_name: string }>( + deps.api, + `roles/${encodeURIComponent(roleId)}/permissions`, + "permissions", + ); + return permissions.map((permission) => permission.permission_name); +} + +// The tenant roles that grant every one of the scopes, so an assignment fixes the warning. +async function rolesGranting(scopes: string[], deps: BusinessDeps): Promise<{ id: string; name: string }[]> { + const tenantRoles = await listAll<{ id: string; name: string }>(deps.api, "roles", "roles"); + const matches: { id: string; name: string }[] = []; + for (const role of tenantRoles) { + const granted = await permissionsOf(role.id, deps); + if (scopes.every((scope) => granted.includes(scope))) matches.push(role); + } + return matches; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/business-types.ts b/internal/cli/data/universal-portals/_source/src/setup/business-types.ts new file mode 100644 index 000000000..92c699adf --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/business-types.ts @@ -0,0 +1,53 @@ +import type { ApiClient } from "../api.js"; +import type { Prompter } from "../prompt.js"; +import { PluginError } from "../plugin-error.js"; +import type { Ui } from "../ui.js"; + +// The types and labels the Business setup steps share, so each step module can +// import them without importing the others. + +export class BusinessAccessError extends PluginError { + // Always set here, so the base class's optional type is narrowed. + declare readonly fixes: string[]; + + constructor(message: string, fixes: string[]) { + super(message, "business_access", fixes); + } +} + +export interface BusinessAccess { + userId: string; + organizationId: string; + organizationName: string; + hasOrganizationRole: boolean; +} + +export interface UserRecord { + user_id: string; + email?: string; + name?: string; +} + +export interface OrganizationRecord { + id: string; + name: string; + display_name?: string; +} + +export interface BusinessInput { + // Email or user ID. Undefined when the user has to be asked for. + userQuery?: string; + organization?: string; +} + +export interface BusinessDeps { + api: ApiClient; + ui: Ui; + prompt: Prompter; + interactive: boolean; + tenantDomain: string; +} + +export function userLabel(user: UserRecord): string { + return user.email ?? user.user_id; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/business.ts b/internal/cli/data/universal-portals/_source/src/setup/business.ts new file mode 100644 index 000000000..6fc78a06a --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/business.ts @@ -0,0 +1,214 @@ +import { ApiClient, ApiError, listAll } from "../api.js"; +import type { Choice } from "../prompt.js"; +import { createOrganization, requireEnabledConnection } from "./business-organization.js"; +import { ensureOrganizationRole } from "./business-role.js"; +import { + BusinessAccess, + BusinessAccessError, + BusinessDeps, + BusinessInput, + OrganizationRecord, + userLabel, + UserRecord, +} from "./business-types.js"; + +// A Business portal is opened for an organization, so the user who will test +// it has to belong to that organization, with a connection to sign in with and +// a role. Setup asks for the user before it creates the portal, and checks each +// step before it changes anything. + +export async function resolveBusinessAccess(input: BusinessInput, deps: BusinessDeps): Promise { + const user = await chooseUser(input.userQuery, deps); + deps.ui.success(`"${userLabel(user)}" found`); + + const organization = await chooseOrganization(user, input.organization, deps); + deps.ui.success(`Organization "${organization.name}" selected`); + + await requireEnabledConnection(organization, deps.api); + const hasOrganizationRole = await ensureOrganizationRole(user, organization, deps); + + return { + userId: user.user_id, + organizationId: organization.id, + organizationName: organization.name, + hasOrganizationRole, + }; +} + +function describeUser(user: UserRecord): string { + return `${user.email ?? "no email"} · ${user.user_id}${user.name ? ` · ${user.name}` : ""}`; +} + +// Looks a user up by email, or by ID when the query has no "@". An unknown ID +// is an empty result, not an error, so the caller can ask again. +async function lookupUsers(query: string, api: ApiClient): Promise { + if (query.includes("@")) { + // The search matches similar addresses too, so only the exact email is kept. The + // search reads users, so it needs read:users, the same scope as the lookup by ID. + const phrase = `email:"${query.replace(/["\\]/g, "\\$&")}"`; + const found = await listAll(api, `users?q=${encodeURIComponent(phrase)}&search_engine=v3`, "users"); + return found.filter((user) => user.email?.toLowerCase() === query.toLowerCase()); + } + try { + return [await api.request("GET", `users/${encodeURIComponent(query)}`)]; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return []; + throw error; + } +} + +async function chooseUser(initialQuery: string | undefined, deps: BusinessDeps): Promise { + let query = initialQuery; + for (;;) { + if (!query) { + if (!deps.interactive) { + throw new BusinessAccessError("A Business portal needs the user who will test it.", [ + "Rerun with --user .", + ]); + } + query = (await deps.prompt.ask("Enter the test user's email or user ID", undefined, "jane@acme.com")).trim(); + if (!query) continue; + } + + const matches = await lookupUsers(query, deps.api); + if (matches.length === 1) return matches[0]; + + if (!deps.interactive) { + if (matches.length === 0) { + throw new BusinessAccessError(`No user matches "${query}".`, ["Check the email or user ID, then rerun with --user."]); + } + throw new BusinessAccessError(`More than one user has the email "${query}".`, [ + "Rerun with the user ID instead: " + matches.map((user) => user.user_id).join(", "), + ]); + } + + if (matches.length === 0) { + deps.ui.warn(`No user matches "${query}".`); + query = undefined; + continue; + } + + const choice = await deps.prompt.choose(`${matches.length} users match "${query}"`, [ + ...matches.map((user): Choice => ({ label: describeUser(user), value: user })), + { label: "Try another email or user ID", value: "retry" }, + ]); + if (choice === "retry") { + query = undefined; + continue; + } + return choice; + } +} + +async function chooseOrganization( + user: UserRecord, + requested: string | undefined, + deps: BusinessDeps, +): Promise { + const memberships = await listAll(deps.api, `users/${encodeURIComponent(user.user_id)}/organizations`, "organizations"); + + if (requested) { + const match = memberships.find((org) => org.id === requested || org.name === requested); + if (match) return match; + throw new BusinessAccessError(`${userLabel(user)} is not a member of "${requested}".`, [ + `Add them: auth0 api post "organizations/${requested}/members" --data '{"members":["${user.user_id}"]}'`, + ]); + } + + if (!deps.interactive) { + if (memberships.length === 1) return memberships[0]; + if (memberships.length > 1) { + throw new BusinessAccessError(`${userLabel(user)} belongs to ${memberships.length} organizations.`, [ + "Rerun with --organization : " + memberships.map((org) => org.id).join(", "), + ]); + } + throw new BusinessAccessError(`${userLabel(user)} is not a member of any organization.`, [ + `Add them: auth0 api post "organizations//members" --data '{"members":["${user.user_id}"]}'`, + ]); + } + + if (memberships.length === 1) return memberships[0]; + + // Members pick one of their organizations. Users with none can join an + // existing organization of the tenant. Either way a new one can be created. + const candidates = memberships.length > 0 ? memberships : await listTenantOrganizations(deps.api); + if (memberships.length === 0) deps.ui.warn(`${userLabel(user)} is not a member of any organization.`); + + deps.ui.info("The portal opens in one organization, and the test user has to be a member of it."); + const choice = await deps.prompt.choose("Choose the organization to test the portal in", [ + ...candidates.map((org): Choice => ({ + label: `${org.name} (${org.id})`, + value: org, + })), + { label: "None of these, create a new organization", value: "create" }, + ]); + + if (choice === "create") return createOrganization(user, deps); + + if (memberships.length > 0) return choice; + return addToOrganization(user, choice, deps); +} + +async function listTenantOrganizations(api: ApiClient): Promise { + return await listAll(api, "organizations", "organizations"); +} + +async function addToOrganization( + user: UserRecord, + organization: OrganizationRecord, + deps: BusinessDeps, +): Promise { + const confirmed = await deps.prompt.confirm(`Add ${userLabel(user)} as a member of ${organization.name}?`, true); + if (!confirmed) { + throw new BusinessAccessError(`${userLabel(user)} is not a member of an organization, and setup did not add them.`, [ + `Add them: auth0 api post "organizations/${organization.id}/members" --data '{"members":["${user.user_id}"]}'`, + ]); + } + await deps.api.request("POST", `organizations/${encodeURIComponent(organization.id)}/members`, { + members: [user.user_id], + }); + deps.ui.success(`Added ${userLabel(user)} to ${organization.name}`); + return organization; +} + +const BUSINESS_REQUIREMENTS = + "Business portals only work in the context of an organization: without it, the organization components do not load. Pick the user who will test the portal, so the link opens in their organization."; +const SKIPPED_TEST_USER_REQUIREMENTS = + "Without a test user, the link needs an organization. The portal only signs members in when the URL carries ?organization=."; +// The placeholder the link keeps until the user replaces it with an organization ID. +export const ORGANIZATION_PLACEHOLDER = ""; + +// The link the setup prints: the organization it opens in, and the test user's access +// when one was chosen. +export interface TestLink { + organizationId: string; + // True while the organization is still the placeholder the user has to replace. + isPlaceholder: boolean; + access?: BusinessAccess; +} + +// Settles the test user before anything is created, so a user who cannot sign in +// stops the run with nothing to clean up. Skipping it asks for the organization ID +// for the link instead, with a placeholder when none is given. +export async function chooseTestLink(input: BusinessInput, deps: BusinessDeps): Promise { + const { ui, prompt, interactive } = deps; + ui.section("Test user"); + + let userQuery = input.userQuery; + if (userQuery === undefined && interactive) { + ui.info(BUSINESS_REQUIREMENTS); + userQuery = (await prompt.ask("Enter the test user's email or user ID (press Enter to skip)", undefined, "jane@acme.com")).trim() || undefined; + } + + if (userQuery) { + const access = await resolveBusinessAccess({ userQuery, organization: input.organization }, deps); + return { organizationId: access.organizationId, isPlaceholder: false, access }; + } + + if (interactive) ui.info(SKIPPED_TEST_USER_REQUIREMENTS); + const organizationId = await prompt.ask( + "Enter the organization ID to test in (the link adds ?organization=)", + ORGANIZATION_PLACEHOLDER, + ); + return { organizationId, isPlaceholder: organizationId === ORGANIZATION_PLACEHOLDER }; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/host.ts b/internal/cli/data/universal-portals/_source/src/setup/host.ts new file mode 100644 index 000000000..21cca4899 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/host.ts @@ -0,0 +1,20 @@ +import type { ApiClient } from "../api.js"; +import { readCustomDomain } from "../audit/snapshot.js"; +import { ORGANIZATION_PLACEHOLDER } from "./business.js"; + +export async function resolvePortalHost( + api: ApiClient, + tenantDomain: string, +): Promise<{ domain: string; isCustom: boolean }> { + const custom = await readCustomDomain(api); + if (custom?.status === "ready" && custom.domain) return { domain: custom.domain, isCustom: true }; + return { domain: tenantDomain, isCustom: false }; +} + +export function portalUrl(domain: string, slug: string, organizationId?: string): string { + const base = `https://${domain}/portals/${slug}`; + if (!organizationId) return base; + // A placeholder stays readable so it is easy to find and replace. + const value = organizationId === ORGANIZATION_PLACEHOLDER ? organizationId : encodeURIComponent(organizationId); + return `${base}?organization=${value}`; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/payload.ts b/internal/cli/data/universal-portals/_source/src/setup/payload.ts new file mode 100644 index 000000000..5007151fc --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/payload.ts @@ -0,0 +1,164 @@ +import type { CreatePortalRequest } from "../portal-model.js"; +import type { PortalTemplate, TemplateId } from "../templates/types.js"; +import { businessTemplate } from "../templates/business.js"; +import { consumerTemplate } from "../templates/consumer.js"; +import type { FormIds } from "../templates/types.js"; + +// Scopes the portal application needs on each resource server, as listed in +// the current Auth0 Portals setup guide. Trim them per component when a +// template uses fewer components. +export const MY_ACCOUNT_SCOPES = [ + "read:me:factors", + "read:me:authentication_methods", + "create:me:authentication_methods", + "delete:me:authentication_methods", +]; + +// Scopes the Business template's organization components use, from the portal +// component reference. Organization details: read and update. Verified domains: +// domains and identity provider domains, read, create, update and delete. +export const MY_ORGANIZATION_SCOPES = [ + "read:my_org:details", + "update:my_org:details", + "read:my_org:configuration", + "read:my_org:domains", + "create:my_org:domains", + "update:my_org:domains", + "delete:my_org:domains", + "read:my_org:identity_providers", + "create:my_org:identity_providers_domains", + "delete:my_org:identity_providers_domains", +]; + +// Scopes for the machine-to-machine app that backs the forms' vault connection. +export const FORMS_VAULT_SCOPES = ["update:users", "update:users_app_metadata"]; + +// Suffix of the portal application's name, after the portal's name. +export const PORTAL_APP_SUFFIX = "(Universal Portals)"; + +export const PORTAL_GRANT_TYPES = [ + "authorization_code", + "refresh_token", + "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", +]; + +export const REFRESH_TOKEN_LIFETIMES = { + idleSeconds: 3600, + absoluteSeconds: 86400, +}; + +export function myAccountAudience(tenantDomain: string): string { + return `https://${tenantDomain}/me/`; +} + +export function myOrganizationAudience(tenantDomain: string): string { + return `https://${tenantDomain}/my-org/`; +} + +export function managementAudience(tenantDomain: string): string { + return `https://${tenantDomain}/api/v2/`; +} + +// The audiences a template calls, with the scopes each needs. Setup creates a +// client grant and a refresh-token policy for each. +export function audiencesFor(template: TemplateId, tenantDomain: string): { audience: string; scope: string[] }[] { + const account = { audience: myAccountAudience(tenantDomain), scope: MY_ACCOUNT_SCOPES }; + if (template === "consumer") return [account]; + return [{ audience: myOrganizationAudience(tenantDomain), scope: MY_ORGANIZATION_SCOPES }, account]; +} + +export interface PortalClientInput { + name: string; + slug: string; + template: TemplateId; + tenantDomain: string; + // Host the portal is served from (the custom domain, or the tenant domain). + portalHost: string; + isCustomDomain: boolean; +} + +export function buildPortalClient(input: PortalClientInput): Record { + const client: Record = { + name: input.name, + app_type: "regular_web", + is_first_party: true, + oidc_conformant: true, + token_endpoint_auth_method: "client_secret_post", + is_token_endpoint_ip_header_trusted: true, + callbacks: [`https://${input.portalHost}/portals/auth/callback`], + // Users land on the portal's own host after logout. + allowed_logout_urls: [`https://${input.portalHost}`], + grant_types: PORTAL_GRANT_TYPES, + organization_usage: "allow", + organization_require_behavior: "no_prompt", + refresh_token: refreshTokenSettings(input.template, input.tenantDomain), + client_metadata: { portals_setup_slug: input.slug }, + // Lets a native app open the portal with a session transfer token. + session_transfer: { allowed_authentication_methods: ["query"], enforce_device_binding: "ip" }, + }; + if (input.isCustomDomain) { + client.oidc_logout = backChannelLogout(input.portalHost); + } + return client; +} + +export function refreshTokenSettings(template: TemplateId, tenantDomain: string): Record { + return { + rotation_type: "non-rotating", + expiration_type: "expiring", + token_lifetime: REFRESH_TOKEN_LIFETIMES.absoluteSeconds, + idle_token_lifetime: REFRESH_TOKEN_LIFETIMES.idleSeconds, + policies: audiencesFor(template, tenantDomain).map((entry) => ({ + audience: entry.audience, + scope: entry.scope, + })), + }; +} + +function backChannelLogout(portalHost: string): Record { + return { + backchannel_logout_urls: [`https://${portalHost}/portals/auth/backchannel-logout`], + backchannel_logout_initiators: { + mode: "custom", + selected_initiators: [ + "idp-logout", + "rp-logout", + "session-expired", + "session-revoked", + "account-deleted", + "account-deactivated", + ], + }, + }; +} + +export function buildFormsVaultClient(name: string): Record { + return { + name, + app_type: "non_interactive", + client_metadata: { auth0_portals_role: "forms-vault" }, + }; +} + +export function buildPortalRequest( + template: PortalTemplate, + input: { slug: string; name: string; clientId: string; clientSecret: string }, +): CreatePortalRequest { + return { + slug: input.slug, + name: input.name, + client: { + token_endpoint_auth_method: "client_secret_post", + client_id: input.clientId, + client_secret: input.clientSecret, + }, + navigation: { sidebar: { components: template.sidebar } }, + pages: { default: template.defaultPage, standard: template.pages }, + }; +} + +export function templateFor(id: TemplateId, forms: FormIds): PortalTemplate { + return id === "consumer" ? consumerTemplate(forms) : businessTemplate(forms); +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/preflight.ts b/internal/cli/data/universal-portals/_source/src/setup/preflight.ts new file mode 100644 index 000000000..13d9a558d --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/preflight.ts @@ -0,0 +1,107 @@ +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. +// 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 { + name: string; + method: HttpMethod; + path: string; + body?: unknown; + // Only used by the business template. + businessOnly?: boolean; +} + +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: {} }, + { 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 }, +]; + +export interface ScopeGap { + probe: string; + requirement: "any" | "all"; + scopes: string[]; +} + +export interface PreflightResult { + gaps: ScopeGap[]; + // Probes that failed for a reason other than a missing scope. + unexpected: { probe: string; status: number; message: string }[]; +} + +export class FeatureNotEnabledError extends PluginError { + constructor(readonly tenantDomain: string) { + super( + `Universal Portals is not enabled for ${tenantDomain}. Ask Auth0 to enable it for this tenant, then run the command again.`, + "feature_not_enabled", + ); + } +} + +export async function runPreflight( + api: ApiClient, + probes: Probe[], + tenantDomain: string, +): Promise { + const gaps: ScopeGap[] = []; + const unexpected: PreflightResult["unexpected"] = []; + + for (const probe of probes) { + try { + await api.request(probe.method, probe.path, probe.body); + } catch (error) { + 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 }; +} + +// Management API message: "Insufficient scope, expected any of: a,b" or +// "Insufficient scope, expected all of: a,b". +export function parseScopeGap(probe: string, error: ApiError): ScopeGap | undefined { + if (error.status !== 403) return undefined; + const match = /expected (any|all) of:\s*(.+)$/.exec(error.message); + if (!match) return undefined; + const requirement = match[1] === "all" ? "all" : "any"; + const scopes = match[2].split(",").map((scope) => scope.trim()).filter(Boolean); + return { probe, requirement, scopes }; +} + +// Scopes the session must have. An "any of" gap with several options is not +// listed: any one of them would do, so the caller reports it as a choice. +export function requiredMissingScopes(result: PreflightResult): string[] { + const required = result.gaps.filter((gap) => gap.requirement === "all" || gap.scopes.length === 1); + return [...new Set(required.flatMap((gap) => gap.scopes))].sort(); +} + +export function choiceGaps(result: PreflightResult): ScopeGap[] { + return result.gaps.filter((gap) => gap.requirement === "any" && gap.scopes.length > 1); +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/resources.ts b/internal/cli/data/universal-portals/_source/src/setup/resources.ts new file mode 100644 index 000000000..00aae5f59 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/resources.ts @@ -0,0 +1,305 @@ +import { ApiClient, ApiError, listAll } from "../api.js"; +import type { CreatePortalRequest } from "../portal-model.js"; +import type { FormDefinition, FormKey } from "../forms.js"; +import { buildPortalClient, buildFormsVaultClient, PortalClientInput } from "./payload.js"; + +// Every ensure* helper looks for its resource by a stable key before it +// creates one, so rerunning setup after a failure reuses what already exists. + +export type Status = "created" | "reused" | "updated"; + +export function describe(status: Status): string { + return status === "reused" ? "already exists" : status === "updated" ? "updated" : "created"; +} + +export interface Ensured { + value: T; + status: Status; +} + +export class NameInUseError extends Error { + constructor(readonly name: string) { + super( + `An application named "${name}" already exists and was not created by this setup. Rename or delete it, then rerun.`, + ); + this.name = "NameInUseError"; + } +} + +export class SlugTakenError extends Error { + constructor(readonly slug: string) { + super(`The portal slug "${slug}" is already used by another portal.`); + this.name = "SlugTakenError"; + } +} + +interface ClientRecord { + client_id: string; + name?: string; + client_metadata?: Record; + session_transfer?: { allowed_authentication_methods?: string[] }; +} + +interface ResourceServerRecord { + id: string; + identifier: string; +} + +interface GrantRecord { + id: string; + audience: string; + scope?: string[]; +} + +interface VaultRecord { + id: string; + name: string; +} + +export interface PortalRecord { + id: string; + slug: string; + client?: { client_id: string }; +} + +export async function ensureResourceServer( + api: ApiClient, + input: { name: string; identifier: string }, +): Promise> { + const existing = await findResourceServer(api, input.identifier); + if (existing) return { value: { id: existing.id }, status: "reused" }; + + try { + const created = await api.request("POST", "resource-servers", { + name: input.name, + identifier: input.identifier, + skip_consent_for_verifiable_first_party_clients: true, + token_dialect: "rfc9068_profile", + }); + return { value: { id: created.id }, status: "created" }; + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const raced = await findResourceServer(api, input.identifier); + if (raced) return { value: { id: raced.id }, status: "reused" }; + } + throw error; + } +} + +async function findResourceServer(api: ApiClient, identifier: string): Promise { + return (await listAll(api, "resource-servers", "resource_servers")).find((server) => server.identifier === identifier); +} + +export async function ensurePortalClient( + api: ApiClient, + input: PortalClientInput, +): Promise> { + const existing = await findClientByName(api, input.name); + const payload = buildPortalClient(input); + + if (existing) { + if (existing.client_metadata?.portals_setup_slug !== input.slug) { + throw new NameInUseError(input.name); + } + await api.request("PATCH", `clients/${existing.client_id}`, { + callbacks: payload.callbacks, + allowed_logout_urls: payload.allowed_logout_urls, + grant_types: payload.grant_types, + organization_usage: payload.organization_usage, + organization_require_behavior: payload.organization_require_behavior, + is_token_endpoint_ip_header_trusted: payload.is_token_endpoint_ip_header_trusted, + refresh_token: payload.refresh_token, + ...(payload.oidc_logout ? { oidc_logout: payload.oidc_logout } : {}), + // Set only when missing, so a session transfer setting changed on purpose survives a rerun. + ...(existing.session_transfer?.allowed_authentication_methods?.length + ? {} + : { session_transfer: payload.session_transfer }), + }); + const clientSecret = await readClientSecret(api, existing.client_id); + return { value: { clientId: existing.client_id, clientSecret }, status: "reused" }; + } + + const created = await api.request<{ client_id: string; client_secret: string }>("POST", "clients", payload); + return { value: { clientId: created.client_id, clientSecret: created.client_secret }, status: "created" }; +} + +export async function findClientByName(api: ApiClient, name: string): Promise { + const clients = await listAll( + api, + "clients?fields=client_id,name,client_metadata,session_transfer&include_fields=true", + "clients", + ); + return clients.find((client) => client.name === name); +} + +// The client secret is only returned by GET /clients/{id} with read:client_keys. +export async function readClientSecret(api: ApiClient, clientId: string): Promise { + const client = await api.request<{ client_secret?: string }>( + "GET", + `clients/${encodeURIComponent(clientId)}?fields=client_id,client_secret&include_fields=true`, + ); + if (!client.client_secret) { + throw new Error(`Could not read the client secret for ${clientId}. The session needs read:client_keys.`); + } + return client.client_secret; +} + +export async function ensureClientGrant( + api: ApiClient, + input: { clientId: string; audience: string; scope: string[]; subjectType?: "user" | "client" }, +): Promise> { + const existing = (await listAll(api, `client-grants?client_id=${encodeURIComponent(input.clientId)}`, "client_grants")).find((grant) => grant.audience === input.audience); + + if (!existing) { + const created = await api.request("POST", "client-grants", { + client_id: input.clientId, + audience: input.audience, + scope: input.scope, + ...(input.subjectType ? { subject_type: input.subjectType } : {}), + }); + return { value: { id: created.id }, status: "created" }; + } + + if (sameScopes(existing.scope ?? [], input.scope)) { + return { value: { id: existing.id }, status: "reused" }; + } + await api.request("PATCH", `client-grants/${existing.id}`, { scope: input.scope }); + return { value: { id: existing.id }, status: "updated" }; +} + +function sameScopes(left: string[], right: string[]): boolean { + const a = [...new Set(left)].sort(); + const b = [...new Set(right)].sort(); + return a.length === b.length && a.every((scope, index) => scope === b[index]); +} + +export async function ensureFormsVaultClient( + api: ApiClient, + name: string, +): Promise> { + const existing = await findClientByName(api, name); + if (existing) { + if (existing.client_metadata?.auth0_portals_role !== "forms-vault") { + throw new NameInUseError(name); + } + const clientSecret = await readClientSecret(api, existing.client_id); + return { value: { clientId: existing.client_id, clientSecret }, status: "reused" }; + } + const created = await api.request<{ client_id: string; client_secret: string }>( + "POST", + "clients", + buildFormsVaultClient(name), + ); + return { value: { clientId: created.client_id, clientSecret: created.client_secret }, status: "created" }; +} + +export interface VaultSetup { + domain: string; + clientId: string; + clientSecret: string; +} + +// The vault API uses snake_case keys, unlike the rest of this module. +function vaultSetupPayload(setup: VaultSetup): Record { + return { + domain: setup.domain, + client_id: setup.clientId, + client_secret: setup.clientSecret, + type: "OAUTH_APP", + }; +} + +export async function ensureVaultConnection( + api: ApiClient, + name: string, + setup: VaultSetup, +): Promise> { + const existing = (await listAll(api, "flows/vault/connections", "connections")).find((connection) => connection.name === name); + if (existing) return { value: { id: existing.id }, status: "reused" }; + + const created = await api.request("POST", "flows/vault/connections", { + app_id: "AUTH0", + name, + setup: vaultSetupPayload(setup), + }); + return { value: { id: created.id }, status: "created" }; +} + +// A PATCH without setup keeps the old session. Sending setup again makes the +// vault mint a new session with the grants in force at that moment. +export async function refreshVaultConnection(api: ApiClient, vaultId: string, setup: VaultSetup): Promise { + await api.request("PATCH", `flows/vault/connections/${encodeURIComponent(vaultId)}`, { + setup: vaultSetupPayload(setup), + }); +} + +export interface FormRef { + id: string; + flowId?: string; +} + +// The import response lists the flows it created, each with its ID. +function flowIdFromImport(bundle: { flows?: { id?: string }[] | Record }): string | undefined { + return Object.values(bundle.flows ?? {}).find((flow) => flow.id)?.id; +} + +// A form's FLOW nodes reference the flow they run by its ID. +function flowIdFromNodes(form: { nodes?: { type?: string; config?: { flow_id?: string } }[] }): string | undefined { + return form.nodes?.find((node) => node.type === "FLOW" && node.config?.flow_id)?.config?.flow_id; +} + +export async function ensureForms( + api: ApiClient, + forms: FormDefinition[], +): Promise>> { + const existing = await listAll<{ id: string; name: string }>(api, "forms", "forms"); + const result = {} as Record>; + + for (const form of forms) { + const match = existing.find((candidate) => candidate.name === form.name); + if (match) { + const detail = await api.request<{ nodes?: { type?: string; config?: { flow_id?: string } }[] }>( + "GET", + `forms/${encodeURIComponent(match.id)}`, + ); + result[form.key] = { value: { id: match.id, flowId: flowIdFromNodes(detail) }, status: "reused" }; + continue; + } + const imported = await api.request<{ + form: { id: string; nodes?: { type?: string; config?: { flow_id?: string } }[] }; + flows?: { id?: string }[]; + }>("POST", "forms/import", form.document); + const flowId = flowIdFromImport(imported) ?? flowIdFromNodes(imported.form); + result[form.key] = { value: { id: imported.form.id, flowId }, status: "created" }; + } + return result; +} + +// The portal with this slug, read in full (with its application), or undefined when the slug is free. +export async function findPortalBySlug(api: ApiClient, slug: string): Promise { + const listed = (await listAll(api, "portals", "portals")).find((portal) => portal.slug === slug); + if (!listed) return undefined; + return api.request("GET", `portals/${encodeURIComponent(listed.id)}`); +} + +export async function ensurePortal( + api: ApiClient, + request: CreatePortalRequest, +): Promise> { + const existing = await findPortalBySlug(api, request.slug); + + if (existing) { + if (existing.client?.client_id !== request.client.client_id) { + throw new SlugTakenError(request.slug); + } + return { value: { id: existing.id, slug: existing.slug }, status: "reused" }; + } + + try { + const created = await api.request("POST", "portals", request); + return { value: { id: created.id, slug: created.slug }, status: "created" }; + } catch (error) { + if (error instanceof ApiError && error.status === 409) throw new SlugTakenError(request.slug); + throw error; + } +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/run.ts b/internal/cli/data/universal-portals/_source/src/setup/run.ts new file mode 100644 index 000000000..e086b87a3 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/run.ts @@ -0,0 +1,273 @@ +import { ApiClient } from "../api.js"; +import { PluginError } from "../plugin-error.js"; +import { applicationUrl, flowEditUrl, formEditUrl, portalEditUrl } from "../dashboard.js"; +import { buildPortalForms, FORM_NAMES, FormKey } from "../forms.js"; +import { isValidPortalSlug } from "../portal-model.js"; +import { choiceGaps, Probe, requiredMissingScopes, runPreflight, ScopeGap, SETUP_PROBES } from "./preflight.js"; +import { portalUrl, resolvePortalHost } from "./host.js"; +import { ensureFormsVault } from "./vault.js"; +import type { Prompter } from "../prompt.js"; +import type { TemplateId } from "../templates/types.js"; +import type { Ui } from "../ui.js"; +import { chooseTestLink, ORGANIZATION_PLACEHOLDER } from "./business.js"; +import { BusinessAccessError } from "./business-types.js"; +import { + audiencesFor, + buildPortalRequest, + myAccountAudience, + myOrganizationAudience, + PORTAL_APP_SUFFIX, + templateFor, +} from "./payload.js"; +import { + ensureClientGrant, + ensureForms, + FormRef, + ensurePortal, + ensurePortalClient, + ensureResourceServer, + describe, + Ensured, + findClientByName, + findPortalBySlug, + SlugTakenError, + Status, +} from "./resources.js"; + + +// Another portal took the slug while setup was running. Setup checks the slug before it +// creates anything, so this is a race, and the rerun needs a new name and slug. +export class SlugRaceError extends PluginError { + constructor(readonly slug: string) { + super( + `The slug "${slug}" was taken while setup was running. Rerun with a different --name and --slug.`, + "slug_race", + ); + } +} + +export class MissingScopesError extends PluginError { + constructor( + readonly scopes: string[], + readonly choices: ScopeGap[], + ) { + super( + scopes.length > 0 + ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` + : "The CLI session is missing a scope it needs.", + "missing_scopes", + scopes.length > 0 ? [`auth0 login --scopes ${scopes.join(",")}`] : undefined, + ); + } +} + +export interface SetupOptions { + template: TemplateId; + name: string; + slug: string; + userQuery?: string; + organization?: string; +} + +export interface SetupDeps { + api: ApiClient; + ui: Ui; + prompt: Prompter; + interactive: boolean; + tenantDomain: string; +} + +export interface SetupResult { + template: TemplateId; + portal: { id: string; slug: string; url: string; status: Status }; + application: { clientId: string; status: Status }; + vault: { id: string; status: Status }; + forms: Record; + resources: { type: string; id: string; status: Status }[]; + organization?: { id: string; name: string; hasRole: boolean }; +} + +export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise { + const resources: SetupResult["resources"] = []; + const { ui, api, tenantDomain } = deps; + + const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); + await checkPermissions(probes, deps); + + let slug = options.slug; + if (!isValidPortalSlug(slug)) throw new Error(`The slug "${slug}" must match ^[a-z][a-z0-9-]{0,99}$.`); + + // A non-interactive run has nobody to ask, so it needs the user up front. + if (options.template === "business" && !options.userQuery && !deps.interactive) { + throw new BusinessAccessError("A Business portal needs the user who will test it.", [ + "Rerun with --user .", + ]); + } + + const appName = `${options.name} ${PORTAL_APP_SUFFIX}`; + slug = await availableSlug(api, slug, appName, deps); + // The test user (or the organization for the link) is settled before anything is created. + const testLink = options.template === "business" ? await chooseTestLink(options, deps) : undefined; + + const host = await resolvePortalHost(api, tenantDomain); + + ui.section("Resource servers"); + for (const server of resourceServersFor(options.template, tenantDomain)) { + const outcome = await ui.task(`Checking ${server.name}`, () => ensureResourceServer(api, server)); + resources.push({ type: "resource_server", id: outcome.value.id, status: outcome.status }); + ui.success(`"${server.name}"`, describe(outcome.status)); + } + ui.result("Resource servers ready"); + + ui.section("Application"); + const client = await ui.task("Creating application", () => + ensurePortalClient(api, { + name: appName, + slug, + template: options.template, + tenantDomain, + portalHost: host.domain, + isCustomDomain: host.isCustom, + }), + ); + resources.push({ type: "application", id: client.value.clientId, status: client.status }); + ui.success(`"${appName}"`, describe(client.status)); + ui.reference("Application", client.value.clientId, applicationUrl(tenantDomain, client.value.clientId)); + + for (const audience of audiencesFor(options.template, tenantDomain)) { + const grant = await ui.task(`Granting ${apiLabel(audience.audience, tenantDomain)}`, () => + ensureClientGrant(api, { + clientId: client.value.clientId, + audience: audience.audience, + scope: audience.scope, + subjectType: "user", + }), + ); + resources.push({ type: "client_grant", id: grant.value.id, status: grant.status }); + ui.success(`"${apiLabel(audience.audience, tenantDomain)}" access`, describe(grant.status)); + } + ui.result("Application ready"); + + ui.section("Forms"); + const vault = await ensureFormsVault(api, deps.ui, tenantDomain, resources); + const forms = await ui.task("Creating forms", () => ensureForms(api, buildPortalForms(vault.value.id))); + for (const [key, outcome] of Object.entries(forms) as [FormKey, Ensured][]) { + resources.push({ type: "form", id: outcome.value.id, status: outcome.status }); + ui.success(`"${FORM_NAMES[key]}"`, describe(outcome.status)); + ui.reference("Form", outcome.value.id, formEditUrl(tenantDomain, outcome.value.id)); + if (outcome.value.flowId) ui.reference("Flow", outcome.value.flowId, flowEditUrl(tenantDomain, outcome.value.flowId)); + } + ui.result("Forms ready"); + + const template = templateFor(options.template, { + personalInfo: forms.personalInfo.value.id, + privacyConsent: forms.privacyConsent.value.id, + communicationPreferences: forms.communicationPreferences.value.id, + }); + + ui.section("Portal"); + let portal: Ensured<{ id: string; slug: string }>; + try { + portal = await ui.task("Creating portal", () => + ensurePortal( + api, + buildPortalRequest(template, { + slug, + name: options.name, + clientId: client.value.clientId, + clientSecret: client.value.clientSecret, + }), + ), + ); + } catch (error) { + if (!(error instanceof SlugTakenError)) throw error; + // No prompt can run inside the task, so the run stops. The resources created so far + // stay, and a new name gives a new application, so the rerun needs both a new name and slug. + throw new SlugRaceError(slug); + } + resources.push({ type: "portal", id: portal.value.id, status: portal.status }); + ui.success(`"${options.name}"`, describe(portal.status)); + ui.result("Portal ready"); + ui.reference("Portal", portal.value.id, portalEditUrl(tenantDomain, portal.value.id)); + + const url = portalUrl(host.domain, portal.value.slug, testLink?.organizationId); + if (testLink?.isPlaceholder) { + ui.info(`Replace ${ORGANIZATION_PLACEHOLDER} in the link with the organization ID to test with.`); + } + + return { + template: options.template, + portal: { id: portal.value.id, slug: portal.value.slug, url, status: portal.status }, + application: { clientId: client.value.clientId, status: client.status }, + vault: { id: vault.value.id, status: vault.status }, + forms: Object.fromEntries( + Object.entries(forms).map(([key, outcome]) => [key, { id: outcome.value.id, status: outcome.status }]), + ) as SetupResult["forms"], + resources, + organization: testLink?.access + ? { + id: testLink.access.organizationId, + name: testLink.access.organizationName, + hasRole: testLink.access.hasOrganizationRole, + } + : undefined, + }; +} + +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 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; + } + 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(", ")}`); + if (required.length > 0) { + deps.ui.detail(`Run: auth0 login --scopes ${required.join(",")}`); + } + throw new MissingScopesError(required, choices); +} + +function resourceServersFor(template: TemplateId, tenantDomain: string): { name: string; identifier: string }[] { + const account = { name: "Auth0 My Account API", identifier: myAccountAudience(tenantDomain) }; + if (template === "consumer") return [account]; + return [{ name: "Auth0 My Organization API", identifier: myOrganizationAudience(tenantDomain) }, account]; +} + +function apiLabel(audience: string, tenantDomain: string): string { + if (audience === myAccountAudience(tenantDomain)) return "My Account API"; + if (audience === myOrganizationAudience(tenantDomain)) return "My Organization API"; + return "Management API"; +} + +// Runs before anything is created: a slug used by another portal is fixed here, not after the +// application and forms exist. A portal already using this setup's application is a rerun, so it passes. +async function availableSlug(api: ApiClient, slug: string, appName: string, deps: SetupDeps): Promise { + let current = slug; + for (;;) { + const portal = await findPortalBySlug(api, current); + if (!portal) return current; + const app = await findClientByName(api, appName); + if (app !== undefined && portal.client?.client_id === app.client_id) return current; + if (!deps.interactive) { + throw new Error(`The slug "${current}" is already used by another portal. Rerun with a different --slug.`); + } + deps.ui.warn(`The slug "${current}" is already taken.`); + current = await askValidSlug(deps.prompt, `${current}-2`, deps.ui); + } +} + +async function askValidSlug(prompt: Prompter, suggestion: string, ui: Ui): Promise { + for (;;) { + const answer = await prompt.ask("Slug", suggestion); + if (isValidPortalSlug(answer)) return answer; + ui.error("The slug must start with a lowercase letter and use only lowercase letters, digits and hyphens."); + } +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/vault.ts b/internal/cli/data/universal-portals/_source/src/setup/vault.ts new file mode 100644 index 000000000..d2c811b3b --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/setup/vault.ts @@ -0,0 +1,47 @@ +import type { ApiClient } from "../api.js"; +import { vaultAppsUrl } from "../dashboard.js"; +import type { Ui } from "../ui.js"; +import { FORMS_VAULT_SCOPES, managementAudience } from "./payload.js"; +import type { SetupResult } from "./run.js"; +import { + describe, + ensureClientGrant, + ensureFormsVaultClient, + Ensured, + ensureVaultConnection, + refreshVaultConnection, +} from "./resources.js"; + +export const FORMS_VAULT_NAME = "Universal Portals forms vault"; + +// Shared forms vault: one machine-to-machine app and one vault connection per +// tenant. A scope change needs a vault refresh, which sends setup again so the +// vault mints a session with the new grants. +export async function ensureFormsVault( + api: ApiClient, + ui: Ui, + tenantDomain: string, + resources: SetupResult["resources"], +): Promise> { + const app = await ui.task("Preparing the forms vault", () => ensureFormsVaultClient(api, FORMS_VAULT_NAME)); + const grant = await ensureClientGrant(api, { + clientId: app.value.clientId, + audience: managementAudience(tenantDomain), + scope: FORMS_VAULT_SCOPES, + subjectType: "client", + }); + + const setup = { domain: tenantDomain, clientId: app.value.clientId, clientSecret: app.value.clientSecret }; + const vault = await ensureVaultConnection(api, FORMS_VAULT_NAME, setup); + if (grant.status === "updated" && vault.status === "reused") { + await refreshVaultConnection(api, vault.value.id, setup); + ui.success("Forms vault refreshed with the updated permissions"); + } + resources.push({ type: "forms_vault_client", id: app.value.clientId, status: app.status }); + resources.push({ type: "forms_vault_grant", id: grant.value.id, status: grant.status }); + resources.push({ type: "forms_vault", id: vault.value.id, status: vault.status }); + ui.success(`"${FORMS_VAULT_NAME}"`, describe(vault.status)); + // Vault connections have no page of their own, so the ID links to the AUTH0 app type. + ui.reference("Vault connection", vault.value.id, vaultAppsUrl(tenantDomain)); + return vault; +} diff --git a/internal/cli/data/universal-portals/_source/src/slug.ts b/internal/cli/data/universal-portals/_source/src/slug.ts new file mode 100644 index 000000000..102126582 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/slug.ts @@ -0,0 +1,11 @@ +// Default URL slug for a portal name, for example "Acme Admin" -> "acme-admin". +export function toPortalSlug(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/^[^a-z]+/, "") + .slice(0, 100) + .replace(/-+$/, ""); + return slug || "my-portal"; +} diff --git a/internal/cli/data/universal-portals/_source/src/templates/business.ts b/internal/cli/data/universal-portals/_source/src/templates/business.ts new file mode 100644 index 000000000..fb653b5a9 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/templates/business.ts @@ -0,0 +1,100 @@ +import type { PortalPage, SidebarComponent } from "../portal-model.js"; +import { SIDEBAR_ICONS } from "./icons.js"; +import { SECURITY_PAGE, SECURITY_SIDEBAR_ICON } from "./security.js"; +import type { FormIds, PortalTemplate } from "./types.js"; + +export function businessTemplate(forms: Pick): PortalTemplate { + const pages: PortalPage[] = [ + { + title: "Organization", + slug: "organization", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Organization details", + description: "Update your organization's name and other details visible to its members.", + variant: "card", + children: [{ type: "page:component:auth0:my_organization:details_edit" }], + }, + }, + ], + }, + { + title: "Domains", + slug: "domains", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Verified domains", + description: "Add and verify the domains that your organization members sign in with.", + variant: "card", + children: [{ type: "page:component:auth0:my_organization:domain_table" }], + }, + }, + ], + }, + SECURITY_PAGE, + { + title: "Preferences", + slug: "preferences", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Personal information", + description: + "Basic info about you, like your name and contact details, that you use across services.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.personalInfo, + completion_message: "Your personal information has been updated.", + }, + }, + ], + }, + }, + { + type: "page:component:auth0:structure:section", + config: { + title: "Communication preferences", + description: "Choose which emails and notifications you'd like to receive from us.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.communicationPreferences, + completion_message: "Your communication preferences have been updated.", + }, + }, + ], + }, + }, + ], + }, + ]; + + const sidebar: SidebarComponent[] = [ + link("Organization", "organization", SIDEBAR_ICONS.organization), + link("Domains", "domains", SIDEBAR_ICONS.domains), + link("Security", "security", SECURITY_SIDEBAR_ICON), + link("Preferences", "preferences", SIDEBAR_ICONS.preferences), + ]; + + return { + id: "business", + apis: ["my-organization", "my-account"], + defaultPage: "organization", + sidebar, + pages, + }; +} + +function link(label: string, pageSlug: string, icon: string): SidebarComponent { + return { type: "sidebar:component:auth0:internal_link:v2", config: { label, page_slug: pageSlug, icon } }; +} diff --git a/internal/cli/data/universal-portals/_source/src/templates/consumer.ts b/internal/cli/data/universal-portals/_source/src/templates/consumer.ts new file mode 100644 index 000000000..dcd6a17a1 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/templates/consumer.ts @@ -0,0 +1,92 @@ +import type { PortalPage, SidebarComponent } from "../portal-model.js"; +import { SIDEBAR_ICONS } from "./icons.js"; +import { SECURITY_PAGE, SECURITY_SIDEBAR_ICON } from "./security.js"; +import type { FormIds, PortalTemplate } from "./types.js"; + +export function consumerTemplate(forms: FormIds): PortalTemplate { + const pages: PortalPage[] = [ + { + title: "Profile", + slug: "profile", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Personal information", + description: + "Basic info about you, like your name and contact details, that you use across services.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.personalInfo, + completion_message: "Your personal information has been updated.", + }, + }, + ], + }, + }, + ], + }, + SECURITY_PAGE, + { + title: "Legal & privacy", + slug: "legal-privacy", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Privacy & data consent", + description: "Control how your personal data is collected and used across our services.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.privacyConsent, + completion_message: "Your privacy preferences have been saved.", + }, + }, + ], + }, + }, + { + type: "page:component:auth0:structure:section", + config: { + title: "Communication preferences", + description: "Choose which emails and notifications you'd like to receive from us.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.communicationPreferences, + completion_message: "Your communication preferences have been updated.", + }, + }, + ], + }, + }, + ], + }, + ]; + + const sidebar: SidebarComponent[] = [ + link("Profile", "profile", SIDEBAR_ICONS.profile), + link("Security", "security", SECURITY_SIDEBAR_ICON), + link("Legal & privacy", "legal-privacy", SIDEBAR_ICONS.legalPrivacy), + ]; + + return { + id: "consumer", + apis: ["my-account"], + defaultPage: "profile", + sidebar, + pages, + }; +} + +function link(label: string, pageSlug: string, icon: string): SidebarComponent { + return { type: "sidebar:component:auth0:internal_link:v2", config: { label, page_slug: pageSlug, icon } }; +} diff --git a/internal/cli/data/universal-portals/_source/src/templates/icons.ts b/internal/cli/data/universal-portals/_source/src/templates/icons.ts new file mode 100644 index 000000000..a3778a7de --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/templates/icons.ts @@ -0,0 +1,12 @@ +// Lucide icon names for sidebar links. The API accepts any string of 1-50 +// characters, so this map is the only place that decides what we ship. +export const SIDEBAR_ICONS = { + profile: "user", + security: "shield-check", + legalPrivacy: "file-text", + organization: "building-2", + domains: "globe", + preferences: "sliders-horizontal", +} as const; + +export const ICON_NAME_PATTERN = /^[a-z0-9-]{1,50}$/; diff --git a/internal/cli/data/universal-portals/_source/src/templates/security.ts b/internal/cli/data/universal-portals/_source/src/templates/security.ts new file mode 100644 index 000000000..695df3ba1 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/templates/security.ts @@ -0,0 +1,32 @@ +import type { PortalPage } from "../portal-model.js"; +import { SIDEBAR_ICONS } from "./icons.js"; + +// Shared by both templates, so the account security page is defined once. +export const SECURITY_PAGE: PortalPage = { + title: "Security", + slug: "security", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Multi-factor authentication", + description: + "Add an extra layer of protection to your account by requiring a second verification step each time you sign in.", + variant: "card", + children: [{ type: "page:component:auth0:my_account:mfa_management" }], + }, + }, + { + type: "page:component:auth0:structure:section", + config: { + title: "Passkeys", + description: + "Use your fingerprint, face, or screen lock instead of a password to sign in quickly and more securely.", + variant: "card", + children: [{ type: "page:component:auth0:my_account:passkey_management" }], + }, + }, + ], +}; + +export const SECURITY_SIDEBAR_ICON = SIDEBAR_ICONS.security; diff --git a/internal/cli/data/universal-portals/_source/src/templates/types.ts b/internal/cli/data/universal-portals/_source/src/templates/types.ts new file mode 100644 index 000000000..c741b6b43 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/templates/types.ts @@ -0,0 +1,18 @@ +import type { PortalPage, SidebarComponent } from "../portal-model.js"; + +// Forms are created by setup, so their IDs are only known at runtime. +export interface FormIds { + personalInfo: string; + privacyConsent: string; + communicationPreferences: string; +} + +export type TemplateId = "consumer" | "business"; + +export interface PortalTemplate { + id: TemplateId; + apis: ("my-account" | "my-organization")[]; + defaultPage: string; + sidebar: SidebarComponent[]; + pages: PortalPage[]; +} diff --git a/internal/cli/data/universal-portals/_source/src/ui.ts b/internal/cli/data/universal-portals/_source/src/ui.ts new file mode 100644 index 000000000..ff01b91bd --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/ui.ts @@ -0,0 +1,186 @@ +import { box, cancel, log, note, spinner } from "@clack/prompts"; + +// The only module that writes human output. It draws with Clack's prompts package: +// the cover, section headers, item and result lines, and a closing note. Everything +// goes to stderr, and stdout is reserved for the JSON result. Silent mode (agent and +// --json) draws nothing. +// +// Output is sequential: each line stays where it was printed. A section header +// starts a block, its items follow, and its result line closes it. A blank line +// comes before every header, item and result, so each entity reads on its own. + +const RESET = "\x1b[0m"; +const codes = { + bold: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[91m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + magenta: "\x1b[35m", +} as const; + +export type Style = keyof typeof codes; + +export function colorEnabled(env: NodeJS.ProcessEnv, isTTY: boolean, noColorFlag = false): boolean { + if (env.NO_COLOR !== undefined || noColorFlag) return false; + return isTTY; +} + +export class Ui { + // silent suppresses all human output (agent and --json modes). animate enables + // the spinner, which needs a terminal. + private spinnerGap = false; + + constructor( + private readonly output: NodeJS.WriteStream, + private readonly color: boolean, + private readonly silent: boolean, + private readonly animate: boolean = false, + ) {} + + paint(style: Style, text: string): string { + if (!this.color) return text; + return `${codes[style]}${text}${RESET}`; + } + + // The title box: the command, and the tenant every change will go to. It prints + // before any question, so a wrong tenant is visible from the start. + banner(title: string, tenant: string): void { + if (this.silent) return; + box(this.paint("cyan", tenant), this.paint("bold", title), { + output: this.output, + width: "auto", + rounded: true, + contentAlign: "center", + titleAlign: "center", + }); + } + + // A section header starts a block of items, closed by its result. + section(title: string): void { + if (this.silent) return; + this.breakLine(); + log.step(title, { output: this.output, spacing: 0 }); + } + + // One item of a section: a plain line. Only the section result carries a symbol. + success(text: string, detail?: string): void { + if (this.silent) return; + const suffix = detail ? ` ${this.paint("dim", detail)}` : ""; + this.breakLine(); + log.message(`${text}${suffix}`, { output: this.output, spacing: 0 }); + } + + // A change the CLI made, with a green check so it reads as done. + fixed(text: string, detail?: string): void { + if (this.silent) return; + const suffix = detail ? ` ${this.paint("dim", detail)}` : ""; + this.breakLine(); + log.message(`${this.paint("green", "✓")} ${text}${suffix}`, { output: this.output, spacing: 0 }); + } + + // The outcome of a section, drawn like the other results. + result(text: string): void { + if (this.silent) return; + this.breakLine(); + log.success(text, { output: this.output, spacing: 0 }); + } + + warn(text: string): void { + if (this.silent) return; + log.warn(text, { output: this.output, spacing: 0 }); + } + + error(text: string): void { + if (this.silent) return; + log.error(text, { output: this.output, spacing: 0 }); + } + + info(text: string): void { + if (this.silent) return; + log.info(text, { output: this.output, spacing: 0 }); + } + + detail(text: string): void { + if (this.silent) return; + log.message(this.paint("dim", text), { output: this.output, spacing: 0 }); + } + + // Shows an ID that links to its dashboard page. Terminals with OSC 8 support + // make the ID clickable. Without color (no terminal, NO_COLOR) it stays plain + // text, as the core CLI does. + reference(label: string, id: string, url: string): void { + if (this.silent) return; + const linked = this.color ? `\x1b]8;;${url}\x1b\\${this.paint("cyan", id)}\x1b]8;;\x1b\\` : id; + log.message(`${this.paint("dim", `${label}:`)} ${linked}`, { output: this.output, spacing: 0 }); + } + + // The closing block of a successful setup: the result, then the portal URL in a note. + ready(url: string): void { + if (this.silent) return; + this.breakLine(); + const linked = this.color ? `\x1b]8;;${url}\x1b\\${this.paint("cyan", url)}\x1b]8;;\x1b\\` : url; + note(`${this.paint("dim", "Open portal:")} ${linked}`, "Portal URL", { output: this.output }); + } + + // The closing line when the user cancels. Clack draws it in the frame. + cancelled(message: string): void { + if (this.silent) return; + cancel(message, { output: this.output }); + } + + // A table with a header row. Widths follow the longest cell, and the first + // column is the status symbol, colored by its text. + table(headers: string[], rows: string[][]): void { + if (this.silent) return; + const widths = headers.map((header, column) => + Math.max(header.length, ...rows.map((row) => row[column].length)), + ); + const format = (cells: string[], style?: (cell: string, column: number) => string) => + cells + .map((cell, column) => { + const padded = cell.padEnd(widths[column]); + return style ? style(padded, column) : padded; + }) + .join(" "); + log.message(this.paint("dim", format(headers)), { output: this.output, spacing: 0 }); + for (const row of rows) { + const status = row[0] === "✖" ? "red" : "yellow"; + log.message(format(row, (cell, column) => (column === 0 ? this.paint(status, cell) : cell)), { output: this.output, spacing: 0 }); + } + } + + newline(): void { + if (this.silent) return; + log.message("", { output: this.output, spacing: 0 }); + } + + // Runs work behind a spinner. The spinner line is cleared when the work ends, + // so the caller prints the result in its place. Without a terminal there is no + // spinner, only the work itself. + async task(label: string, work: () => Promise): Promise { + if (this.silent || !this.animate) return work(); + + const running = spinner({ output: this.output }); + running.start(label); + try { + return await work(); + } finally { + running.clear(); + // The spinner drew its own guide line, which is the gap before the next line. + this.spinnerGap = true; + } + } + + // A blank line before a header, item or result. Right after a spinner the gap is + // already there, so no second one is drawn. + private breakLine(): void { + if (this.spinnerGap) { + this.spinnerGap = false; + return; + } + log.message("", { output: this.output, spacing: 0 }); + } +} diff --git a/internal/cli/data/universal-portals/_source/src/write-tracker.ts b/internal/cli/data/universal-portals/_source/src/write-tracker.ts new file mode 100644 index 000000000..f4806a4bc --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/write-tracker.ts @@ -0,0 +1,17 @@ +import type { ApiClient, HttpMethod } from "./api.js"; + +// Records whether a request that changes the tenant succeeded. Failed requests do +// not count, so the preflight probes (rejected with 400) do not count either. +export function trackWrites(api: ApiClient): { api: ApiClient; wrote: () => boolean } { + let wrote = false; + return { + api: { + async request(method: HttpMethod, path: string, body?: unknown): Promise { + const result = await api.request(method, path, body); + if (method !== "GET") wrote = true; + return result; + }, + }, + wrote: () => wrote, + }; +} diff --git a/internal/cli/data/universal-portals/_source/test/api.test.ts b/internal/cli/data/universal-portals/_source/test/api.test.ts new file mode 100644 index 000000000..1deecd780 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/api.test.ts @@ -0,0 +1,102 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { ApiClient, ApiError, createApiClient, listAll, listItems, type FetchLike } from "../src/api.js"; +import { readContext } from "../src/context.js"; + +function fakeFetch(status: number, body: unknown): { fetch: FetchLike; calls: { url: string; init: RequestInit }[] } { + const calls: { url: string; init: RequestInit }[] = []; + const fetch: FetchLike = async (url, init) => { + calls.push({ url, init }); + const text = body === undefined ? "" : JSON.stringify(body); + return new Response(text, { status, headers: { "content-type": "application/json" } }); + }; + return { fetch, calls }; +} + +test("listAll reads every page until a short one", async () => { + const everything = Array.from({ length: 250 }, (_, index) => ({ id: `item_${index}` })); + const api = { + async request(_method: string, path: string) { + const page = Number(new URL(`https://x/${path}`).searchParams.get("page")); + return everything.slice(page * 100, (page + 1) * 100); + }, + } as ApiClient; + + const items = await listAll<{ id: string }>(api, "things", "things"); + + assert.equal(items.length, 250); + assert.equal(items[249].id, "item_249"); +}); + +test("listAll stops with an error when an endpoint ignores paging", async () => { + const api = { + async request() { + return Array.from({ length: 100 }, (_, index) => ({ id: `item_${index}` })); + }, + } as ApiClient; + + await assert.rejects(listAll(api, "things", "things"), /did not stop paging/); +}); + +test("requests go to the proxy with the bearer token and JSON body", async () => { + const { fetch, calls } = fakeFetch(200, { id: "x" }); + const api = createApiClient("https://127.0.0.1:4433/", "secret-token", fetch); + const result = await api.request<{ id: string }>("POST", "clients", { name: "a" }); + + assert.equal(result.id, "x"); + assert.equal(calls[0].url, "https://127.0.0.1:4433/api/v2/clients"); + assert.equal(calls[0].init.method, "POST"); + assert.deepEqual(calls[0].init.headers, { + authorization: "Bearer secret-token", + accept: "application/json", + "content-type": "application/json", + }); + assert.equal(calls[0].init.body, JSON.stringify({ name: "a" })); +}); + +test("a non-2xx response becomes an ApiError with status, code and message", async () => { + const { fetch } = fakeFetch(403, { + statusCode: 403, + message: "Insufficient scope, expected any of: create:clients", + errorCode: "insufficient_scope", + }); + const api = createApiClient("https://proxy", "t", fetch); + + await assert.rejects(api.request("POST", "clients", {}), (error: unknown) => { + assert.ok(error instanceof ApiError); + assert.equal(error.status, 403); + assert.equal(error.errorCode, "insufficient_scope"); + assert.match(error.message, /create:clients/); + return true; + }); +}); + +test("listItems reads both array responses and keyed responses", () => { + assert.deepEqual(listItems([{ id: 1 }], "portals"), [{ id: 1 }]); + assert.deepEqual(listItems({ portals: [{ id: 2 }] }, "portals"), [{ id: 2 }]); + assert.deepEqual(listItems({ other: [] }, "portals"), []); +}); + +test("readContext names every missing variable", () => { + assert.throws(() => readContext({}), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /AUTH0_CLI_PROXY_URL/); + assert.match(error.message, /AUTH0_CLI_PROXY_TOKEN/); + assert.match(error.message, /AUTH0_CLI_TENANT_DOMAIN/); + return true; + }); +}); + +test("readContext returns the three values when all are set", () => { + const context = readContext({ + AUTH0_CLI_PROXY_URL: "https://127.0.0.1:1", + AUTH0_CLI_PROXY_TOKEN: "t", + AUTH0_CLI_TENANT_DOMAIN: "acme.us.auth0.com", + }); + assert.deepEqual(context, { + proxyUrl: "https://127.0.0.1:1", + proxyToken: "t", + tenantDomain: "acme.us.auth0.com", + }); +}); diff --git a/internal/cli/data/universal-portals/_source/test/audit-checks.test.ts b/internal/cli/data/universal-portals/_source/test/audit-checks.test.ts new file mode 100644 index 000000000..5632f52fd --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/audit-checks.test.ts @@ -0,0 +1,281 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { auditPortal } from "../src/audit/checks.js"; +import type { PortalSnapshot } from "../src/audit/snapshot.js"; + +const DOMAIN = "acme.us.auth0.com"; +const ME = `https://${DOMAIN}/me/`; +const MY_ORG = `https://${DOMAIN}/my-org/`; + +// A healthy consumer portal, shaped like one created by setup. +function healthy(): PortalSnapshot { + return { + tenantDomain: DOMAIN, + portal: { + id: "up_1", + slug: "acme", + client: { client_id: "cli_1" }, + navigation: { + sidebar: { + components: [ + { type: "sidebar:component:auth0:internal_link:v2", config: { label: "Profile", page_slug: "profile", icon: "user" } }, + { type: "sidebar:component:auth0:internal_link:v2", config: { label: "Security", page_slug: "security", icon: "shield-check" } }, + ], + }, + }, + pages: { + default: "profile", + standard: [ + { + title: "Profile", + slug: "profile", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + variant: "card", + children: [{ type: "page:component:auth0:form", config: { form_id: "ap_1", completion_message: "Saved" } }], + }, + }, + ], + }, + { + title: "Security", + slug: "security", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + variant: "card", + children: [{ type: "page:component:auth0:my_account:mfa_management", config: {} }], + }, + }, + ], + }, + ], + }, + }, + application: { + client_id: "cli_1", + grant_types: ["authorization_code", "refresh_token"], + callbacks: [`https://${DOMAIN}/portals/auth/callback`], + organization_usage: "allow", + is_token_endpoint_ip_header_trusted: true, + session_transfer: { allowed_authentication_methods: ["query"] }, + refresh_token: { + rotation_type: "non-rotating", + idle_token_lifetime: 3600, + token_lifetime: 86400, + infinite_idle_token_lifetime: false, + policies: [{ audience: ME, scope: ["read:me:factors"] }], + }, + }, + grants: [{ audience: ME, scope: ["read:me:factors"] }], + resourceServers: [ME], + forms: { ap_1: true }, + applicationConnections: [{ id: "con_db", name: "Username-Password-Authentication" }], + }; +} + +function ids(snapshot: PortalSnapshot): string[] { + return auditPortal(snapshot).map((finding) => finding.id); +} + +test("a healthy consumer portal has no findings", () => { + assert.deepEqual(auditPortal(healthy()), []); +}); + +test("a missing home page is an error", () => { + const snapshot = healthy(); + snapshot.portal.pages = { ...snapshot.portal.pages, default: undefined }; + assert.ok(ids(snapshot).includes("P001")); +}); + +test("a sidebar link in the legacy shape is an error", () => { + const snapshot = healthy(); + snapshot.portal.navigation = { + sidebar: { components: [{ type: "sidebar:component:auth0:internal_link", config: { label: "Profile", to: "profile" } }] }, + }; + assert.ok(ids(snapshot).includes("P003")); +}); + +test("a sidebar link to a missing page is an error", () => { + const snapshot = healthy(); + snapshot.portal.navigation!.sidebar!.components!.push({ + type: "sidebar:component:auth0:internal_link:v2", + config: { label: "Billing", page_slug: "billing" }, + }); + assert.ok(ids(snapshot).includes("P004")); +}); + +test("a page that is not in the sidebar is a warning", () => { + const snapshot = healthy(); + snapshot.portal.pages!.standard!.push({ title: "Billing", slug: "billing", components: [] }); + const findings = auditPortal(snapshot); + assert.ok(findings.some((finding) => finding.id === "P005" && finding.severity === "warning")); +}); + +test("a form the portal uses that does not exist is an error", () => { + const snapshot = healthy(); + snapshot.forms = { ap_1: false }; + assert.ok(ids(snapshot).includes("P007")); +}); + +test("client_credentials on the portal application is an error", () => { + const snapshot = healthy(); + snapshot.application!.grant_types = [...snapshot.application!.grant_types!, "client_credentials"]; + assert.ok(ids(snapshot).includes("P010")); +}); + +test("a rotating refresh token is an error", () => { + const snapshot = healthy(); + snapshot.application!.refresh_token!.rotation_type = "rotating"; + assert.ok(ids(snapshot).includes("P013")); +}); + +test("a refresh token policy missing for a used API is an error", () => { + const snapshot = healthy(); + snapshot.application!.refresh_token!.policies = []; + assert.ok(ids(snapshot).includes("P020")); +}); + +test("no connection available to the application is an error", () => { + const snapshot = healthy(); + snapshot.applicationConnections = []; + assert.ok(ids(snapshot).includes("P021")); +}); + +test("business components without organization usage is an error", () => { + const snapshot = healthy(); + snapshot.portal.pages!.standard![0].components!.push({ + type: "page:component:auth0:structure:section", + config: { + variant: "card", + children: [{ type: "page:component:auth0:my_organization:details_edit", config: {} }], + }, + }); + snapshot.application!.organization_usage = "deny"; + snapshot.resourceServers.push(MY_ORG); + snapshot.grants.push({ audience: MY_ORG, scope: ["read:my_org:details"] }); + snapshot.application!.refresh_token!.policies!.push({ audience: MY_ORG, scope: ["read:my_org:details"] }); + assert.ok(ids(snapshot).includes("P016")); +}); + +test("an organization with no connection in common with the application is an error", () => { + const snapshot = healthy(); + snapshot.organization = { id: "org_1", name: "acme", connections: ["con_other"] }; + assert.ok(ids(snapshot).includes("P022")); +}); + +test("an organization that shares a connection with the application passes", () => { + const snapshot = healthy(); + snapshot.organization = { id: "org_1", name: "acme", connections: ["con_db"] }; + assert.deepEqual(auditPortal(snapshot), []); +}); + +test("custom code without a custom domain is a warning", () => { + const snapshot = healthy(); + snapshot.portal.custom_code = { head: "" }; + assert.ok(auditPortal(snapshot).some((finding) => finding.id === "P008" && finding.severity === "warning")); +}); + +test("DPoP enabled on the portal application is an error with an automatic fix", () => { + const snapshot = healthy(); + snapshot.application!.require_proof_of_possession = true; + const finding = auditPortal(snapshot).find((item) => item.id === "P023"); + assert.equal(finding?.severity, "error"); + assert.ok(finding?.remedy?.apply, "the fix can be applied"); +}); + +test("session transfer not enabled is a warning, and has no automatic fix", () => { + const snapshot = healthy(); + snapshot.application!.session_transfer = { allowed_authentication_methods: [] }; + const finding = auditPortal(snapshot).find((item) => item.id === "P024"); + assert.equal(finding?.severity, "warning"); + assert.equal(finding?.remedy?.apply, undefined); +}); + +// One table row per check: a healthy snapshot, one change, and the finding that +// change must produce. Each row becomes its own named test. +const BREAKS: { id: string; name: string; breakIt: (snapshot: PortalSnapshot) => void }[] = [ + { + id: "P002", + name: "the home page missing from the sidebar is a warning", + breakIt: (snapshot) => { + snapshot.portal.navigation!.sidebar!.components = []; + }, + }, + { + id: "P003", + name: "a legacy sidebar link is an error", + breakIt: (snapshot) => { + snapshot.portal.navigation!.sidebar!.components = [ + { type: "sidebar:component:auth0:internal_link", config: { label: "Profile", to: "profile" } }, + ]; + }, + }, + { + id: "P006", + name: "a page with no components is a warning", + breakIt: (snapshot) => { + snapshot.portal.pages!.standard!.push({ title: "Billing", slug: "billing", components: [] }); + snapshot.portal.navigation!.sidebar!.components!.push({ + type: "sidebar:component:auth0:internal_link:v2", + config: { label: "Billing", page_slug: "billing" }, + }); + }, + }, + { + id: "P009", + name: "a portal with no application is an error", + breakIt: (snapshot) => { + snapshot.portal.client = undefined; + snapshot.application = undefined; + }, + }, + { + id: "P011", + name: "a Management API grant on the portal application is an error", + breakIt: (snapshot) => { + snapshot.grants.push({ id: "cgr_mgmt", audience: `https://${DOMAIN}/api/v2/`, scope: ["read:users"] }); + }, + }, + { + id: "P012", + name: "a missing callback URL is an error", + breakIt: (snapshot) => { + snapshot.application!.callbacks = []; + }, + }, + { + id: "P014", + name: "a refresh token with no idle expiry is an error", + breakIt: (snapshot) => { + snapshot.application!.refresh_token!.infinite_idle_token_lifetime = true; + }, + }, + { + id: "P017", + name: "an untrusted token endpoint IP header is a warning", + breakIt: (snapshot) => { + snapshot.application!.is_token_endpoint_ip_header_trusted = false; + }, + }, + { + id: "P019", + name: "a missing client grant for a used API is an error", + breakIt: (snapshot) => { + snapshot.grants = []; + }, + }, +]; + +for (const row of BREAKS) { + test(`${row.id}: ${row.name}`, () => { + const snapshot = healthy(); + row.breakIt(snapshot); + const finding = auditPortal(snapshot).find((item) => item.id === row.id); + assert.ok(finding, `${row.id} should be reported`); + }); +} 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 new file mode 100644 index 000000000..0c8700f16 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts @@ -0,0 +1,121 @@ +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 { auditPortal } from "../src/audit/checks.js"; +import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; +import { runSetup } from "../src/setup/run.js"; +import { Ui } from "../src/ui.js"; +import { FakeTenant } from "./fake-tenant.js"; +import { captureOutput } from "./capture-output.js"; + +const DOMAIN = "acme.us.auth0.com"; +const quiet = () => new Ui(captureOutput().stream, false, true); + +async function createPortal(tenant: FakeTenant): Promise<{ portalId: string; clientId: string }> { + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { + api: tenant, + ui: quiet(), + prompt: { ask: async (_q, d) => d ?? "", confirm: async (_q, d) => d, choose: async (_q, o) => o[0].value, checklist: async () => { throw new Error("unexpected question"); } }, + interactive: false, + tenantDomain: DOMAIN, + }, + ); + return { portalId: result.portal.id, clientId: result.application.clientId }; +} + +function answers(confirm: boolean) { + return { + async ask() { + return ""; + }, + async confirm() { + return confirm; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }; +} + +test("applying the errors repairs a home page, a missing refresh policy, and rotation", async () => { + const tenant = new FakeTenant(); + const { portalId, clientId } = await createPortal(tenant); + + // Break the portal the way it commonly breaks: no home page, a component + // without its refresh policy, and rotation switched on. + tenant.portals.get(portalId)!.pages.default = undefined; + const client = tenant.clients.get(clientId)!; + client.refresh_token = { ...client.refresh_token, rotation_type: "rotating", policies: [] }; + + const readSnapshot = () => fetchPortalSnapshot(tenant, portalId, DOMAIN); + const findings = auditPortal(await readSnapshot()); + const errors = fixableFindings(findings).filter((finding) => finding.severity === "error"); + const result = await applyFixes(errors, { api: tenant, ui: quiet(), readSnapshot }); + assert.ok(result.applied >= 3, `applied ${result.applied}`); + + const after = await runAudit({ portalId, strict: false }, { api: tenant, ui: quiet(), tenantDomain: DOMAIN }); + assert.equal(after.errors, 0, JSON.stringify(after.findings)); +}); + +test("a fix that is already in place is skipped, not applied twice", async () => { + const tenant = new FakeTenant(); + const { portalId } = await createPortal(tenant); + tenant.portals.get(portalId)!.pages.default = undefined; + const readSnapshot = () => fetchPortalSnapshot(tenant, portalId, DOMAIN); + const stale = auditPortal(await readSnapshot()); + const home = stale.find((finding) => finding.id === "P001")!; + tenant.portals.get(portalId)!.pages.default = "profile"; // fixed by hand meanwhile + + const result = await applyFixes([home], { api: tenant, ui: quiet(), readSnapshot }); + assert.equal(result.alreadyFixed, 1); + assert.equal(result.applied, 0); +}); + +test("a fix still applies when the finding's message changed after the audit", async () => { + const tenant = new FakeTenant(); + const { portalId } = await createPortal(tenant); + const legacy = { type: "sidebar:component:auth0:internal_link", config: { label: "Profile", to: "profile", icon: "user" } }; + tenant.portals.get(portalId)!.navigation.sidebar.components = [legacy]; + const readSnapshot = () => fetchPortalSnapshot(tenant, portalId, DOMAIN); + const finding = auditPortal(await readSnapshot()).find((item) => item.id === "P003"); + assert.ok(finding?.remedy?.apply, "P003 is reported with a fix"); + + // A second legacy link changes the count in the message, not the problem. + tenant.portals.get(portalId)!.navigation.sidebar.components.push({ ...legacy, config: { ...legacy.config, to: "account" } }); + const result = await applyFixes([finding], { api: tenant, ui: quiet(), readSnapshot }); + + assert.equal(result.applied, 1); + assert.equal(result.alreadyFixed, 0); +}); + +test("findings that share an ID are told apart by their subject", async () => { + const tenant = new FakeTenant(); + const { portalId } = await createPortal(tenant); + tenant.portals.get(portalId)!.pages.standard.push( + { slug: "terms", title: "Terms", components: [] }, + { slug: "faq", title: "FAQ", components: [] }, + ); + + const emptyPages = auditPortal(await fetchPortalSnapshot(tenant, portalId, DOMAIN)).filter((finding) => finding.id === "P006"); + + assert.equal(emptyPages.length, 2); + assert.equal(new Set(emptyPages.map(findingKey)).size, 2); +}); + +test("warnings are reported but never offered for fixing", async () => { + const tenant = new FakeTenant(); + const { portalId, clientId } = await createPortal(tenant); + tenant.clients.get(clientId)!.is_token_endpoint_ip_header_trusted = false; + + const findings = auditPortal(await fetchPortalSnapshot(tenant, portalId, DOMAIN)); + assert.equal(fixableFindings(findings).length, 0, "warnings are never fixable"); + assert.ok(findings.some((finding) => finding.id === "P017" && finding.severity === "warning")); +}); diff --git a/internal/cli/data/universal-portals/_source/test/audit-remedies.test.ts b/internal/cli/data/universal-portals/_source/test/audit-remedies.test.ts new file mode 100644 index 000000000..4c7d8e67f --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/audit-remedies.test.ts @@ -0,0 +1,128 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { auditPortal } from "../src/audit/checks.js"; +import { applyFixes, fixableFindings } from "../src/audit/fix.js"; +import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; +import { runSetup } from "../src/setup/run.js"; +import { Ui } from "../src/ui.js"; +import { FakeTenant } from "./fake-tenant.js"; +import { captureOutput } from "./capture-output.js"; + +const DOMAIN = "acme.us.auth0.com"; +const quiet = () => new Ui(captureOutput().stream, false, true); + +// A healthy consumer portal from setup, so each test breaks exactly one thing. +async function healthyPortal(): Promise<{ tenant: FakeTenant; portalId: string; clientId: string }> { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { + api: tenant, + ui: quiet(), + prompt: { ask: async (_q, d) => d ?? "", confirm: async (_q, d) => d, choose: async (_q, o) => o[0].value, checklist: async () => { throw new Error("unexpected question"); } }, + interactive: false, + tenantDomain: DOMAIN, + }, + ); + return { tenant, portalId: result.portal.id, clientId: result.application.clientId }; +} + +// Runs the fix for one finding and returns what the tenant received. +async function fixOnly(tenant: FakeTenant, portalId: string, id: string): Promise { + const readSnapshot = () => fetchPortalSnapshot(tenant, portalId, DOMAIN); + const finding = auditPortal(await readSnapshot()).find((item) => item.id === id); + assert.ok(finding, `${id} should be reported before the fix`); + assert.ok(finding.remedy?.apply, `${id} should have an automatic fix`); + await applyFixes([finding], { api: tenant, ui: quiet(), readSnapshot }); +} + +test("P003: converting a legacy sidebar link leaves only :v2 links in the sidebar", async () => { + const { tenant, portalId } = await healthyPortal(); + tenant.portals.get(portalId)!.navigation.sidebar.components = [ + { type: "sidebar:component:auth0:internal_link", config: { label: "Profile", to: "profile", icon: "user" } }, + ]; + + await fixOnly(tenant, portalId, "P003"); + + const links = tenant.portals.get(portalId)!.navigation.sidebar.components; + assert.deepEqual(links[0], { + type: "sidebar:component:auth0:internal_link:v2", + config: { label: "Profile", page_slug: "profile", icon: "user" }, + }); +}); + +test("P002: adding the home page to the sidebar makes it reachable", async () => { + const { tenant, portalId } = await healthyPortal(); + tenant.portals.get(portalId)!.navigation.sidebar.components = []; + + await fixOnly(tenant, portalId, "P002"); + + const pages = tenant.portals.get(portalId)!.navigation.sidebar.components.map((item: { config: { page_slug: string } }) => item.config.page_slug); + assert.ok(pages.includes("profile")); +}); + +test("P011: removing the Management API grant deletes it from the application", async () => { + const { tenant, portalId, clientId } = await healthyPortal(); + tenant.grants.set("cgr_mgmt", { + id: "cgr_mgmt", + client_id: clientId, + audience: `https://${DOMAIN}/api/v2/`, + scope: ["read:users"], + }); + + await fixOnly(tenant, portalId, "P011"); + + assert.equal(tenant.grants.has("cgr_mgmt"), false); +}); + +test("P009: fixing a missing application creates the client and links it to the portal", async () => { + const { tenant, portalId, clientId } = await healthyPortal(); + tenant.portals.get(portalId)!.client = undefined; + tenant.clients.delete(clientId); + const clientsBefore = tenant.clients.size; + + await fixOnly(tenant, portalId, "P009"); + + assert.equal(tenant.clients.size, clientsBefore + 1, "one client was created"); + const linked = tenant.portals.get(portalId)!.client; + assert.ok(linked?.client_id && tenant.clients.has(linked.client_id), "the portal points at the new client"); + assert.equal(linked.client_secret !== undefined, true, "the secret is sent with the link"); +}); + +test("P009: a link that fails after the application was created does not create a second application on rerun", async () => { + const { tenant, portalId, clientId } = await healthyPortal(); + tenant.portals.get(portalId)!.client = undefined; + tenant.clients.delete(clientId); + const clientsBefore = tenant.clients.size; + tenant.failNext(`PATCH portals/${portalId}`, 500); + + await fixOnly(tenant, portalId, "P009"); + assert.equal(tenant.portals.get(portalId)!.client, undefined, "the first link failed"); + + await fixOnly(tenant, portalId, "P009"); + + assert.equal(tenant.clients.size, clientsBefore + 1, "one application in total"); + const linked = tenant.portals.get(portalId)!.client; + assert.ok(linked?.client_id && tenant.clients.has(linked.client_id), "the portal points at the application"); +}); + +test("P023: turning DPoP off on the application", async () => { + const { tenant, portalId, clientId } = await healthyPortal(); + tenant.clients.get(clientId)!.require_proof_of_possession = true; + + await fixOnly(tenant, portalId, "P023"); + + assert.equal(tenant.clients.get(clientId)!.require_proof_of_possession, false); +}); + +test("warnings are not fixed, even when a fix exists for them", async () => { + const { tenant, portalId, clientId } = await healthyPortal(); + tenant.clients.get(clientId)!.is_token_endpoint_ip_header_trusted = false; + + const findings = auditPortal(await fetchPortalSnapshot(tenant, portalId, DOMAIN)); + const warning = findings.find((item) => item.id === "P017"); + assert.equal(warning?.severity, "warning"); + assert.equal(fixableFindings(findings).some((item) => item.id === "P017"), false); +}); diff --git a/internal/cli/data/universal-portals/_source/test/audit-run.test.ts b/internal/cli/data/universal-portals/_source/test/audit-run.test.ts new file mode 100644 index 000000000..b937f4e71 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/audit-run.test.ts @@ -0,0 +1,184 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { auditPortal } from "../src/audit/checks.js"; +import { applyFixRounds, fixableFindings } from "../src/audit/fix.js"; +import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; +import { runAudit } from "../src/audit/run.js"; +import { MY_ORGANIZATION_SCOPES } from "../src/setup/payload.js"; +import { runSetup } from "../src/setup/run.js"; +import { Ui } from "../src/ui.js"; +import { FakeTenant } from "./fake-tenant.js"; +import { captureOutput } from "./capture-output.js"; + +const DOMAIN = "acme.us.auth0.com"; + +function quietUi(): Ui { + return new Ui(captureOutput().stream, false, true); +} + +function setupPrompt() { + return { + async ask(_q: string, defaultValue?: string) { + return defaultValue ?? ""; + }, + async confirm(_q: string, defaultValue: boolean) { + return defaultValue; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }; +} + +// A portal created by setup should pass the audit with no findings, so the +// audit and the setup agree on what a healthy portal is. +test("a portal created by setup passes the audit", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + + const audit = await runAudit( + { portalId: result.portal.id, strict: false }, + { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }, + ); + assert.deepEqual(audit.findings, []); + assert.equal(audit.failed, false); +}); + +test("a deleted form is reported as an error and fails the audit", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + const formId = [...tenant.forms.keys()][0]; + tenant.forms.delete(formId); + + const audit = await runAudit( + { portalId: result.portal.id, strict: false }, + { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }, + ); + assert.ok(audit.findings.some((finding) => finding.id === "P007")); + assert.equal(audit.failed, true); +}); + +test("a portal whose application was deleted is reported as P009, not a crash", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + tenant.clients.delete(result.application.clientId); + + const audit = await runAudit( + { portalId: result.portal.id, strict: false }, + { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }, + ); + assert.ok(audit.findings.some((finding) => finding.id === "P009")); + assert.equal(audit.failed, true); +}); + +test("the P009 fix creates an application and links it, so the next audit is clean", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + tenant.clients.delete(result.application.clientId); + + const readSnapshot = () => fetchPortalSnapshot(tenant, result.portal.id, DOMAIN); + const findings = auditPortal(await readSnapshot()); + await applyFixRounds(fixableFindings(findings), { api: tenant, ui: quietUi(), readSnapshot }, async (exposed) => exposed); + + const linked = tenant.portals.get(result.portal.id)!.client.client_id; + assert.ok(tenant.clients.has(linked), "the portal points at a client that exists"); + assert.notEqual(linked, result.application.clientId); + const audit = await runAudit( + { portalId: result.portal.id, strict: false }, + { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }, + ); + assert.equal(audit.errors, 0); +}); + +test("errors that a fix exposes are left alone when the user declines them", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + tenant.clients.delete(result.application.clientId); + + const readSnapshot = () => fetchPortalSnapshot(tenant, result.portal.id, DOMAIN); + await applyFixRounds(fixableFindings(auditPortal(await readSnapshot())), { api: tenant, ui: quietUi(), readSnapshot }, async () => []); + + const audit = await runAudit({ portalId: result.portal.id, strict: false }, { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }); + assert.ok(audit.findings.some((finding) => finding.id === "P019"), "the exposed grant error is still reported"); +}); + +test("a form ID the API cannot match is reported as a missing form (P007), not a crash", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + const portal = tenant.portals.get(result.portal.id)!; + const form = portal.pages.standard[0].components[0].config.children[0]; + form.config.form_id = "ap_not-a-real-form-id"; + + const audit = await runAudit( + { portalId: result.portal.id, strict: false }, + { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }, + ); + assert.ok(audit.findings.some((finding) => finding.id === "P007")); +}); + +test("a warning fails the audit only with --strict", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + // Custom code with no custom domain is a warning. + const portal = tenant.portals.get(result.portal.id)!; + portal.custom_code = { head: "" }; + + const lenient = await runAudit({ portalId: result.portal.id, strict: false }, { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }); + const strict = await runAudit({ portalId: result.portal.id, strict: true }, { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }); + assert.equal(lenient.failed, false); + assert.equal(strict.failed, true); +}); + +test("a business portal created by setup passes the audit", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + tenant.users.set("auth0|jane", { user_id: "auth0|jane", email: "jane@acme.com", name: "Jane" }); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|jane"])); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_admin", { id: "rol_admin", name: "Organization - Admin", permissions: MY_ORGANIZATION_SCOPES }); + tenant.orgRoles.set("org_1:auth0|jane", ["rol_admin"]); + + const result = await runSetup( + { template: "business", name: "Acme Admin", slug: "acme-admin", userQuery: "auth0|jane" }, + { api: tenant, ui: quietUi(), prompt: setupPrompt(), interactive: false, tenantDomain: DOMAIN }, + ); + + const audit = await runAudit( + { portalId: result.portal.id, strict: false }, + { api: tenant, ui: quietUi(), tenantDomain: DOMAIN }, + ); + assert.deepEqual(audit.findings, []); +}); diff --git a/internal/cli/data/universal-portals/_source/test/browser.test.ts b/internal/cli/data/universal-portals/_source/test/browser.test.ts new file mode 100644 index 000000000..2fc33a6b0 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/browser.test.ts @@ -0,0 +1,18 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { browserCommand } from "../src/browser.js"; + +const URL_ = "https://auth.acme.com/portals/acme"; + +test("macOS opens the URL with open", () => { + assert.deepEqual(browserCommand(URL_, "darwin"), { command: "open", args: [URL_] }); +}); + +test("Windows opens the URL with start, passing an empty title first", () => { + assert.deepEqual(browserCommand(URL_, "win32"), { command: "cmd", args: ["/c", "start", "", URL_] }); +}); + +test("Linux opens the URL with xdg-open", () => { + assert.deepEqual(browserCommand(URL_, "linux"), { command: "xdg-open", args: [URL_] }); +}); diff --git a/internal/cli/data/universal-portals/_source/test/business.test.ts b/internal/cli/data/universal-portals/_source/test/business.test.ts new file mode 100644 index 000000000..836ba99cc --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/business.test.ts @@ -0,0 +1,497 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { ApiError, HttpMethod, toApiError } from "../src/api.js"; +import type { Prompter } from "../src/prompt.js"; +import { BusinessAccessError } from "../src/setup/business-types.js"; +import { MY_ORGANIZATION_SCOPES } from "../src/setup/payload.js"; +import { chooseTestLink, ORGANIZATION_PLACEHOLDER, resolveBusinessAccess } from "../src/setup/business.js"; +import { Ui } from "../src/ui.js"; +import { FakeTenant } from "./fake-tenant.js"; +import { captureOutput } from "./capture-output.js"; + +// Answers the business questions from scripts. choose() takes the option whose +// label contains the given text, which keeps the script readable. +function scripted(answers: { ask?: string[]; choose?: string[]; confirm?: boolean[] }): Prompter { + const asks = [...(answers.ask ?? [])]; + const chooses = [...(answers.choose ?? [])]; + const confirms = [...(answers.confirm ?? [])]; + return { + async ask() { + const next = asks.shift(); + if (next === undefined) throw new Error("unexpected question"); + return next; + }, + async confirm() { + const next = confirms.shift(); + if (next === undefined) throw new Error("unexpected confirmation"); + return next; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_question: string, options: { label: string; value: T }[]): Promise { + const label = chooses.shift(); + const match = options.find((option) => option.label.includes(label ?? "\u0000")); + if (!match) throw new Error(`no option matching "${label}" in: ${options.map((o) => o.label).join(" | ")}`); + return match.value; + }, + }; +} + +function ui(): Ui { + return new Ui(captureOutput().stream, false, true); +} + +function seedUser(tenant: FakeTenant, id: string, email: string): void { + tenant.users.set(id, { user_id: id, email, name: email.split("@")[0] }); +} + +// A tenant where enabling a connection on the new organization conflicts. +class ConflictingConnectionTenant extends FakeTenant { + override async request(method: HttpMethod, path: string, body?: unknown): Promise { + if (method === "POST" && path.endsWith("/enabled_connections")) { + throw toApiError(409, { message: "The connection is already enabled." }); + } + return super.request(method, path, body); + } +} + +test("a conflict after the organization exists is not treated as a name clash", async () => { + const tenant = new ConflictingConnectionTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + + await assert.rejects( + resolveBusinessAccess( + { userQuery: "auth0|a" }, + { + api: tenant, + ui: ui(), + prompt: scripted({ ask: ["Acme Corp", "acme-corp"], confirm: [true], choose: ["None of these"] }), + interactive: true, + tenantDomain: "acme.us.auth0.com", + }, + ), + ApiError, + ); + assert.equal(tenant.organizations.size, 1); +}); + +test("skipping the test user links to the organization ID the user types", async () => { + const link = await chooseTestLink( + {}, + { api: new FakeTenant(), ui: ui(), prompt: scripted({ ask: ["", "org_42"] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.deepEqual(link, { organizationId: "org_42", isPlaceholder: false }); +}); + +test("skipping the test user and the organization keeps the placeholder in the link", async () => { + const link = await chooseTestLink( + {}, + { api: new FakeTenant(), ui: ui(), prompt: scripted({ ask: ["", ORGANIZATION_PLACEHOLDER] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.deepEqual(link, { organizationId: ORGANIZATION_PLACEHOLDER, isPlaceholder: true }); +}); + +test("a chosen test user links to their organization and keeps their access", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const link = await chooseTestLink( + { userQuery: "jane@acme.com" }, + { api: tenant, ui: ui(), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(link.isPlaceholder, false); + assert.equal(link.organizationId, "org_1"); + assert.equal(link.access?.userId, "auth0|a"); +}); + +test("an email lookup keeps only the exact address, not a longer one that contains it", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + seedUser(tenant, "auth0|b", "jane@acme.com.au"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { api: tenant, ui: ui(), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.userId, "auth0|a"); +}); + +test("an email that matches several users lets the user pick one", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + seedUser(tenant, "google-oauth2|b", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["google-oauth2|b"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [false], choose: ["google-oauth2|b"] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.userId, "google-oauth2|b"); +}); + +test("from a duplicate-email list, trying another email or ID looks the user up again", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + seedUser(tenant, "auth0|b", "jane@acme.com"); + seedUser(tenant, "auth0|other", "other@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|other"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { + api: tenant, + ui: ui(), + prompt: scripted({ confirm: [false], ask: ["auth0|other"], choose: ["Try another", "auth0|other"] }), + interactive: true, + tenantDomain: "acme.us.auth0.com", + }, + ); + assert.equal(access.userId, "auth0|other"); +}); + +test("an unknown email asks again instead of stopping", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "nobody@acme.com" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [false], ask: ["jane@acme.com"] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.userId, "auth0|a"); +}); + +test("without a terminal an email that matches several users stops with the IDs to use", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + seedUser(tenant, "auth0|b", "jane@acme.com"); + + await assert.rejects( + resolveBusinessAccess({ userQuery: "jane@acme.com" }, { api: tenant, ui: ui(), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }), + (error: unknown) => { + assert.ok(error instanceof BusinessAccessError); + assert.match(error.fixes.join(" "), /auth0\|a, auth0\|b/); + return true; + }, + ); +}); + +test("a user with no organization can create one, with a database connection and themselves as a member", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + + const access = await resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { + api: tenant, + ui: ui(), + prompt: scripted({ ask: ["Acme Admins", "acme-admins"], choose: ["None of these"], confirm: [true, false] }), + interactive: true, + tenantDomain: "acme.us.auth0.com", + }, + ); + + const org = [...tenant.organizations.values()].find((o) => o.display_name === "Acme Admins"); + assert.ok(org, "the organization was created"); + assert.equal(access.organizationId, org.id); + assert.deepEqual([...(tenant.orgConnections.get(org.id) ?? [])], ["con_db"]); + assert.ok(tenant.members.get(org.id)?.has("auth0|a")); +}); + +test("declining the organization confirmation creates nothing", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + + await assert.rejects( + resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { + api: tenant, + ui: ui(), + prompt: scripted({ ask: ["Acme Admins", "acme-admins"], choose: ["None of these"], confirm: [false] }), + interactive: true, + tenantDomain: "acme.us.auth0.com", + }, + ), + BusinessAccessError, + ); + assert.equal(tenant.organizations.size, 0); +}); + +test("a taken organization name asks for another name, with a suggestion", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_taken", { id: "org_taken", name: "acme-admins", display_name: "Acme" }); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + + const access = await resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { + api: tenant, + ui: ui(), + prompt: scripted({ ask: ["Acme Admins", "acme-admins", "acme-admins-2"], choose: ["None of these"], confirm: [true, true, false] }), + interactive: true, + tenantDomain: "acme.us.auth0.com", + }, + ); + + const created = tenant.organizations.get(access.organizationId); + assert.equal(created?.name, "acme-admins-2"); + assert.equal(created?.display_name, "Acme Admins"); +}); + +test("an organization name with invalid characters asks again", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + + const access = await resolveBusinessAccess( + { userQuery: "jane@acme.com" }, + { + api: tenant, + ui: ui(), + prompt: scripted({ ask: ["Acme Admins", "Acme Admins!", "acme-admins"], choose: ["None of these"], confirm: [true, false] }), + interactive: true, + tenantDomain: "acme.us.auth0.com", + }, + ); + assert.equal(tenant.organizations.get(access.organizationId)?.name, "acme-admins"); +}); + +test("a user ID that matches nobody asks again instead of stopping", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|nobody" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [false], ask: ["auth0|a"] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.userId, "auth0|a"); +}); + +test("a user in several organizations can pick the one the portal is for", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + for (const [id, name] of [["org_1", "acme"], ["org_2", "labs"]] as const) { + tenant.organizations.set(id, { id, name }); + tenant.members.set(id, new Set(["auth0|a"])); + tenant.orgConnections.set(id, ["con_db"]); + } + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [false], choose: ["labs"] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.organizationId, "org_2"); +}); + +test("a user in no organization can join an existing one after confirmation", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ choose: ["acme"], confirm: [true, false] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.organizationId, "org_1"); + assert.ok(tenant.members.get("org_1")?.has("auth0|a"), "the user was added as a member"); +}); + +test("declining to add a user to an organization adds nobody", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.orgConnections.set("org_1", ["con_db"]); + + await assert.rejects( + resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ choose: ["acme"], confirm: [false] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ), + BusinessAccessError, + ); + assert.equal(tenant.members.get("org_1")?.has("auth0|a") ?? false, false); +}); + +test("a user with no organization role can be given one after confirmation", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_admin", { id: "rol_admin", name: "Organization - Admin", permissions: MY_ORGANIZATION_SCOPES }); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ choose: ["Organization - Admin"], confirm: [true] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.hasOrganizationRole, true); + assert.deepEqual(tenant.orgRoles.get("org_1:auth0|a"), ["rol_admin"]); +}); + +test("declining the role assignment keeps going, and the user has no role", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_admin", { id: "rol_admin", name: "Organization - Admin", permissions: MY_ORGANIZATION_SCOPES }); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [false] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.hasOrganizationRole, false); + assert.equal(tenant.orgRoles.get("org_1:auth0|a"), undefined); +}); + +test("a role that lacks an organization scope is a warning that names the missing scopes", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_reader", { + id: "rol_reader", + name: "Organization - Reader", + permissions: ["read:my_org:details", "read:my_org:domains"], + }); + tenant.orgRoles.set("org_1:auth0|a", ["rol_reader"]); + + const captured = captureOutput(); + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: new Ui(captured.stream, false, false), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.hasOrganizationRole, false); + assert.match(captured.text(), /update:my_org:details/); + assert.match(captured.text(), /delete:my_org:domains/); +}); + +test("a role with read but no write scopes warns that changes cannot be saved", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + const reads = MY_ORGANIZATION_SCOPES.filter((scope) => scope.startsWith("read:")); + tenant.roles.set("rol_viewer", { id: "rol_viewer", name: "Organization - Viewer", permissions: reads }); + tenant.orgRoles.set("org_1:auth0|a", ["rol_viewer"]); + + const captured = captureOutput(); + await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: new Ui(captured.stream, false, false), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }, + ); + assert.match(captured.text(), /lacks write permissions/); + assert.doesNotMatch(captured.text(), /lacks read permissions/); +}); + +test("with no role that grants the missing permissions, the offer is to create the portal role, and declining keeps the user without one", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_reader", { + id: "rol_reader", + name: "Organization - Reader", + permissions: ["read:my_org:details", "read:my_org:domains"], + }); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [false] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.hasOrganizationRole, false); + assert.equal(tenant.orgRoles.get("org_1:auth0|a"), undefined); + assert.equal([...tenant.roles.values()].some((role) => role.name === "Universal Portals - Admin"), false); +}); + +test("accepting the offer creates Universal Portals - Admin with only the portal permissions and assigns it", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({ confirm: [true] }), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + const role = [...tenant.roles.values()].find((candidate) => candidate.name === "Universal Portals - Admin"); + assert.equal(access.hasOrganizationRole, true); + assert.ok(role, "the role was created"); + assert.deepEqual([...role.permissions].sort(), [...MY_ORGANIZATION_SCOPES].sort()); + assert.deepEqual(tenant.orgRoles.get("org_1:auth0|a"), [role.id]); +}); + +test("a role that grants every organization scope is accepted without an offer", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_admin", { id: "rol_admin", name: "Organization - Admin", permissions: MY_ORGANIZATION_SCOPES }); + tenant.orgRoles.set("org_1:auth0|a", ["rol_admin"]); + + const access = await resolveBusinessAccess( + { userQuery: "auth0|a" }, + { api: tenant, ui: ui(), prompt: scripted({}), interactive: true, tenantDomain: "acme.us.auth0.com" }, + ); + assert.equal(access.hasOrganizationRole, true); +}); + +test("without a terminal, a user who matches nobody stops and says why", async () => { + const tenant = new FakeTenant(); + await assert.rejects( + resolveBusinessAccess({ userQuery: "nobody@acme.com" }, { api: tenant, ui: ui(), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }), + (error: unknown) => { + assert.ok(error instanceof BusinessAccessError); + assert.match(error.message, /No user matches "nobody@acme.com"/); + return true; + }, + ); +}); + +test("without a terminal, an organization the user is not in stops with the command to add them", async () => { + const tenant = new FakeTenant(); + seedUser(tenant, "auth0|a", "jane@acme.com"); + tenant.organizations.set("org_1", { id: "org_1", name: "acme" }); + tenant.members.set("org_1", new Set(["auth0|a"])); + tenant.organizations.set("org_2", { id: "org_2", name: "labs" }); + + await assert.rejects( + resolveBusinessAccess( + { userQuery: "auth0|a", organization: "org_2" }, + { api: tenant, ui: ui(), prompt: scripted({}), interactive: false, tenantDomain: "acme.us.auth0.com" }, + ), + (error: unknown) => { + assert.ok(error instanceof BusinessAccessError); + assert.match(error.fixes.join(" "), /organizations\/org_2\/members/); + return true; + }, + ); +}); diff --git a/internal/cli/data/universal-portals/_source/test/capture-output.ts b/internal/cli/data/universal-portals/_source/test/capture-output.ts new file mode 100644 index 000000000..2e5f359f2 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/capture-output.ts @@ -0,0 +1,21 @@ +import { Writable } from "node:stream"; + +// A stream that keeps what is written to it, shaped like a pipe (not a terminal). +export interface CapturedOutput { + stream: NodeJS.WriteStream; + text(): string; +} + +export function captureOutput(): CapturedOutput { + let text = ""; + const writable = new Writable({ + write(chunk, _encoding, done) { + text += chunk; + done(); + }, + }); + return { + stream: Object.assign(writable, { isTTY: false, columns: 80 }) as unknown as NodeJS.WriteStream, + text: () => text, + }; +} diff --git a/internal/cli/data/universal-portals/_source/test/clack-prompter.test.ts b/internal/cli/data/universal-portals/_source/test/clack-prompter.test.ts new file mode 100644 index 000000000..886d49ca9 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/clack-prompter.test.ts @@ -0,0 +1,99 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { PassThrough, Writable } from "node:stream"; + +import { clackPrompter } from "../src/clack-prompter.js"; +import { CancelledError } from "../src/prompt.js"; + +// Keystrokes go in through a stream, and the prompt's output is captured. Keys are +// sent only after the question has been drawn, so the answers do not race the render. +function terminal() { + const input = new PassThrough(); + let drawn = ""; + const output = new Writable({ + write(chunk, _encoding, done) { + drawn += chunk; + done(); + }, + }); + Object.assign(output, { isTTY: false, columns: 80 }); + return { + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + async answer(question: string, keys: string[], run: () => Promise): Promise { + const result = run(); + await waitUntilDrawn(() => drawn.includes(question)); + for (const key of keys) input.write(key); + return result; + }, + }; +} + +async function waitUntilDrawn(drawn: () => boolean): Promise { + while (!drawn()) await new Promise((resolve) => setImmediate(resolve)); +} + +const ENTER = "\r"; +const DOWN = "\x1b[B"; +const CTRL_C = "\x03"; + +test("a typed answer is returned trimmed", async () => { + const term = terminal(); + const prompter = clackPrompter(term.input, term.output); + + const answer = await term.answer("Slug", [" acme-account ", ENTER], () => prompter.ask("Slug")); + + assert.equal(answer, "acme-account"); +}); + +test("an empty answer returns the default", async () => { + const term = terminal(); + const prompter = clackPrompter(term.input, term.output); + + const answer = await term.answer("Slug", [ENTER], () => prompter.ask("Slug", "acme-account-2")); + + assert.equal(answer, "acme-account-2"); +}); + +test("an empty answer with no default is an empty string, which skips an optional question", async () => { + const term = terminal(); + const prompter = clackPrompter(term.input, term.output); + + const answer = await term.answer("Test user", [ENTER], () => prompter.ask("Test user")); + + assert.equal(answer, ""); +}); + +test("confirm returns the default on Enter, and the other answer when pressed", async () => { + const term = terminal(); + const prompter = clackPrompter(term.input, term.output); + + const defaulted = await term.answer("Assign a role?", [ENTER], () => prompter.confirm("Assign a role?", true)); + assert.equal(defaulted, true); + + const term2 = terminal(); + const prompter2 = clackPrompter(term2.input, term2.output); + const declined = await term2.answer("Assign a role?", ["n", ENTER], () => prompter2.confirm("Assign a role?", true)); + assert.equal(declined, false); +}); + +test("choose returns the value of the option the user picks", async () => { + const term = terminal(); + const prompter = clackPrompter(term.input, term.output); + const options = [ + { label: "Consumer", hint: "end users", value: "consumer" }, + { label: "Business", hint: "organization admins", value: "business" }, + ]; + + const picked = await term.answer("Template", [DOWN, ENTER], () => prompter.choose("Template", options)); + + assert.equal(picked, "business"); +}); + +test("Ctrl+C at a question rejects with CancelledError, for every question type", async () => { + const term = terminal(); + const prompter = clackPrompter(term.input, term.output); + + await assert.rejects(term.answer("Slug", [CTRL_C], () => prompter.ask("Slug")), CancelledError); + await assert.rejects(term.answer("Open?", [CTRL_C], () => prompter.confirm("Open?", true)), CancelledError); +}); diff --git a/internal/cli/data/universal-portals/_source/test/cli.test.ts b/internal/cli/data/universal-portals/_source/test/cli.test.ts new file mode 100644 index 000000000..b617af160 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -0,0 +1,404 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { ApiError, FetchLike } from "../src/api.js"; +import { main, IoStreams } from "../src/cli.js"; +import { missingFlags, parseSetupArgs, UsageError } from "../src/args.js"; +import { toPortalSlug } from "../src/slug.js"; +import type { Prompter } from "../src/prompt.js"; +import { captureOutput } from "./capture-output.js"; +import { FakeTenant } from "./fake-tenant.js"; + +const DOMAIN = "acme.us.auth0.com"; +const ENV = { + AUTH0_CLI_PROXY_URL: "https://127.0.0.1:4433", + AUTH0_CLI_PROXY_TOKEN: "proxy-token", + AUTH0_CLI_TENANT_DOMAIN: DOMAIN, +}; + +// Serves the fake tenant over the same HTTP shape the proxy uses, so the CLI's +// real HTTP client is exercised. +function fetchFor(tenant: FakeTenant): FetchLike { + return async (url, init) => { + const parsed = new URL(url); + const path = parsed.pathname.replace(/^\/api\/v2\//, "") + parsed.search; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + try { + const result = await tenant.request(init.method as "GET", path, body); + return new Response(result === undefined ? "" : JSON.stringify(result), { status: 200 }); + } catch (error) { + if (error instanceof ApiError) { + return new Response(JSON.stringify(error.body), { status: error.status }); + } + throw error; + } + }; +} + +function captureIo(fetchImpl: FetchLike, isTTY = false): { io: IoStreams; stdout: () => string; stderr: () => string } { + let out = ""; + const captureErr = captureOutput(); + const io: IoStreams = { + stdout: { write: (text) => (out += text) }, + stderr: captureErr.stream, + stdinStream: process.stdin, + isTTY, + fetchImpl, + }; + return { io, stdout: () => out, stderr: () => captureErr.text() }; +} + +// Polls until the condition holds, or resolves false after the timeout. +async function waitFor(condition: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return true; +} + +test("parses the setup flags", () => { + const args = parseSetupArgs([ + "setup", "--template", "business", "--name", "Acme Admin", + "--user", "jane@acme.com", "--json", + ]); + assert.equal(args.command, "setup"); + assert.equal(args.template, "business"); + assert.equal(args.name, "Acme Admin"); + assert.equal(args.user, "jane@acme.com"); + assert.equal(args.json, true); +}); + +test("rejects an unknown template and an unknown command", () => { + assert.throws(() => parseSetupArgs(["setup", "--template", "both"]), UsageError); + assert.throws(() => parseSetupArgs(["publish"]), /Unknown command "publish"/); +}); + +test("audit takes exactly one portal ID, plus --organization and --strict", () => { + const args = parseSetupArgs(["audit", "up_123", "--organization", "org_1", "--strict"]); + assert.equal(args.command, "audit"); + assert.equal(args.portalId, "up_123"); + assert.equal(args.organization, "org_1"); + assert.equal(args.strict, true); + assert.throws(() => parseSetupArgs(["audit"]), /Usage: auth0 universal-portals audit/); +}); + +test("no command prints help", () => { + assert.equal(parseSetupArgs([]).command, "help"); + assert.equal(parseSetupArgs(["--help"]).command, "help"); +}); + +test("missingFlags names what a non-interactive run still needs", () => { + const args = parseSetupArgs(["setup", "--template", "business"]); + assert.deepEqual(missingFlags(args), ["--name", "--user"]); + assert.deepEqual(missingFlags(parseSetupArgs(["setup", "--template", "consumer", "--name", "x"])), []); +}); + +test("the default slug is a kebab-case version of the name", () => { + assert.equal(toPortalSlug("Acme Admin"), "acme-admin"); + assert.equal(toPortalSlug(" Café & Co. 2! "), "caf-co-2"); + assert.equal(toPortalSlug("123 portal"), "portal"); + assert.equal(toPortalSlug("!!!"), "my-portal"); +}); + +test("--help prints usage and does not touch the tenant", async () => { + const tenant = new FakeTenant(); + const { io, stdout } = captureIo(fetchFor(tenant)); + const code = await main(["--help"], ENV, io); + assert.equal(code, 0); + assert.match(stdout(), /Usage: auth0 universal-portals setup/); + assert.equal(tenant.calls.length, 0); +}); + +test("interactive questions are drawn on stderr, never stdout", async () => { + const keys = new PassThrough(); + const { io, stdout, stderr } = captureIo(fetchFor(new FakeTenant()), true); + io.stdinStream = keys as unknown as NodeJS.ReadStream; + + const run = main(["setup"], ENV, io); + const drawn = await waitFor(() => stderr().includes("Which kind of portal"), 2000); + assert.ok(drawn, "the first question was not drawn on stderr"); + keys.write("\x03"); + + assert.equal(await run, 130); + assert.equal(stdout(), ""); +}); + +// Answers each question once it is drawn on stderr, in the order given. +async function answerInOrder(keys: PassThrough, stderr: () => string, answers: [question: string, keys: string][]) { + for (const [question, input] of answers) { + if (!(await waitFor(() => stderr().includes(question), 2000))) { + throw new Error(`question not drawn: ${question}`); + } + keys.write(input); + } +} + +function seedBusinessTenant(tenant: FakeTenant): void { + tenant.users.set("auth0|jane", { user_id: "auth0|jane", email: "jane@acme.com", name: "Jane" }); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); +} + +test("cancelling after an organization was created says what was kept", async () => { + const tenant = new FakeTenant(); + seedBusinessTenant(tenant); + const keys = new PassThrough(); + const { io, stderr } = captureIo(fetchFor(tenant), true); + io.stdinStream = keys as unknown as NodeJS.ReadStream; + + const run = main( + ["setup", "--template", "business", "--name", "Acme Admin", "--slug", "acme-admin", "--user", "auth0|jane"], + ENV, + io, + ); + await answerInOrder(keys, stderr, [ + ["Choose the organization to test the portal in", "\r"], + ["Organization display name", "Acme Corp\r"], + ["Organization name", "\r"], + ["enable a database connection", "\r"], + ["No role in this tenant", "\x03"], + ]); + + assert.equal(await run, 130); + assert.equal(tenant.organizations.size, 1); + assert.match(stderr(), /Nothing was rolled back/); + assert.doesNotMatch(stderr(), /Nothing was created/); +}); + +test("cancelling before any write says nothing was created", async () => { + const tenant = new FakeTenant(); + seedBusinessTenant(tenant); + const keys = new PassThrough(); + const { io, stderr } = captureIo(fetchFor(tenant), true); + io.stdinStream = keys as unknown as NodeJS.ReadStream; + + const run = main( + ["setup", "--template", "business", "--name", "Acme Admin", "--slug", "acme-admin", "--user", "auth0|jane"], + ENV, + io, + ); + await answerInOrder(keys, stderr, [ + ["Choose the organization to test the portal in", "\r"], + ["Organization display name", "\x03"], + ]); + + assert.equal(await run, 130); + assert.equal(tenant.organizations.size, 0); + assert.match(stderr(), /Nothing was created/); +}); + +const LEGACY_LINK = { type: "sidebar:component:auth0:internal_link", config: { label: "Profile", to: "profile", icon: "user" } }; + +// A consumer portal from setup, with its application. +async function tenantWithPortal(): Promise<{ tenant: FakeTenant; portalId: string; clientId: string }> { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const { io } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--no-input", "--template", "consumer", "--name", "Acme Account", "--slug", "acme-account"], ENV, io); + assert.equal(code, 0); + const portalId = [...tenant.portals.keys()][0]; + return { tenant, portalId, clientId: tenant.portals.get(portalId)!.client.client_id }; +} + +// Answers the fix menu by position (0 all, 1 choose, 2 skip) and keeps the checked +// errors whose IDs are listed. +function fixMenu(position: number, keep: string[] = []): Prompter { + return { + ask: async () => { + throw new Error("unexpected question"); + }, + confirm: async () => { + throw new Error("unexpected question"); + }, + choose: async (_question: string, options: { value: T }[]): Promise => options[position].value, + checklist: async (_question: string, items: { label: string; value: T }[]): Promise => + items.filter((item) => keep.includes(item.label.split(" ")[0])).map((item) => item.value), + }; +} + +async function auditInTerminal(tenant: FakeTenant, portalId: string, prompter: Prompter): Promise<{ code: number; stderr: string }> { + const { io, stderr } = captureIo(fetchFor(tenant), true); + io.prompter = prompter; + const code = await main(["audit", portalId], ENV, io); + return { code, stderr: stderr() }; +} + +test("the fix menu's 'fix all' applies every error and the portal passes the audit", async () => { + const { tenant, portalId } = await tenantWithPortal(); + tenant.portals.get(portalId)!.navigation.sidebar.components = [LEGACY_LINK]; + + const { code } = await auditInTerminal(tenant, portalId, fixMenu(0)); + + assert.equal(code, 0); + const links = tenant.portals.get(portalId)!.navigation.sidebar.components; + assert.ok(links.every((link: { type: string }) => link.type.endsWith(":v2"))); +}); + +test("choosing errors applies only the checked ones, and a later error is asked about again", async () => { + const { tenant, portalId, clientId } = await tenantWithPortal(); + tenant.portals.get(portalId)!.navigation.sidebar.components = [LEGACY_LINK]; + tenant.portals.get(portalId)!.client = undefined; + tenant.clients.delete(clientId); + const clientsBefore = tenant.clients.size; + + const { code } = await auditInTerminal(tenant, portalId, fixMenu(1, ["P003"])); + + assert.equal(code, 1, "the unchecked application error is still reported"); + assert.ok(tenant.portals.get(portalId)!.navigation.sidebar.components.every((link: { type: string }) => link.type.endsWith(":v2"))); + assert.equal(tenant.portals.get(portalId)!.client, undefined, "the unchecked error was not fixed"); + assert.equal(tenant.clients.size, clientsBefore, "no application was created"); +}); + +test("skipping the fixes leaves the portal as it is", async () => { + const { tenant, portalId } = await tenantWithPortal(); + tenant.portals.get(portalId)!.navigation.sidebar.components = [LEGACY_LINK]; + + const { code } = await auditInTerminal(tenant, portalId, fixMenu(2)); + + assert.equal(code, 1, "the error is still reported"); + assert.equal(tenant.portals.get(portalId)!.navigation.sidebar.components[0].type, LEGACY_LINK.type); +}); + +test("a failure after resources were created says that a rerun reuses them", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + tenant.failNext("POST portals", 500); + const { io, stderr } = captureIo(fetchFor(tenant)); + + const code = await main(["setup", "--no-input", "--template", "consumer", "--name", "Acme Account", "--slug", "acme-account"], ENV, io); + + assert.equal(code, 1); + assert.ok(tenant.clients.size > 0, "resources were created before the failure"); + assert.match(stderr(), /Run the same command again to resume\. Existing resources are reused\./); +}); + +test("a slug taken during setup keeps its own message, without the rerun hint", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + tenant.failNext("POST portals", 409); + const { io, stderr } = captureIo(fetchFor(tenant)); + + const code = await main(["setup", "--no-input", "--template", "consumer", "--name", "Acme Account", "--slug", "acme-account"], ENV, io); + + assert.equal(code, 1); + assert.match(stderr(), /taken while setup was running/); + assert.doesNotMatch(stderr(), /Existing resources are reused/); +}); + +// The recorded --json output of a consumer setup. Set UPDATE_GOLDEN=1 to record it again +// after an intended change to the output, and review the diff. +const CONSUMER_SETUP_JSON = fileURLToPath(new URL("../../test/fixtures/consumer-setup.json", import.meta.url)); + +test("a consumer setup prints the same JSON, byte for byte, as the recorded output", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const { io, stdout } = captureIo(fetchFor(tenant)); + + const code = await main( + ["setup", "--no-input", "--json", "--template", "consumer", "--name", "Acme Account", "--slug", "acme-account"], + ENV, + io, + ); + + assert.equal(code, 0); + if (process.env.UPDATE_GOLDEN === "1") writeFileSync(CONSUMER_SETUP_JSON, stdout()); + assert.equal(stdout(), readFileSync(CONSUMER_SETUP_JSON, "utf8")); +}); + +test("a non-interactive run without the required flags stops before any API call", async () => { + const tenant = new FakeTenant(); + const { io, stderr } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--no-input", "--template", "consumer"], ENV, io); + + assert.equal(code, 2); + assert.match(stderr(), /--name/); + assert.equal(tenant.calls.length, 0); +}); + +test("a run outside the CLI names the variables it is missing", async () => { + const { io, stderr } = captureIo(fetchFor(new FakeTenant())); + const code = await main(["setup", "--no-input", "--template", "consumer", "--name", "x"], {}, io); + + assert.equal(code, 1); + assert.match(stderr(), /AUTH0_CLI_PROXY_URL, AUTH0_CLI_PROXY_TOKEN, AUTH0_CLI_TENANT_DOMAIN/); +}); + +test("a non-interactive consumer run prints the result as JSON and never the full secret", async () => { + const tenant = new FakeTenant(); + const { io, stdout, stderr } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--no-input", "--json", "--template", "consumer", "--name", "Acme Account"], + ENV, + io, + ); + + assert.equal(code, 0); + const result = JSON.parse(stdout()); + assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-account`); + const secret = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Account"))?.client_secret; + assert.ok(secret); + assert.ok(!stdout().includes(secret) && !stderr().includes(secret), "the full secret must not be printed"); +}); + +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"); + const { io, stdout, stderr } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--json", "--template", "consumer", "--name", "Acme Account"], + ENV, + io, + ); + + assert.equal(code, 1); + assert.equal(stdout(), ""); + const envelope = JSON.parse(stderr()); + assert.equal(envelope.error, "missing_scopes"); + assert.deepEqual(envelope.fixes, ["auth0 login --scopes create:portals"]); +}); + +test("a Business run that is missing its user stops with a usage error", async () => { + const { io, stderr } = captureIo(fetchFor(new FakeTenant())); + const code = await main( + ["setup", "--no-input", "--template", "business", "--name", "Acme Admin"], + ENV, + io, + ); + assert.equal(code, 2); + assert.match(stderr(), /--user/); +}); + +test("the entry point runs when started through a symlink, as npx starts a plugin", () => { + const entry = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + const link = join(mkdtempSync(join(tmpdir(), "up-bin-")), "universal-portals"); + symlinkSync(entry, link); + + const output = execFileSync(process.execPath, [link, "--help"], { encoding: "utf8" }); + assert.match(output, /Usage: auth0 universal-portals setup/); +}); + +test("the CLI's global flags are accepted and --tenant is returned for the check", () => { + const args = parseSetupArgs(["setup", "--tenant", "acme.us.auth0.com", "--debug", "--no-color", "--template", "consumer", "--name", "x"]); + assert.equal(args.tenant, "acme.us.auth0.com"); + assert.equal(args.command, "setup"); +}); + +test("a --tenant that differs from the CLI's tenant stops the run before any API call", async () => { + const tenant = new FakeTenant(); + const { io, stderr } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--tenant", "other.us.auth0.com", "--no-input", "--template", "consumer", "--name", "x"], + ENV, + io, + ); + assert.equal(code, 2); + assert.match(stderr(), /does not match the tenant/); + assert.equal(tenant.calls.length, 0); +}); diff --git a/internal/cli/data/universal-portals/_source/test/dashboard.test.ts b/internal/cli/data/universal-portals/_source/test/dashboard.test.ts new file mode 100644 index 000000000..40acd6717 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/dashboard.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { applicationUrl, tenantLocation } from "../src/dashboard.js"; + +test("a tenant with a region code maps to its manage host, region and name", () => { + assert.deepEqual(tenantLocation("acme.us.auth0.com"), { + base: "https://manage.auth0.com", + region: "us", + name: "acme", + }); +}); + +test("a tenant without a region code is treated as us", () => { + assert.deepEqual(tenantLocation("dev-tti06f6y.auth0.com"), { + base: "https://manage.auth0.com", + region: "us", + name: "dev-tti06f6y", + }); +}); + +test("the application link opens the application's settings in the dashboard", () => { + assert.equal( + applicationUrl("acme.eu.auth0.com", "abc123"), + "https://manage.auth0.com/dashboard/eu/acme/applications/abc123/settings", + ); +}); diff --git a/internal/cli/data/universal-portals/_source/test/errors.test.ts b/internal/cli/data/universal-portals/_source/test/errors.test.ts new file mode 100644 index 000000000..057d2c6ed --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/errors.test.ts @@ -0,0 +1,31 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { ApiError } from "../src/api.js"; +import { UsageError } from "../src/args.js"; +import { describeError } from "../src/errors.js"; +import { BusinessAccessError } from "../src/setup/business-types.js"; +import { FeatureNotEnabledError } from "../src/setup/preflight.js"; +import { MissingScopesError, SlugRaceError } from "../src/setup/run.js"; + +test("each error reports its own code, and the fixes it names", () => { + assert.deepEqual(describeError(new UsageError("bad flag")), { code: "usage", message: "bad flag", details: {} }); + assert.deepEqual(describeError(new SlugRaceError("acme")).code, "slug_race"); + assert.deepEqual(describeError(new FeatureNotEnabledError("acme.us.auth0.com")).code, "feature_not_enabled"); + assert.deepEqual(describeError(new BusinessAccessError("no user", ["Rerun with --user."])).details, { + fixes: ["Rerun with --user."], + }); + assert.deepEqual(describeError(new MissingScopesError(["read:clients"], [])).details, { + fixes: ["auth0 login --scopes read:clients"], + }); +}); + +test("an error with no fixes leaves fixes out of the JSON", () => { + const { details } = describeError(new MissingScopesError([], [])); + assert.equal("fixes" in details, false); +}); + +test("an API error keeps the code the API sent", () => { + const error = new ApiError(403, "insufficient_scope", "Insufficient scope", {}); + assert.deepEqual(describeError(error), { code: "insufficient_scope", message: "Insufficient scope", details: { status: 403 } }); +}); diff --git a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts new file mode 100644 index 000000000..850f3c4ce --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts @@ -0,0 +1,300 @@ +import { ApiClient, ApiError, HttpMethod, toApiError } from "../src/api.js"; + +// The shape of each ID the Management API returns. IDs come from a counter, so a +// run is repeatable and its output can be compared byte for byte. +const ID_SHAPES: Record = { + rs: { prefix: "", length: 24 }, + cli: { prefix: "", length: 32 }, + cgr: { prefix: "cgr_", length: 16 }, +}; +const DEFAULT_ID_SHAPE = { prefix: "", length: 22 }; + +// Like the real list endpoints: with page or per_page in the query, a list comes back +// one page at a time. Page is 0-based and per_page defaults to 50 (unverified on the +// live API, which is why the tests use more items than that). +function pageOf(method: HttpMethod, rawPath: string, result: unknown): unknown { + const params = new URLSearchParams(rawPath.split("?")[1] ?? ""); + if (method !== "GET" || (!params.has("page") && !params.has("per_page"))) return result; + const page = Number(params.get("page") ?? 0); + const perPage = Number(params.get("per_page") ?? 50); + const slice = (items: unknown[]) => items.slice(page * perPage, (page + 1) * perPage); + if (Array.isArray(result)) return slice(result); + if (result && typeof result === "object") { + const key = Object.keys(result).find((name) => Array.isArray((result as Record)[name])); + if (key) return { ...result, [key]: slice((result as Record)[key]) }; + } + return result; +} + +// In-memory stand-in for the Management API routes setup uses. It enforces +// the rules setup depends on: duplicate resource server identifiers and +// portal slugs conflict, and a client secret is only returned by the +// single-client read. +export class FakeTenant implements ApiClient { + readonly clients = new Map>(); + readonly grants = new Map>(); + readonly resourceServers = new Map>(); + readonly vaults = new Map>(); + readonly forms = new Map>(); + readonly portals = new Map>(); + readonly users = new Map>(); + readonly organizations = new Map>(); + // organization id -> user ids that are members + readonly members = new Map>(); + // organization id -> connection ids enabled for it + readonly orgConnections = new Map(); + // "orgId:userId" -> role ids assigned in that organization + readonly orgRoles = new Map(); + readonly roles = new Map>(); + readonly connections = new Map>(); + customDomain?: { domain: string; status: string }; + private denials = new Map(); + readonly calls: string[] = []; + private nextId = 1; + private failures = new Map(); + + // Makes the next call to "METHOD path" fail with the given status, once. + failNext(key: string, status = 500): void { + this.failures.set(key, status); + } + + // Every call to "METHOD path" fails with the Management API's scope error. + deny(key: string, message: string): void { + this.denials.set(key, message); + } + + async request(method: HttpMethod, path: string, body?: unknown): Promise { + const key = `${method} ${path.split("?")[0]}`; + this.calls.push(`${method} ${path}`); + const denial = this.denials.get(key); + if (denial !== undefined) { + throw new ApiError(403, "insufficient_scope", denial, { statusCode: 403, message: denial, errorCode: "insufficient_scope" }); + } + // Like the real API, a create with no body is a validation error, not a + // success. Preflight relies on this to tell a present scope from a missing one. + if (method === "POST" && body !== undefined && Object.keys(body as object).length === 0) { + throw toApiError(400, { message: "Payload validation error: 'Missing required property'" }); + } + const failStatus = this.failures.get(key); + if (failStatus !== undefined) { + this.failures.delete(key); + throw toApiError(failStatus, { message: "simulated failure" }); + } + return pageOf(method, path, this.route(method, path, body)) as T; + } + + private id(prefix: string): string { + this.nextId += 1; + const shape = ID_SHAPES[prefix] ?? { ...DEFAULT_ID_SHAPE, prefix: `${prefix}_` }; + return shape.prefix + String(this.nextId).padStart(shape.length, "0"); + } + + private route(method: HttpMethod, rawPath: string, body: any): unknown { + const [path, query = ""] = rawPath.split("?"); + const parts = path.split("/").map((part) => decodeURIComponent(part)); + + if (method === "GET" && path === "resource-servers") { + return { resource_servers: [...this.resourceServers.values()] }; + } + if (method === "POST" && path === "resource-servers") { + if ([...this.resourceServers.values()].some((server) => server.identifier === body.identifier)) { + throw toApiError(409, { message: "The resource server identifier already exists" }); + } + const server = { id: this.id("rs"), ...body }; + this.resourceServers.set(server.id, server); + return server; + } + + if (method === "GET" && path === "clients") { + return { clients: [...this.clients.values()].map(({ client_secret: _secret, ...rest }) => rest) }; + } + if (method === "POST" && path === "clients") { + const client = { + client_id: this.id("cli"), + client_secret: String(this.nextId).padStart(64, "s"), + ...body, + }; + this.clients.set(client.client_id, client); + return client; + } + if (parts[0] === "clients" && parts.length === 2) { + const client = this.clients.get(parts[1]); + if (!client) throw toApiError(404, { message: "not found" }); + if (method === "GET") { + return query.includes("client_secret") ? { client_id: client.client_id, client_secret: client.client_secret } : client; + } + if (method === "PATCH") { + Object.assign(client, body); + return client; + } + } + + if (method === "GET" && path === "client-grants") { + const clientId = new URLSearchParams(query).get("client_id"); + return [...this.grants.values()].filter((grant) => grant.client_id === clientId); + } + if (method === "POST" && path === "client-grants") { + const grant = { id: this.id("cgr"), ...body }; + this.grants.set(grant.id, grant); + return grant; + } + if (method === "DELETE" && parts[0] === "client-grants" && parts.length === 2) { + if (!this.grants.has(parts[1])) throw toApiError(404, { message: "The grant does not exist" }); + this.grants.delete(parts[1]); + return undefined; + } + if (method === "PATCH" && parts[0] === "client-grants" && parts.length === 2) { + Object.assign(this.grants.get(parts[1]) ?? {}, body); + return this.grants.get(parts[1]); + } + + if (method === "GET" && path === "flows/vault/connections") { + return { connections: [...this.vaults.values()] }; + } + if (method === "POST" && path === "flows/vault/connections") { + const vault = { id: this.id("ac"), name: body.name, setup: body.setup }; + this.vaults.set(vault.id, vault); + return vault; + } + if (method === "PATCH" && parts[0] === "flows" && parts[1] === "vault" && parts[2] === "connections" && parts.length === 4) { + const vault = this.vaults.get(parts[3]); + if (!vault) throw toApiError(404, { message: "Connection not found" }); + vault.setup = body.setup; + vault.minted_with_scopes = this.scopesFor(vault.setup.client_id); + return vault; + } + + if (method === "GET" && path === "forms") { + return [...this.forms.values()]; + } + if (method === "POST" && path === "forms/import") { + const form = { id: this.id("ap"), name: body.form.name, document: body, flowId: this.id("af") }; + this.forms.set(form.id, form); + return { + form: { id: form.id, name: form.name }, + flows: { "#FLOW-1#": { id: form.flowId, name: "Flow" } }, + }; + } + if (method === "GET" && parts[0] === "forms" && parts.length === 2) { + if (!/^ap_[A-Za-z0-9]{22}$/.test(parts[1])) throw toApiError(400, { message: "The provided input does not satisfy the expected schema" }); + const form = this.forms.get(parts[1]); + if (!form) throw toApiError(404, { message: "Form not found" }); + return { id: form.id, name: form.name, nodes: [{ type: "FLOW", config: { flow_id: form.flowId } }] }; + } + if (method === "POST" && path === "forms/export") { + const form = this.forms.get(body.form_id); + if (!form) throw toApiError(404, { message: "Form not found" }); + return { form: { id: form.id, name: form.name }, flows: { "#FLOW-1#": { id: form.flowId, name: "Flow" } } }; + } + + if (method === "GET" && path === "portals") { + return { portals: [...this.portals.values()].map(({ id, slug, name }) => ({ id, slug, name })) }; + } + if (method === "GET" && parts[0] === "portals" && parts.length === 2) { + const portal = this.portals.get(parts[1]); + if (!portal) throw toApiError(404, { message: "not found" }); + return portal; + } + if (method === "PATCH" && parts[0] === "portals" && parts.length === 2) { + const portal = this.portals.get(parts[1]); + if (!portal) throw toApiError(404, { message: "not found" }); + Object.assign(portal, body); + return portal; + } + if (method === "POST" && path === "portals") { + if ([...this.portals.values()].some((portal) => portal.slug === body.slug)) { + throw new ApiError(409, "portal_conflict", "Slug already in use", {}); + } + const portal = { id: this.id("up"), ...body, client: { client_id: body.client.client_id } }; + this.portals.set(portal.id, portal); + return portal; + } + + if (method === "GET" && path === "custom-domains/default") { + if (!this.customDomain) throw toApiError(404, { message: "Not Found" }); + return this.customDomain; + } + + // The search matches a phrase anywhere in the email, like the real search. + if (method === "GET" && path === "users") { + const phrase = /^email:"(.*)"$/.exec(new URLSearchParams(query).get("q") ?? "")?.[1]; + const unescaped = phrase?.replace(/\\(.)/g, "$1") ?? ""; + return [...this.users.values()].filter((user) => String(user.email ?? "").includes(unescaped)); + } + if (method === "GET" && parts[0] === "users" && parts.length === 2) { + const user = this.users.get(parts[1]); + if (!user) throw toApiError(404, { message: "The user does not exist." }); + return user; + } + if (method === "GET" && parts[0] === "users" && parts[2] === "organizations") { + return [...this.organizations.values()].filter((org) => this.members.get(org.id)?.has(parts[1])); + } + if (method === "GET" && path === "organizations") { + return [...this.organizations.values()]; + } + if (method === "POST" && path === "organizations") { + if ([...this.organizations.values()].some((org) => org.name === body.name)) { + throw toApiError(409, { message: "The organization name is already in use." }); + } + const org = { id: this.id("org"), name: body.name, display_name: body.display_name }; + this.organizations.set(org.id, org); + return org; + } + if (method === "GET" && parts[0] === "clients" && parts[2] === "connections") { + return { connections: [...this.connections.values()] }; + } + if (method === "GET" && path === "connections") { + return [...this.connections.values()]; + } + if (method === "POST" && parts[0] === "organizations" && parts[2] === "enabled_connections") { + const list = this.orgConnections.get(parts[1]) ?? []; + this.orgConnections.set(parts[1], [...list, body.connection_id]); + return {}; + } + if (method === "POST" && parts[0] === "organizations" && parts[2] === "members" && parts.length === 3) { + const members = this.members.get(parts[1]) ?? new Set(); + for (const userId of body.members) members.add(userId); + this.members.set(parts[1], members); + return {}; + } + if (method === "GET" && parts[0] === "organizations" && parts[2] === "enabled_connections") { + return (this.orgConnections.get(parts[1]) ?? []).map((connection_id) => ({ connection_id })); + } + if (parts[0] === "organizations" && parts[2] === "members" && parts[4] === "roles") { + const key = `${parts[1]}:${parts[3]}`; + if (method === "GET") { + return (this.orgRoles.get(key) ?? []).map((id) => this.roles.get(id)).filter(Boolean); + } + if (method === "POST") { + this.orgRoles.set(key, [...(this.orgRoles.get(key) ?? []), ...body.roles]); + return {}; + } + } + if (method === "POST" && path === "roles") { + const role = { id: this.id("rol"), name: body.name, description: body.description, permissions: [] as string[] }; + this.roles.set(role.id, role); + return role; + } + if (method === "POST" && parts[0] === "roles" && parts[2] === "permissions") { + const role = this.roles.get(parts[1]); + if (!role) throw toApiError(404, { message: "The role does not exist" }); + role.permissions = [...(role.permissions ?? []), ...body.permissions.map((permission: { permission_name: string }) => permission.permission_name)]; + return {}; + } + if (method === "GET" && parts[0] === "roles" && parts[2] === "permissions") { + const role = this.roles.get(parts[1]); + return { permissions: (role?.permissions ?? []).map((permission_name: string) => ({ permission_name })) }; + } + if (method === "GET" && path === "roles") { + return [...this.roles.values()]; + } + + throw toApiError(404, { message: `FakeTenant has no route for ${method} ${path}` }); + } + + // Scopes the vault connection's token would carry, taken from its grant. + private scopesFor(clientId: string): string[] { + const grant = [...this.grants.values()].find((g) => g.client_id === clientId && g.audience?.endsWith("/api/v2/")); + return grant?.scope ?? []; + } +} diff --git a/internal/cli/data/universal-portals/_source/test/fixtures/consumer-setup.json b/internal/cli/data/universal-portals/_source/test/fixtures/consumer-setup.json new file mode 100644 index 000000000..e4244d5dd --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/fixtures/consumer-setup.json @@ -0,0 +1,83 @@ +{ + "template": "consumer", + "portal": { + "id": "up_0000000000000000000014", + "slug": "acme-account", + "url": "https://acme.us.auth0.com/portals/acme-account", + "status": "created" + }, + "application": { + "clientId": "00000000000000000000000000000003", + "status": "created" + }, + "vault": { + "id": "ac_0000000000000000000007", + "status": "created" + }, + "forms": { + "personalInfo": { + "id": "ap_0000000000000000000008", + "status": "created" + }, + "communicationPreferences": { + "id": "ap_0000000000000000000010", + "status": "created" + }, + "privacyConsent": { + "id": "ap_0000000000000000000012", + "status": "created" + } + }, + "resources": [ + { + "type": "resource_server", + "id": "000000000000000000000002", + "status": "created" + }, + { + "type": "application", + "id": "00000000000000000000000000000003", + "status": "created" + }, + { + "type": "client_grant", + "id": "cgr_0000000000000004", + "status": "created" + }, + { + "type": "forms_vault_client", + "id": "00000000000000000000000000000005", + "status": "created" + }, + { + "type": "forms_vault_grant", + "id": "cgr_0000000000000006", + "status": "created" + }, + { + "type": "forms_vault", + "id": "ac_0000000000000000000007", + "status": "created" + }, + { + "type": "form", + "id": "ap_0000000000000000000008", + "status": "created" + }, + { + "type": "form", + "id": "ap_0000000000000000000010", + "status": "created" + }, + { + "type": "form", + "id": "ap_0000000000000000000012", + "status": "created" + }, + { + "type": "portal", + "id": "up_0000000000000000000014", + "status": "created" + } + ] +} diff --git a/internal/cli/data/universal-portals/_source/test/forms.test.ts b/internal/cli/data/universal-portals/_source/test/forms.test.ts new file mode 100644 index 000000000..65311bf84 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/forms.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { buildPortalForms, FORM_NAMES, type FormKey } from "../src/forms.js"; + +const VAULT = "ac_test_vault"; + +function updateAction(document: Record): Record { + const flows = document.flows as Record[] }>; + return flows["#FLOW-1#"].actions[0]; +} + +test("builds the three portal forms with their beta names", () => { + const forms = buildPortalForms(VAULT); + assert.deepEqual( + forms.map((form) => form.key).sort(), + ["communicationPreferences", "personalInfo", "privacyConsent"], + ); + for (const form of forms) { + assert.equal(form.name, FORM_NAMES[form.key]); + } +}); + +test("every form routes its user update through the vault connection", () => { + for (const form of buildPortalForms(VAULT)) { + const action = updateAction(form.document); + assert.equal(action.action, "UPDATE_USER"); + assert.equal((action.params as { connection_id: string }).connection_id, "#CONN-1#"); + assert.deepEqual(form.document.connections, { "#CONN-1#": { id: VAULT } }); + } +}); + +test("each form writes its answers to the metadata it is meant to update", () => { + const expected: Record = { + personalInfo: "user_metadata", + communicationPreferences: "app_metadata", + privacyConsent: "app_metadata", + }; + for (const form of buildPortalForms(VAULT)) { + const changes = (updateAction(form.document).params as { changes: Record }).changes; + assert.ok(expected[form.key] in changes, `${form.key} should write ${expected[form.key]}`); + } +}); + +test("every form has a start node and a step that links to its flow", () => { + for (const form of buildPortalForms(VAULT)) { + const nodes = (form.document.form as { nodes: { id: string; type: string; config?: { next_node?: string } }[] }).nodes; + const step = nodes.find((node) => node.type === "STEP"); + const flow = nodes.find((node) => node.type === "FLOW"); + assert.ok(step && flow, `${form.key} is missing a step or flow node`); + assert.equal(step.config?.next_node, flow.id); + } +}); diff --git a/internal/cli/data/universal-portals/_source/test/lockfile.test.ts b/internal/cli/data/universal-portals/_source/test/lockfile.test.ts new file mode 100644 index 000000000..2b2a25df0 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/lockfile.test.ts @@ -0,0 +1,20 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +// The lockfile is committed to a public repository, so every package URL must be the public +// registry. Installs still work behind a mirror, because npm swaps the host it is configured for. +test("every package in the lockfile resolves from the public registry", () => { + const lockfile = JSON.parse(readFileSync(new URL("../../package-lock.json", import.meta.url), "utf8")); + const resolved = Object.entries<{ resolved?: string }>(lockfile.packages) + .filter(([name, entry]) => name !== "" && entry.resolved !== undefined) + .map(([name, entry]) => ({ name, url: entry.resolved as string })); + + assert.ok(resolved.length > 0, "the lockfile lists no packages"); + const elsewhere = resolved.filter(({ url }) => !url.startsWith("https://registry.npmjs.org/")); + assert.deepEqual( + elsewhere.map(({ name }) => name), + [], + "these packages resolve from a host other than the public registry", + ); +}); diff --git a/internal/cli/data/universal-portals/_source/test/portal-model.test.ts b/internal/cli/data/universal-portals/_source/test/portal-model.test.ts new file mode 100644 index 000000000..fbbbf84c2 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/portal-model.test.ts @@ -0,0 +1,22 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { isValidPortalSlug } from "../src/portal-model.js"; + +test("accepts lowercase slugs that start with a letter", () => { + assert.equal(isValidPortalSlug("acme-admin"), true); + assert.equal(isValidPortalSlug("a"), true); +}); + +test("rejects slugs that break the portal pattern", () => { + assert.equal(isValidPortalSlug("My Portal"), false); + assert.equal(isValidPortalSlug("1portal"), false); + assert.equal(isValidPortalSlug("-portal"), false); + assert.equal(isValidPortalSlug("portal_one"), false); + assert.equal(isValidPortalSlug(""), false); +}); + +test("rejects slugs longer than 100 characters", () => { + assert.equal(isValidPortalSlug(`a${"b".repeat(99)}`), true); + assert.equal(isValidPortalSlug(`a${"b".repeat(100)}`), false); +}); diff --git a/internal/cli/data/universal-portals/_source/test/preflight.test.ts b/internal/cli/data/universal-portals/_source/test/preflight.test.ts new file mode 100644 index 000000000..90f133733 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/preflight.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { ApiClient, ApiError, HttpMethod, toApiError } from "../src/api.js"; +import { FeatureNotEnabledError, Probe, choiceGaps, parseScopeGap, requiredMissingScopes, runPreflight } from "../src/setup/preflight.js"; + +// Answers each probe with the response the scope gate would return, keyed by +// "METHOD path". Anything not listed succeeds. +function tenantWith(answers: Record): ApiClient { + return { + async request(method: HttpMethod, path: string): Promise { + const answer = answers[`${method} ${path}`]; + if (!answer) return undefined as T; + if (answer.status >= 400) throw toApiError(answer.status, answer.body); + return answer.body as T; + }, + }; +} + +const insufficient = (scopes: string, mode: "any" | "all" = "any") => ({ + status: 403, + body: { + statusCode: 403, + error: "Forbidden", + message: `Insufficient scope, expected ${mode} of: ${scopes}`, + errorCode: "insufficient_scope", + }, +}); + +const PROBES: Probe[] = [ + { name: "create client", method: "POST", path: "clients", body: {} }, + { name: "import forms", method: "POST", path: "forms/import", body: {} }, + { name: "create portal", method: "POST", path: "portals", body: {} }, +]; + +test("a scope the session has is not reported", async () => { + const result = await runPreflight(tenantWith({}), PROBES, "acme.us.auth0.com"); + assert.deepEqual(result.gaps, []); + assert.deepEqual(result.unexpected, []); +}); + +test("a validation error (400) means the scope is present", async () => { + const api = tenantWith({ "POST clients": { status: 400, body: { message: "Payload validation error" } } }); + const result = await runPreflight(api, PROBES, "acme.us.auth0.com"); + assert.deepEqual(result.gaps, []); +}); + +test("every missing scope is reported at once, with the requirement", async () => { + const api = tenantWith({ + "POST clients": insufficient("create:clients"), + "POST forms/import": insufficient("create:forms,create:flows", "all"), + "POST portals": insufficient("create:portals"), + }); + const result = await runPreflight(api, PROBES, "acme.us.auth0.com"); + + assert.deepEqual(result.gaps, [ + { probe: "create client", requirement: "any", scopes: ["create:clients"] }, + { probe: "import forms", requirement: "all", scopes: ["create:forms", "create:flows"] }, + { probe: "create portal", requirement: "any", scopes: ["create:portals"] }, + ]); + assert.deepEqual(requiredMissingScopes(result), ["create:clients", "create:flows", "create:forms", "create:portals"]); +}); + +test("an 'any of' gap with several options is a choice, not a required scope", async () => { + const api = tenantWith({ "POST portals": insufficient("create:portals,create:portals_beta") }); + const result = await runPreflight(api, PROBES, "acme.us.auth0.com"); + assert.deepEqual(requiredMissingScopes(result), []); + assert.equal(choiceGaps(result).length, 1); +}); + +test("feature_not_enabled stops the preflight with an actionable error", async () => { + const api = tenantWith({ + "POST portals": { + status: 403, + body: { statusCode: 403, message: "Feature not enabled", errorCode: "feature_not_enabled" }, + }, + }); + await assert.rejects(runPreflight(api, PROBES, "acme.us.auth0.com"), (error: unknown) => { + assert.ok(error instanceof FeatureNotEnabledError); + assert.match(error.message, /Universal Portals is not enabled for acme\.us\.auth0\.com/); + assert.match(error.message, /Ask Auth0 to enable it .*run the command again/); + return true; + }); +}); + +test("a 403 without a scope message is kept as unexpected, not as a scope gap", async () => { + const api = tenantWith({ "POST clients": { status: 403, body: { message: "Forbidden" } } }); + const result = await runPreflight(api, PROBES, "acme.us.auth0.com"); + assert.deepEqual(result.gaps, []); + assert.equal(result.unexpected.length, 1); + assert.equal(result.unexpected[0].status, 403); +}); + +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/resources.test.ts b/internal/cli/data/universal-portals/_source/test/resources.test.ts new file mode 100644 index 000000000..c45fc7ac8 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/resources.test.ts @@ -0,0 +1,199 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { FakeTenant } from "./fake-tenant.js"; +import { buildPortalForms } from "../src/forms.js"; +import { buildPortalClient, buildPortalRequest, templateFor } from "../src/setup/payload.js"; +import { + ensureClientGrant, + ensureForms, + ensurePortal, + ensurePortalClient, + ensureResourceServer, + ensureVaultConnection, + NameInUseError, + refreshVaultConnection, + SlugTakenError, +} from "../src/setup/resources.js"; + +const DOMAIN = "acme.us.auth0.com"; +const CLIENT_INPUT = { + name: "Acme Admin (Auth0 Portals)", + slug: "acme-admin", + template: "business" as const, + tenantDomain: DOMAIN, + portalHost: DOMAIN, + isCustomDomain: false, +}; + +test("a resource server is created once and reused on rerun", async () => { + const tenant = new FakeTenant(); + const first = await ensureResourceServer(tenant, { name: "My Account", identifier: `https://${DOMAIN}/me/` }); + const second = await ensureResourceServer(tenant, { name: "My Account", identifier: `https://${DOMAIN}/me/` }); + + assert.equal(first.status, "created"); + assert.equal(second.status, "reused"); + assert.equal(second.value.id, first.value.id); + assert.equal(tenant.resourceServers.size, 1); +}); + +test("a rerun returns the same client and secret, without creating a second client", async () => { + const tenant = new FakeTenant(); + const first = await ensurePortalClient(tenant, CLIENT_INPUT); + const second = await ensurePortalClient(tenant, CLIENT_INPUT); + + assert.equal(first.status, "created"); + assert.equal(second.status, "reused"); + assert.equal(second.value.clientId, first.value.clientId); + assert.equal(second.value.clientSecret, first.value.clientSecret); + assert.equal(tenant.clients.size, 1); +}); + +test("a rerun turns on session transfer for a client that lacks it", async () => { + const tenant = new FakeTenant(); + const { value } = await ensurePortalClient(tenant, CLIENT_INPUT); + delete tenant.clients.get(value.clientId)!.session_transfer; + + await ensurePortalClient(tenant, CLIENT_INPUT); + + assert.deepEqual(tenant.clients.get(value.clientId)!.session_transfer, { + allowed_authentication_methods: ["query"], + enforce_device_binding: "ip", + }); +}); + +test("a rerun keeps a session transfer setting that was changed on purpose", async () => { + const tenant = new FakeTenant(); + const { value } = await ensurePortalClient(tenant, CLIENT_INPUT); + tenant.clients.get(value.clientId)!.session_transfer = { + allowed_authentication_methods: ["query"], + enforce_device_binding: "asn", + }; + + await ensurePortalClient(tenant, CLIENT_INPUT); + + assert.equal(tenant.clients.get(value.clientId)!.session_transfer.enforce_device_binding, "asn"); +}); + +test("an application past the first page of clients is found and reused, not created again", async () => { + const tenant = new FakeTenant(); + const { value } = await ensurePortalClient(tenant, CLIENT_INPUT); + // Push the application past the first page: 100 other clients come before it. + const application = tenant.clients.get(value.clientId)!; + tenant.clients.delete(value.clientId); + for (let index = 0; index < 100; index += 1) { + tenant.clients.set(`other_${index}`, { client_id: `other_${index}`, name: `Other ${index}` }); + } + tenant.clients.set(value.clientId, application); + + const rerun = await ensurePortalClient(tenant, CLIENT_INPUT); + + assert.equal(rerun.status, "reused"); + assert.equal(tenant.clients.size, 101); +}); + +test("a same-named application that setup did not create is never reused", async () => { + const tenant = new FakeTenant(); + tenant.clients.set("cli_manual", { client_id: "cli_manual", client_secret: "x", name: CLIENT_INPUT.name }); + + await assert.rejects(ensurePortalClient(tenant, CLIENT_INPUT), NameInUseError); +}); + +test("a client grant is created, reused when unchanged, and updated when scopes differ", async () => { + const tenant = new FakeTenant(); + const audience = `https://${DOMAIN}/me/`; + + const created = await ensureClientGrant(tenant, { clientId: "cli_1", audience, scope: ["read:me:factors"] }); + const reused = await ensureClientGrant(tenant, { clientId: "cli_1", audience, scope: ["read:me:factors"] }); + const updated = await ensureClientGrant(tenant, { + clientId: "cli_1", + audience, + scope: ["read:me:factors", "read:me:authentication_methods"], + }); + + assert.equal(created.status, "created"); + assert.equal(reused.status, "reused"); + assert.equal(updated.status, "updated"); + assert.equal(tenant.grants.size, 1); +}); + +test("a vault connection is reused by name, and a scope change refreshes it with setup", async () => { + const tenant = new FakeTenant(); + const setup = { domain: DOMAIN, clientId: "cli_vault", clientSecret: "s" }; + const created = await ensureVaultConnection(tenant, "Auth0 Portals forms vault", setup); + const reused = await ensureVaultConnection(tenant, "Auth0 Portals forms vault", setup); + + assert.equal(created.status, "created"); + assert.equal(reused.value.id, created.value.id); + assert.equal(tenant.vaults.size, 1); + + await refreshVaultConnection(tenant, created.value.id, setup); + assert.equal(tenant.vaults.get(created.value.id)?.setup.client_id, "cli_vault"); +}); + +test("forms are imported once and reused by name", async () => { + const tenant = new FakeTenant(); + const forms = buildPortalForms("ac_vault"); + + const first = await ensureForms(tenant, forms); + const second = await ensureForms(tenant, forms); + + assert.equal(first.personalInfo.status, "created"); + assert.equal(second.personalInfo.status, "reused"); + assert.equal(second.personalInfo.value.id, first.personalInfo.value.id); + assert.equal(tenant.forms.size, 3); +}); + +test("a portal is created, reused for the same client, and refused for another client", async () => { + const tenant = new FakeTenant(); + const forms = await ensureForms(tenant, buildPortalForms("ac_vault")); + const template = templateFor("business", { + personalInfo: forms.personalInfo.value.id, + privacyConsent: forms.privacyConsent.value.id, + communicationPreferences: forms.communicationPreferences.value.id, + }); + const request = buildPortalRequest(template, { + slug: "acme-admin", + name: "Acme Admin", + clientId: "cli_1", + clientSecret: "s", + }); + + const created = await ensurePortal(tenant, request); + const reused = await ensurePortal(tenant, request); + assert.equal(created.status, "created"); + assert.equal(reused.status, "reused"); + + const otherClient = { ...request, client: { ...request.client, client_id: "cli_other" } }; + await assert.rejects(ensurePortal(tenant, otherClient), SlugTakenError); +}); + +test("a simulated failure mid-run leaves completed resources in place for the rerun to reuse", async () => { + const tenant = new FakeTenant(); + await ensurePortalClient(tenant, CLIENT_INPUT); + tenant.failNext("POST forms/import", 500); + + await assert.rejects(ensureForms(tenant, buildPortalForms("ac_vault")), /simulated failure/); + + const rerunClient = await ensurePortalClient(tenant, CLIENT_INPUT); + assert.equal(rerunClient.status, "reused"); + assert.equal(tenant.clients.size, 1); +}); + +test("each form reports the flow it runs, whether it was created or reused", async () => { + const tenant = new FakeTenant(); + const first = await ensureForms(tenant, buildPortalForms("ac_vault")); + const second = await ensureForms(tenant, buildPortalForms("ac_vault")); + + assert.ok(first.personalInfo.value.flowId, "a created form reports its flow"); + assert.equal(second.personalInfo.value.flowId, first.personalInfo.value.flowId); + assert.equal(second.personalInfo.status, "reused"); +}); + +test("the portal application accepts session transfer tokens from native apps", () => { + const client = buildPortalClient(CLIENT_INPUT); + assert.deepEqual(client.session_transfer, { + allowed_authentication_methods: ["query"], + enforce_device_binding: "ip", + }); +}); diff --git a/internal/cli/data/universal-portals/_source/test/run.test.ts b/internal/cli/data/universal-portals/_source/test/run.test.ts new file mode 100644 index 000000000..0803332d3 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/run.test.ts @@ -0,0 +1,396 @@ +import { strict as assert } from "node:assert"; +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 { FORMS_VAULT_NAME } from "../src/setup/vault.js"; +import { FORMS_VAULT_SCOPES, MY_ACCOUNT_SCOPES, MY_ORGANIZATION_SCOPES } from "../src/setup/payload.js"; +import { FakeTenant } from "./fake-tenant.js"; +import { captureOutput } from "./capture-output.js"; + +const DOMAIN = "acme.us.auth0.com"; + +function harness( + tenant: FakeTenant, + overrides: Partial = {}, +): { deps: SetupDeps; output: () => string } { + const captured = captureOutput(); + const ui = new Ui(captured.stream, false, false); + const prompt: Prompter = { + async ask(_question, defaultValue) { + return defaultValue ?? ""; + }, + async confirm() { + return false; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_question: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }; + return { + deps: { api: tenant, ui, prompt, interactive: false, tenantDomain: DOMAIN, ...overrides }, + output: () => captured.text(), + }; +} + +function seedBusinessUser(tenant: FakeTenant, options: { withOrg: boolean; withRole: boolean }): void { + tenant.users.set("auth0|user1", { user_id: "auth0|user1", email: "jane@acme.com", name: "Jane" }); + tenant.organizations.set("org_1", { id: "org_1", name: "acme-corp" }); + tenant.orgConnections.set("org_1", ["con_db"]); + tenant.roles.set("rol_admin", { id: "rol_admin", name: "Organization - Admin", permissions: MY_ORGANIZATION_SCOPES }); + if (options.withOrg) tenant.members.set("org_1", new Set(["auth0|user1"])); + if (options.withRole) tenant.orgRoles.set("org_1:auth0|user1", ["rol_admin"]); +} + +const CONSUMER: SetupOptions = { + template: "consumer", + name: "Acme Account", + slug: "acme-account", +}; + +test("a consumer portal is created end to end and the output never shows a full secret", async () => { + const tenant = new FakeTenant(); + const { deps, output } = harness(tenant); + const result = await runSetup(CONSUMER, deps); + + assert.equal(result.portal.status, "created"); + assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-account`); + assert.equal(tenant.portals.size, 1); + assert.equal(tenant.forms.size, 3); + assert.equal(tenant.vaults.size, 1); + + const secret = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Account"))?.client_secret; + assert.ok(secret); + assert.ok(!output().includes(secret), "the full client secret must never be printed"); +}); + +test("a consumer portal is linked to a client with only the My Account policy and no client credentials", async () => { + const tenant = new FakeTenant(); + await runSetup(CONSUMER, harness(tenant).deps); + + const appClient = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Account")); + assert.ok(appClient); + assert.ok(!appClient.grant_types.includes("client_credentials"), "the portal app must not have client_credentials"); + assert.equal(appClient.refresh_token.rotation_type, "non-rotating"); + assert.equal(appClient.refresh_token.idle_token_lifetime, 3600); + assert.equal(appClient.refresh_token.token_lifetime, 86400); + assert.deepEqual( + appClient.refresh_token.policies.map((policy: { audience: string }) => policy.audience), + [`https://${DOMAIN}/me/`], + ); +}); + +test("a rerun reuses every resource and creates nothing", async () => { + const tenant = new FakeTenant(); + await runSetup(CONSUMER, harness(tenant).deps); + const before = { clients: tenant.clients.size, grants: tenant.grants.size, forms: tenant.forms.size, portals: tenant.portals.size }; + + const rerun = await runSetup(CONSUMER, harness(tenant).deps); + + assert.equal(rerun.portal.status, "reused"); + assert.equal(rerun.application.status, "reused"); + assert.equal(rerun.vault.status, "reused"); + assert.deepEqual( + Object.values(rerun.forms).map((form) => form.status), + ["reused", "reused", "reused"], + ); + assert.deepEqual( + { clients: tenant.clients.size, grants: tenant.grants.size, forms: tenant.forms.size, portals: tenant.portals.size }, + before, + ); +}); + +test("a failure partway through is recovered by rerunning the same command", async () => { + const tenant = new FakeTenant(); + tenant.failNext("POST forms/import", 500); + + await assert.rejects(runSetup(CONSUMER, harness(tenant).deps), /simulated failure/); + assert.equal(tenant.portals.size, 0, "the portal is the last step, so it must not exist yet"); + + const rerun = await runSetup(CONSUMER, harness(tenant).deps); + assert.equal(rerun.portal.status, "created"); + assert.equal(rerun.application.status, "reused"); + assert.equal(tenant.clients.size, 2, "one portal app and one forms vault app"); +}); + +test("a taken slug fails without prompting when there is no terminal", async () => { + const tenant = new FakeTenant(); + await runSetup(CONSUMER, harness(tenant).deps); + tenant.clients.clear(); + + await assert.rejects(runSetup(CONSUMER, harness(tenant).deps), /already used by another portal/); +}); + +test("a taken slug stops before the application, grants or vault are created", async () => { + const tenant = new FakeTenant(); + tenant.portals.set("up_other", { id: "up_other", slug: "acme-account", name: "Other", client: { client_id: "cli_x" } }); + + await assert.rejects(runSetup(CONSUMER, harness(tenant).deps), /already used by another portal/); + assert.equal(tenant.clients.size, 0); + assert.equal(tenant.grants.size, 0); + assert.equal(tenant.vaults.size, 0); +}); + +test("an unknown test user stops before the application, grants or vault are created", async () => { + const tenant = new FakeTenant(); + const business = { ...CONSUMER, template: "business" as const, name: "Acme Admin", slug: "acme-admin", userQuery: "nobody@acme.com" }; + + await assert.rejects(runSetup(business, harness(tenant).deps), /No user matches/); + assert.equal(tenant.clients.size, 0); + assert.equal(tenant.grants.size, 0); + assert.equal(tenant.vaults.size, 0); + assert.equal(tenant.portals.size, 0); +}); + +test("a cancel at the test user question stops before anything is created", async () => { + const tenant = new FakeTenant(); + const { deps } = harness(tenant, { + interactive: true, + prompt: { + async ask() { + throw new CancelledError(); + }, + async confirm() { + return false; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_question: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }, + }); + + await assert.rejects(runSetup({ ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin" }, deps), CancelledError); + assert.equal(tenant.clients.size, 0); + assert.equal(tenant.portals.size, 0); +}); + +test("a taken slug is re-prompted when a terminal is available", async () => { + const tenant = new FakeTenant(); + tenant.portals.set("up_other", { id: "up_other", slug: "acme-account", name: "Other", client: { client_id: "cli_x" } }); + + const { deps } = harness(tenant, { + interactive: true, + prompt: { + async ask() { + return "acme-account-2"; + }, + async confirm() { + return false; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }, + }); + const result = await runSetup(CONSUMER, deps); + assert.equal(result.portal.slug, "acme-account-2"); +}); + +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"); + + 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"]); + return true; + }); + assert.equal(tenant.clients.size, 0, "preflight must run before any create"); +}); + +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" }; + const result = await runSetup(CONSUMER, harness(tenant).deps); + + assert.equal(result.portal.url, "https://auth.acme.com/portals/acme-account"); + const appClient = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Account")); + assert.deepEqual(appClient?.callbacks, ["https://auth.acme.com/portals/auth/callback"]); + assert.ok(appClient?.oidc_logout, "back-channel logout is set on a custom domain"); +}); + +test("the forms vault is refreshed with setup when its grant gains a scope", async () => { + const tenant = new FakeTenant(); + await runSetup(CONSUMER, harness(tenant).deps); + + const vaultApp = [...tenant.clients.values()].find((client) => client.client_metadata?.auth0_portals_role === "forms-vault"); + assert.ok(vaultApp); + const grant = [...tenant.grants.values()].find((g) => g.client_id === vaultApp.client_id); + assert.ok(grant); + grant.scope = ["update:users"]; + + const rerun = await runSetup(CONSUMER, harness(tenant).deps); + const vault = [...tenant.vaults.values()].find((v) => v.name === FORMS_VAULT_NAME); + assert.equal(rerun.vault.status, "reused"); + assert.deepEqual(vault?.minted_with_scopes, FORMS_VAULT_SCOPES); +}); + +test("business setup creates the portal with the organization in its URL", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: true, withRole: true }); + const result = await runSetup( + { ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin", userQuery: "jane@acme.com" }, + harness(tenant).deps, + ); + + assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-admin?organization=org_1`); + assert.equal(result.organization?.hasRole, true); + const appClient = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Admin")); + assert.deepEqual( + appClient?.refresh_token.policies.map((policy: { audience: string }) => policy.audience).sort(), + [`https://${DOMAIN}/me/`, `https://${DOMAIN}/my-org/`].sort(), + ); + const orgGrant = [...tenant.grants.values()].find((g) => g.audience === `https://${DOMAIN}/my-org/`); + assert.deepEqual(orgGrant?.scope, MY_ORGANIZATION_SCOPES); + const accountGrant = [...tenant.grants.values()].find((g) => g.audience === `https://${DOMAIN}/me/`); + assert.deepEqual(accountGrant?.scope, MY_ACCOUNT_SCOPES); +}); + +test("business setup stops for a user who is in no organization when there is no terminal", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: false, withRole: false }); + + await assert.rejects( + runSetup({ ...CONSUMER, template: "business", userQuery: "jane@acme.com" }, harness(tenant).deps), + (error: unknown) => { + assert.ok(error instanceof BusinessAccessError); + assert.match(error.fixes.join(" "), /auth0 api post \"organizations\/\/members\"/); + return true; + }, + ); + assert.equal(tenant.portals.size, 0, "no portal is created before the test user is checked"); +}); + +test("business setup adds a user to an organization only after confirmation", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: false, withRole: false }); + + const { deps } = harness(tenant, { + interactive: true, + prompt: { + async ask() { + return ""; + }, + async confirm() { + return true; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }, + }); + const result = await runSetup({ ...CONSUMER, template: "business", userQuery: "jane@acme.com" }, deps); + assert.equal(result.organization?.id, "org_1"); + assert.ok(tenant.members.get("org_1")?.has("auth0|user1")); +}); + +test("business setup refuses an organization with no enabled connection", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: true, withRole: true }); + tenant.orgConnections.set("org_1", []); + + await assert.rejects( + runSetup({ ...CONSUMER, template: "business", userQuery: "jane@acme.com" }, harness(tenant).deps), + (error: unknown) => { + assert.ok(error instanceof BusinessAccessError); + assert.match(error.message, /no enabled connections/); + return true; + }, + ); +}); + +test("business setup asks for the test user before the portal is created", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: true, withRole: true }); + const asked: { question: string; portalsBefore: number }[] = []; + const { deps } = harness(tenant, { + interactive: true, + prompt: { + async ask(question) { + asked.push({ question, portalsBefore: tenant.portals.size }); + return "jane@acme.com"; + }, + async confirm() { + return true; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }, + }); + const result = await runSetup({ ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin" }, deps); + + assert.deepEqual(asked, [ + { question: "Enter the test user's email or user ID (press Enter to skip)", portalsBefore: 0 }, + ]); + assert.equal(tenant.portals.size, 1); + assert.equal(result.organization?.id, "org_1"); +}); + +test("business setup without a user check puts the organization ID in the link", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: false, withRole: false }); + const { deps } = harness(tenant, { + interactive: true, + prompt: { + async ask(question) { + return /test user/i.test(question) ? "" : "org_1"; + }, + async confirm() { + return false; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }, + }); + const result = await runSetup({ ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin" }, deps); + + assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-admin?organization=org_1`); + assert.equal(result.organization, undefined, "no membership is checked without a user"); +}); + +test("leaving the organization empty puts a placeholder in the link", async () => { + const tenant = new FakeTenant(); + const { deps } = harness(tenant, { + interactive: true, + prompt: { + async ask(_question, defaultValue) { + return defaultValue ?? ""; + }, + async confirm() { + return false; + }, + async checklist(): Promise { + throw new Error("unexpected question"); + }, + async choose(_q: string, options: { value: T }[]): Promise { + return options[0].value; + }, + }, + }); + const result = await runSetup({ ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin" }, deps); + + assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-admin?organization=`); +}); diff --git a/internal/cli/data/universal-portals/_source/test/scopes.test.ts b/internal/cli/data/universal-portals/_source/test/scopes.test.ts new file mode 100644 index 000000000..c22572546 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/scopes.test.ts @@ -0,0 +1,64 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { ApiClient } from "../src/api.js"; +import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; +import { applyFixes } from "../src/audit/fix.js"; +import { auditPortal } from "../src/audit/checks.js"; +import { PLUGIN_SCOPES } from "../src/scopes.js"; +import { runPreflight, SETUP_PROBES } from "../src/setup/preflight.js"; +import { runSetup, SetupDeps } from "../src/setup/run.js"; +import { Ui } from "../src/ui.js"; +import { FakeTenant } from "./fake-tenant.js"; +import { captureOutput } from "./capture-output.js"; + +const DOMAIN = "acme.us.auth0.com"; +const quiet = () => new Ui(captureOutput().stream, false, true); + +// Mirrors the CLI proxy's rule (internal/plugins/proxy.go, requiredScopeFor): the verb +// comes from the HTTP method and the resource is the first path segment. +const VERBS: Record = { GET: "read", POST: "create", PATCH: "update", PUT: "update", DELETE: "delete" }; + +function scopeOf(call: string): string { + const [method, path] = call.split(" "); + const resource = path.split("?")[0].split("/")[0].replace(/-/g, "_"); + return `${VERBS[method]}:${resource}`; +} + +function deps(tenant: FakeTenant): SetupDeps { + return { + api: tenant, + ui: quiet(), + prompt: { + ask: async (_q, d) => d ?? "", + confirm: async () => false, + checklist: async () => { + throw new Error("unexpected question"); + }, + choose: async (_q, options) => options[0].value, + }, + interactive: false, + tenantDomain: DOMAIN, + }; +} + +test("every scope the plugin's flows need is in PLUGIN_SCOPES", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const result = await runSetup({ template: "consumer", name: "Acme Account", slug: "acme-account" }, deps(tenant)); + + await runPreflight(tenant, SETUP_PROBES, DOMAIN); + + tenant.portals.get(result.portal.id)!.navigation.sidebar.components = [ + { type: "sidebar:component:auth0:internal_link", config: { label: "Profile", to: "profile", icon: "user" } }, + ]; + const readSnapshot = () => fetchPortalSnapshot(tenant, result.portal.id, DOMAIN); + const fixes = auditPortal(await readSnapshot()).filter((finding) => finding.remedy?.apply); + await applyFixes(fixes, { api: tenant, ui: quiet(), readSnapshot }); + + const needed = [...new Set(tenant.calls.map(scopeOf))].sort(); + const missing = needed.filter((scope) => !PLUGIN_SCOPES.includes(scope)); + assert.deepEqual(missing, [], "add these scopes to PLUGIN_SCOPES"); +}); + +export type { ApiClient }; diff --git a/internal/cli/data/universal-portals/_source/test/templates.test.ts b/internal/cli/data/universal-portals/_source/test/templates.test.ts new file mode 100644 index 000000000..19d72accd --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/templates.test.ts @@ -0,0 +1,131 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import type { PageComponent, PortalPage, SidebarComponent } from "../src/portal-model.js"; +import { LIMITS } from "../src/portal-model.js"; +import { ICON_NAME_PATTERN, SIDEBAR_ICONS } from "../src/templates/icons.js"; +import { businessTemplate } from "../src/templates/business.js"; +import { consumerTemplate } from "../src/templates/consumer.js"; +import type { FormIds, PortalTemplate } from "../src/templates/types.js"; + +const FORMS: FormIds = { + personalInfo: "ap_personal", + privacyConsent: "ap_privacy", + communicationPreferences: "ap_comms", +}; + +function sidebarPageSlugs(sidebar: SidebarComponent[]): string[] { + return sidebar.map((item) => { + assert.equal(item.type, "sidebar:component:auth0:internal_link:v2"); + return item.config.page_slug; + }); +} + +function formIdsUsed(pages: PortalPage[]): string[] { + return pages.flatMap((page) => + (page.components ?? []).flatMap((component) => + component.type === "page:component:auth0:structure:section" + ? component.config.children.flatMap((child) => + child.type === "page:component:auth0:form" ? [child.config.form_id] : [], + ) + : [], + ), + ); +} + +function allComponents(pages: PortalPage[]): PageComponent[] { + return pages.flatMap((page) => page.components ?? []); +} + +function templatesUnderTest(): PortalTemplate[] { + return [consumerTemplate(FORMS), businessTemplate(FORMS)]; +} + +for (const template of templatesUnderTest()) { + test(`${template.id}: every sidebar link points to an existing page`, () => { + const pageSlugs = template.pages.map((page) => page.slug); + for (const slug of sidebarPageSlugs(template.sidebar)) { + assert.ok(pageSlugs.includes(slug), `sidebar link to missing page "${slug}"`); + } + }); + + test(`${template.id}: every page is in the sidebar`, () => { + const linked = sidebarPageSlugs(template.sidebar); + for (const page of template.pages) { + assert.ok(linked.includes(page.slug), `page "${page.slug}" is not in the navigation`); + } + }); + + test(`${template.id}: the default page is one of the pages`, () => { + assert.ok(template.pages.some((page) => page.slug === template.defaultPage)); + }); + + test(`${template.id}: every sidebar icon matches the allowed name pattern`, () => { + for (const item of template.sidebar) { + const icon = item.config.icon; + assert.ok(icon !== undefined && ICON_NAME_PATTERN.test(icon), `bad icon "${icon}"`); + } + }); + + test(`${template.id}: every page title fits the limits`, () => { + for (const page of template.pages) { + assert.ok(page.title.length >= LIMITS.pageTitle.min && page.title.length <= LIMITS.pageTitle.max); + } + }); + + test(`${template.id}: every component is a section, and sections hold only leaf components`, () => { + for (const component of allComponents(template.pages)) { + assert.equal(component.type, "page:component:auth0:structure:section", `top-level ${component.type} is not in a section`); + const sectionConfig = (component as Extract).config; + assert.ok(sectionConfig.children.length <= LIMITS.sectionChildren.max); + for (const child of sectionConfig.children) { + assert.notEqual(child.type, "page:component:auth0:structure:section", "sections cannot be nested"); + } + } + }); + + test(`${template.id}: every form used exists in the supplied form ids`, () => { + const known = Object.values(FORMS); + for (const formId of formIdsUsed(template.pages)) { + assert.ok(known.includes(formId), `unknown form "${formId}"`); + } + }); +} + +test("consumer: uses only the My Account API and no organization components", () => { + const consumer = consumerTemplate(FORMS); + assert.deepEqual(consumer.apis, ["my-account"]); + for (const component of allComponents(consumer.pages)) { + const children = (component as Extract).config.children; + for (const child of children) { + assert.ok(!child.type.includes("my_organization"), `organization component in consumer: ${child.type}`); + } + } +}); + +test("business: needs both APIs and its home page is the organization page", () => { + const business = businessTemplate(FORMS); + assert.deepEqual(business.apis, ["my-organization", "my-account"]); + assert.equal(business.defaultPage, "organization"); +}); + +test("business: the security page is the same definition as consumer", () => { + const consumerSecurity = consumerTemplate(FORMS).pages.find((page) => page.slug === "security"); + const businessSecurity = businessTemplate(FORMS).pages.find((page) => page.slug === "security"); + assert.deepEqual(businessSecurity, consumerSecurity); +}); + +test("business: preferences uses the personal info and comms forms only", () => { + const preferences = businessTemplate(FORMS).pages.find((page) => page.slug === "preferences"); + assert.ok(preferences); + assert.deepEqual(formIdsUsed([preferences]).sort(), [FORMS.communicationPreferences, FORMS.personalInfo].sort()); +}); + +test("sidebar icons are the ones named in the icon map", () => { + const usedIcons = [...consumerTemplate(FORMS).sidebar, ...businessTemplate(FORMS).sidebar].map( + (item) => item.config.icon, + ); + for (const icon of usedIcons) { + assert.ok(Object.values(SIDEBAR_ICONS).includes(icon as never), `icon "${icon}" not in the icon map`); + } +}); diff --git a/internal/cli/data/universal-portals/_source/test/ui.test.ts b/internal/cli/data/universal-portals/_source/test/ui.test.ts new file mode 100644 index 000000000..ed6c3e768 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/ui.test.ts @@ -0,0 +1,123 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { colorEnabled, Ui } from "../src/ui.js"; +import { captureOutput } from "./capture-output.js"; + +function capture(color: boolean, silent = false, animate = false): { ui: Ui; out: () => string } { + const captured = captureOutput(); + return { ui: new Ui(captured.stream, color, silent, animate), out: () => captured.text() }; +} + +test("success, warn, error and info write their messages", () => { + const { ui, out } = capture(false); + ui.success("Resource servers ready"); + ui.warn("Slug is taken"); + ui.error("Missing scope"); + ui.info("Run again to resume"); + const text = out(); + assert.match(text, /Resource servers ready/); + assert.match(text, /Slug is taken/); + assert.match(text, /Missing scope/); + assert.match(text, /Run again to resume/); +}); + +test("the outcome note of a success line is dimmed when colors are on", () => { + const plain = capture(false); + plain.ui.success("Auth0 My Account API", "already exists"); + assert.match(plain.out(), /Auth0 My Account API already exists/); + assert.doesNotMatch(plain.out(), /\x1b\[/, "no escape sequences without colors"); + + const colored = capture(true); + colored.ui.success("Auth0 My Account API", "already exists"); + assert.match(colored.out(), /\x1b\[2malready exists\x1b\[0m/); +}); + +test("the title box shows the command and the tenant before any question", () => { + const { ui, out } = capture(false); + ui.banner("Universal Portals setup", "acme.us.auth0.com"); + assert.match(out(), /Universal Portals setup/); + assert.match(out(), /acme\.us\.auth0\.com/); +}); + +test("silent mode writes nothing", () => { + const { ui, out } = capture(false, true); + ui.banner("Universal Portals setup", "acme.us.auth0.com"); + ui.section("Resource servers"); + ui.success("Auth0 My Account API"); + ui.warn("Slug is taken"); + ui.ready("https://acme.us.auth0.com/portals/acme"); + assert.equal(out(), ""); +}); + +test("NO_COLOR and --no-color disable colors even on a TTY", () => { + assert.equal(colorEnabled({ NO_COLOR: "1" }, true), false); + assert.equal(colorEnabled({}, true, true), false); + assert.equal(colorEnabled({}, true), true); + assert.equal(colorEnabled({}, false), false); +}); + +test("a task returns its result and, without a terminal, prints no spinner", async () => { + const { ui, out } = capture(false, false, false); + const result = await ui.task("Creating application", async () => "created"); + assert.equal(result, "created"); + assert.equal(out(), ""); +}); + +test("a task with a terminal shows its label while the work runs", async () => { + const { ui, out } = capture(false, false, true); + const result = await ui.task("Creating application", async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + return 42; + }); + assert.equal(result, 42); + assert.match(out(), /Creating application/); +}); + +test("a failing task rethrows its error", async () => { + const { ui } = capture(false, false, true); + await assert.rejects( + ui.task("Creating portal", async () => { + throw new Error("boom"); + }), + /boom/, + ); +}); + +test("a reference links the ID to its dashboard page when colors are on", () => { + const colored = capture(true); + colored.ui.reference("Application", "abc123", "https://manage.auth0.com/dashboard/us/acme/applications/abc123/settings"); + assert.match(colored.out(), /\x1b\]8;;https:\/\/manage\.auth0\.com\/dashboard\/us\/acme\/applications\/abc123\/settings\x1b\\/); +}); + +test("without colors a reference shows the plain ID and no escape sequences", () => { + const plain = capture(false); + plain.ui.reference("Application", "abc123", "https://manage.auth0.com/x"); + assert.match(plain.out(), /Application: abc123/); + assert.doesNotMatch(plain.out(), /\x1b\]8;;/); +}); + +test("a section, its items and its result print in order, each after a blank line", () => { + const { ui, out } = capture(false); + ui.section("Application"); + ui.success("\"Acme Account (Universal Portals)\" created"); + ui.result("Application created"); + const text = out(); + const header = text.indexOf("Application\n"); + const item = text.indexOf("Acme Account"); + const result = text.indexOf("Application created"); + assert.ok(header >= 0 && header < item && item < result, "header, item, then result"); + assert.match(text, /\n\u2502\n/, "a blank line separates the lines"); +}); + +test("the closing block shows the portal URL in a note", () => { + const { ui, out } = capture(false); + ui.ready("https://acme.us.auth0.com/portals/acme"); + assert.match(out(), /Open portal: https:\/\/acme\.us\.auth0\.com\/portals\/acme/); +}); + +test("a reference in a colored note links the portal URL", () => { + const { ui, out } = capture(true); + ui.ready("https://acme.us.auth0.com/portals/acme"); + assert.match(out(), /\x1b\]8;;https:\/\/acme\.us\.auth0\.com\/portals\/acme\x1b\\/); +}); diff --git a/internal/cli/data/universal-portals/_source/tsconfig.json b/internal/cli/data/universal-portals/_source/tsconfig.json new file mode 100644 index 000000000..0d61caa24 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "types": ["node"], + "strict": true, + "skipLibCheck": true + }, + "include": ["src", "test"] +}