Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/auth0_universal-portals.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ has_children: true
---
# auth0 universal-portals

Manage Auth0 Universal Portals resources.
Set up and audit Auth0 Universal Portals.

These commands run a tool that is built into the CLI. It needs Node.js 20.12 or newer, and it calls the
Auth0 Management API as the tenant you are logged in to.

## Commands

- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application
- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal
- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal

43 changes: 43 additions & 0 deletions docs/auth0_universal-portals_audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
layout: default
parent: auth0 universal-portals
has_toc: false
---
# auth0 universal-portals audit

Checks a Universal Portal and its application against the setup defaults, and fixes the errors the tool is able to fix.

All flags are handled by the tool. Run "auth0 universal-portals audit --help" for the list.

## Usage
```
auth0 universal-portals audit [flags]
```

## Examples

```
auth0 universal-portals audit up_123
auth0 universal-portals audit up_123 --fix
auth0 up audit up_123 --organization org_123 --strict --json
```




## Inherited Flags

```
--debug Enable debug mode.
--no-color Disable colors.
--no-input Disable interactivity.
--tenant string Specific tenant to use.
```


## Related Commands

- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal
- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal


20 changes: 7 additions & 13 deletions docs/auth0_universal-portals_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ has_toc: false
---
# auth0 universal-portals setup

Provisions the Auth0 resources required by a Universal Portals application:
Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs.

- Auth0 My Account API and My Organization API (resource servers)
- A Regular Web App client with the required configuration
- Client grants for My Account API, My Organization API, and the Management API
All flags are handled by the tool. Run "auth0 universal-portals setup --help" for the list.

## Usage
```
Expand All @@ -20,17 +18,12 @@ auth0 universal-portals setup [flags]

```
auth0 universal-portals setup
auth0 universal-portals setup --name "Acme" --slug "acme"
auth0 up setup -n "Acme" -s "acme"
auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com
auth0 up setup --dry-run --template consumer --name "Acme Account" --json
auth0 up setup --no-input --template consumer --name "Acme Account" --json
```


## Flags

```
-n, --name string Display name of the portal.
-s, --slug string URL-friendly identifier for the portal (e.g. my-portal).
```


## Inherited Flags
Expand All @@ -45,6 +38,7 @@ auth0 universal-portals setup [flags]

## Related Commands

- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application
- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal
- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal


2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,6 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead
- [auth0 test](auth0_test.md) - Try your Universal Login box or get a token
- [auth0 token-exchange](auth0_token-exchange.md) - Manage token exchange profiles
- [auth0 universal-login](auth0_universal-login.md) - Manage the Universal Login experience
- [auth0 universal-portals](auth0_universal-portals.md) - Manage Universal Portals resources
- [auth0 universal-portals](auth0_universal-portals.md) - Set up and audit Universal Portals
- [auth0 users](auth0_users.md) - Manage resources for users

8 changes: 8 additions & 0 deletions internal/cli/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,11 @@ A series of JS files prefixed with `rule-template-`. Each file contains a single

### Action templates
A series of JS files prefixed with `action-template-`, each containing an empty action for a given extensibility point. There's an empty action for each extensibility point. These are used as code templates for new actions created with `auth0 actions create`.

### Universal Portals tool
The `universal-portals` folder holds the Universal Portals setup and audit tool.

- `universal-portals/universal-portals.mjs` is the only file embedded into the binary. It is one JavaScript file that Node.js can run, generated from the sources next to it. Do not edit it by hand.
- `universal-portals/_source` is the TypeScript project the file is built from, with its own tests, `package.json` and lockfile. It is not embedded, and Go tooling skips it because of the leading underscore.

To change the tool, edit `_source/src`, then run `npm test` and `npm run bundle` inside `_source`, and commit the sources together with the rebuilt `universal-portals.mjs`. A test fails when the file is older than its sources.
5 changes: 5 additions & 0 deletions internal/cli/data/universal-portals/_source/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface ParsedSetupArgs {
organization?: string;
json: boolean;
noInput: boolean;
// Setup only: show what a run would create and change, without writing to the tenant.
dryRun?: boolean;
}

// Parses the arguments the plugin receives from the CLI. Interactive questions
Expand All @@ -44,6 +46,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs {
organization: { type: "string", short: "o" },
json: { type: "boolean" },
"no-input": { type: "boolean" },
"dry-run": { type: "boolean" },
help: { type: "boolean", short: "h" },
strict: { type: "boolean" },
fix: { type: "boolean" },
Expand Down Expand Up @@ -101,6 +104,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs {
organization: values.organization,
json: Boolean(values.json),
noInput: Boolean(values["no-input"]),
dryRun: Boolean(values["dry-run"]),
};
}

Expand All @@ -126,6 +130,7 @@ export function usage(): string {
" -u, --user <email or id> Business only: the user who will test the portal.",
" -o, --organization <id|name> Business only: the organization, when the user has several.",
" --no-input Never prompt; fail on anything missing.",
" --dry-run Show what setup would create, without changing the tenant.",
" --json Print the result as JSON on stdout.",
" -h, --help Show this help.",
"",
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/data/universal-portals/_source/src/dry-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ApiClient, HttpMethod } from "./api.js";

// A request a real run would send. Secrets in the body are replaced by CLIENT_SECRET_PLACEHOLDER.
export interface PlannedRequest {
method: Exclude<HttpMethod, "GET">;
path: string;
body?: unknown;
}

export const CLIENT_SECRET_PLACEHOLDER = "<client secret>";

// Sends every read to the tenant and records each other request instead of sending it. Setup runs
// as it does for real, so the created, reused and updated decisions and the payloads all come from
// the same code. The IDs of resources a real run would create are placeholders, and a read about one
// of them returns an empty list, because that resource does not exist yet.
export function planWrites(api: ApiClient): { api: ApiClient; requests: () => PlannedRequest[] } {
const requests: PlannedRequest[] = [];
const placeholders: string[] = [];
let counter = 0;

return {
api: {
async request<T>(method: HttpMethod, path: string, body?: unknown): Promise<T> {
if (method === "GET") {
if (placeholders.some((id) => path.includes(encodeURIComponent(id)))) return [] as T;
return api.request<T>(method, path, body);
}

requests.push({ method: method as PlannedRequest["method"], path, body: redact(body) });
if (method !== "POST") return {} as T;

const id = `<new:${path.split("/")[0]}:${++counter}>`;
placeholders.push(id);
return placeholderResponse(path, body, id) as T;
},
},
requests: () => JSON.parse(JSON.stringify(requests)) as PlannedRequest[],
};
}

// The part of a create response that setup reads back: the ID, and for clients the secret.
function placeholderResponse(path: string, body: unknown, id: string): unknown {
const fields = isRecord(body) ? body : {};
if (path === "clients") return { ...fields, client_id: id, client_secret: CLIENT_SECRET_PLACEHOLDER };
if (path === "forms/import") return { form: { id, nodes: [] }, flows: [] };
return { ...fields, id };
}

// Copies the value and replaces every client secret, so a secret read from the tenant for a reused
// application can never reach the output.
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, key === "client_secret" ? CLIENT_SECRET_PLACEHOLDER : redact(item)]),
);
}
return value;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { openBrowser } from "./browser.js";
import { CancelledError, type Prompter } from "./prompt.js";
import type { TemplateId } from "./templates/types.js";
import type { IoStreams } from "./cli.js";
import { type PlannedRequest, planWrites } from "./dry-run.js";
import { reportError } from "./errors.js";
import { runSetup, SetupOptions, SlugRaceError } from "./setup/run.js";
import { runSetup, SetupOptions, SetupResult, SlugRaceError } from "./setup/run.js";
import { toPortalSlug } from "./slug.js";
import { trackWrites } from "./write-tracker.js";
import type { Ui } from "./ui.js";
Expand All @@ -28,19 +29,24 @@ export async function runSetupCommand(
}
}
const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run }));
// A dry run reads the tenant as a real run does, but keeps the requests that would change it.
const planned = args.dryRun ? planWrites(tracked.api) : undefined;

ui.banner("Universal Portals setup", context.tenantDomain);
if (planned) ui.info(DRY_RUN_NOTE);
try {
const options = prompter
? await askForMissing(args, prompter, context.tenantDomain)
: optionsFromArgs(args);
const result = await runSetup(options, {
api: tracked.api,
api: planned?.api ?? tracked.api,
ui,
prompt: prompter ?? unansweredPrompter(),
interactive,
tenantDomain: context.tenantDomain,
dryRun: planned !== undefined,
});
if (planned) return reportPlan(result, planned.requests(), args.json, io, ui);
if (args.json) {
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return 0;
Expand All @@ -65,6 +71,23 @@ export async function runSetupCommand(
}
}

// The statuses in a dry run are what a real run would report. Writes are not checked here, so
// the note says so: a real run is the only check of the permissions for creating things.
const DRY_RUN_NOTE =
"Dry run: nothing is created or changed. Statuses show what a real run would do. Write permissions are checked by a real run only.";

// The result of a real run, plus the requests it would send in the order it would send them. The
// portal is not opened, because nothing was created.
function reportPlan(result: SetupResult, requests: PlannedRequest[], json: boolean, io: IoStreams, ui: Ui): number {
if (json) {
io.stdout.write(`${JSON.stringify({ dryRun: true, ...result, requests }, null, 2)}\n`);
return 0;
}
ui.result(`Dry run complete. ${requests.length} request(s) would change the tenant.`);
ui.detail("Run the same command without --dry-run to set up the portal.");
return 0;
}

// Enter opens the portal. "n" or Ctrl+C means no: the portal is already set up,
// so cancelling this question is not a cancelled setup.
async function declinesToOpen(prompter: Prompter): Promise<boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export interface SetupDeps {
prompt: Prompter;
interactive: boolean;
tenantDomain: string;
// A dry run only reads, so the probes that send writes are skipped. Their scopes are not checked.
dryRun?: boolean;
}

export interface SetupResult {
Expand All @@ -92,7 +94,10 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
const resources: SetupResult["resources"] = [];
const { ui, api, tenantDomain } = deps;

const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business");
const probes = SETUP_PROBES.filter(
(probe) =>
(!probe.businessOnly || options.template === "business") && (!deps.dryRun || probe.method === "GET"),
);
await checkPermissions(probes, deps);

let slug = options.slug;
Expand Down
57 changes: 57 additions & 0 deletions internal/cli/data/universal-portals/_source/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ test("parses the setup flags", () => {
assert.equal(args.json, true);
});

test("--dry-run is a setup flag and is off unless it is given", () => {
assert.equal(parseSetupArgs(["setup", "--dry-run"]).dryRun, true);
assert.equal(parseSetupArgs(["setup"]).dryRun, false);
});

test("rejects an unknown template and an unknown command", () => {
assert.throws(() => parseSetupArgs(["setup", "--template", "both"]), UsageError);
assert.throws(() => parseSetupArgs(["publish"]), /Unknown command "publish"/);
Expand Down Expand Up @@ -381,6 +386,58 @@ test("a non-interactive consumer run prints the result as JSON and never the ful
assert.ok(!stdout().includes(secret) && !stderr().includes(secret), "the full secret must not be printed");
});

test("a dry run prints the plan as JSON and changes nothing in the tenant", async () => {
const tenant = new FakeTenant();
const { io, stdout } = captureIo(fetchFor(tenant));
const code = await main(
["setup", "--dry-run", "--no-input", "--json", "--template", "consumer", "--name", "Acme Account"],
ENV,
io,
);

assert.equal(code, 0);
const plan = JSON.parse(stdout());
assert.equal(plan.dryRun, true);
assert.equal(plan.portal.status, "created");
assert.equal(plan.portal.url, `https://${DOMAIN}/portals/acme-account`);
assert.ok(plan.requests.some((request: { method: string; path: string }) => request.method === "POST" && request.path === "portals"));
assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []);
assert.equal(tenant.portals.size + tenant.clients.size, 0);
});

test("a dry run without --json writes its summary to stderr and nothing to stdout", async () => {
const tenant = new FakeTenant();
const { io, stdout, stderr } = captureIo(fetchFor(tenant));
const code = await main(["setup", "--dry-run", "--no-input", "--template", "consumer", "--name", "Acme Account"], ENV, io);

assert.equal(code, 0);
assert.equal(stdout(), "");
assert.match(stderr(), /Dry run complete/);
assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []);
});

test("a dry run with missing flags gives the same usage error as a real run", async () => {
const tenant = new FakeTenant();
const { io, stderr } = captureIo(fetchFor(tenant));
const code = await main(["setup", "--dry-run", "--no-input", "--template", "consumer"], ENV, io);

assert.equal(code, 2);
assert.match(stderr(), /Missing required flags for a non-interactive run: --name/);
assert.equal(tenant.calls.length, 0);
});

test("a dry run without a login stops with the login error and reads nothing", async () => {
const tenant = new FakeTenant();
const { io, stdout, stderr } = captureIo(fetchFor(tenant));
io.run = async () => ({ exitCode: 1, stdout: "", stderr: "Error: failed to find tenant" });
const code = await main(["setup", "--dry-run", "--json", "--template", "consumer", "--name", "Acme Account"], {}, io);

assert.equal(code, 1);
assert.equal(stdout(), "");
assert.equal(JSON.parse(stderr()).error, "auth0_login_required");
assert.equal(tenant.calls.length, 0);
});

test("in agent mode a missing scope is reported as a JSON error on stderr", async () => {
const tenant = new FakeTenant();
tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals");
Expand Down
Loading
Loading