Skip to content
Closed
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.
3 changes: 2 additions & 1 deletion internal/cli/data/universal-portals/_source/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export function createApiClient(baseUrl: string, token: string, fetchImpl: Fetch

export function toApiError(status: number, body: unknown): ApiError {
const record = isRecord(body) ? body : {};
const message = typeof record.message === "string" ? record.message : `HTTP ${status}`;
const text = typeof body === "string" ? body.trim() : "";
const message = typeof record.message === "string" ? record.message : text || `HTTP ${status}`;
const errorCode = typeof record.errorCode === "string" ? record.errorCode : undefined;
return new ApiError(status, errorCode, message, body);
}
Expand Down
20 changes: 16 additions & 4 deletions internal/cli/data/universal-portals/_source/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export class UsageError extends PluginError {

export interface ParsedSetupArgs {
command: "setup" | "audit" | "help";
// With --help after a command name, the help shows only that command.
helpFor?: "setup" | "audit";
portalId?: string;
strict: boolean;
fix?: boolean;
Expand All @@ -24,6 +26,8 @@ export interface ParsedSetupArgs {
organization?: string;
json: boolean;
noInput: boolean;
// Setup only: show what a run would create and change, without writing to the tenant.
dryRun?: boolean;
}

// Parses the arguments the plugin receives from the CLI. Interactive questions
Expand All @@ -44,6 +48,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs {
organization: { type: "string", short: "o" },
json: { type: "boolean" },
"no-input": { type: "boolean" },
"dry-run": { type: "boolean" },
help: { type: "boolean", short: "h" },
strict: { type: "boolean" },
fix: { type: "boolean" },
Expand All @@ -54,6 +59,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs {
if (values.help || first === undefined) {
return {
command: "help",
helpFor: first === "setup" || first === "audit" ? first : undefined,
strict: false,
tenant: globals.tenant,
noColor: globals.noColor,
Expand Down Expand Up @@ -101,6 +107,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs {
organization: values.organization,
json: Boolean(values.json),
noInput: Boolean(values["no-input"]),
dryRun: Boolean(values["dry-run"]),
};
}

Expand All @@ -113,8 +120,8 @@ export function missingFlags(args: ParsedSetupArgs): string[] {
return missing;
}

export function usage(): string {
return [
export function usage(command?: "setup" | "audit"): string {
const setup = [
"Usage: auth0 universal-portals setup [flags]",
"",
"Creates an Auth0 Portal with its application, forms and pages.",
Expand All @@ -126,9 +133,11 @@ 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.",
"",
];
const audit = [
"Usage: auth0 universal-portals audit <portal-id> [flags]",
"",
"Checks a portal and its application against the setup defaults.",
Expand All @@ -139,7 +148,10 @@ export function usage(): string {
" --strict Also exit 1 when there are warnings.",
" --json Print the result as JSON on stdout.",
" -h, --help Show this help.",
].join("\n");
];
if (command === "setup") return setup.join("\n");
if (command === "audit") return audit.join("\n");
return [...setup, "", ...audit].join("\n");
}

function splitGlobalFlags(argv: string[]): { globals: { tenant?: string; noColor: boolean }; rest: string[] } {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,34 @@ export async function runAuditCommand(
const fixable = fixableFindings(report.findings);
const applyDeps = { api, ui, readSnapshot };
let result = report;
let fixesFailed = 0;

if (args.fix) {
// Scripts: apply the errors the CLI can fix, without asking.
const errors = fixable.filter((finding) => finding.severity === "error");
if (errors.length > 0) {
ui.section("Fix");
await applyFixRounds(errors, applyDeps, async (exposed) => exposed);
fixesFailed = (await applyFixRounds(errors, applyDeps, async (exposed) => exposed)).failed;
result = await finalAudit(options, ui, api, context.tenantDomain);
}
} else if (prompter && fixable.length > 0) {
const selected = await chooseFixes(prompter, fixable, ui);
if (selected.length > 0) {
ui.section("Fix");
await applyFixRounds(selected, applyDeps, async (exposed) => {
const applied = await applyFixRounds(selected, applyDeps, async (exposed) => {
ui.detail("These errors appeared after the fixes above.");
return chooseFixes(prompter, exposed, ui);
});
fixesFailed = applied.failed;
result = await finalAudit(options, ui, api, context.tenantDomain);
}
} else if (fixable.length > 0) {
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;
// A fix that failed fails the run, even when the final audit no longer sees the error.
return result.failed || fixesFailed > 0 ? 1 : 0;
} catch (error) {
if (error instanceof CancelledError && !args.json) {
ui.warn("Cancelled. Fixes already applied stay in your tenant.");
Expand Down
20 changes: 17 additions & 3 deletions internal/cli/data/universal-portals/_source/src/audit/fix.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { ApiClient } from "../api.js";
import { ApiClient, ApiError } from "../api.js";
import { scopeFixes } from "../scope-fixes.js";
import { parseScopeGap } from "../setup/preflight.js";
import type { Ui } from "../ui.js";
import { auditPortal, Finding } from "./checks.js";
import type { PortalSnapshot } from "./snapshot.js";
Expand All @@ -16,6 +18,8 @@ export interface ApplyResult {
failed: number;
// Fixes that were no longer needed by the time they ran.
alreadyFixed: number;
// Scopes the failed fixes needed, so the fix for them is shown once at the end.
missingScopes: string[];
}

// Only errors are fixed. Warnings may be intentional, so they stay in the
Expand All @@ -32,7 +36,7 @@ export function findingKey(finding: Finding): string {

export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise<ApplyResult> {
const { ui } = deps;
const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 };
const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] };

for (const finding of selected) {
const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding));
Expand All @@ -50,6 +54,8 @@ export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise<
} catch (error) {
result.failed += 1;
ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`);
const gap = error instanceof ApiError ? parseScopeGap(label, error) : undefined;
if (gap) result.missingScopes.push(...(gap.requirement === "any" ? gap.scopes.slice(0, 1) : gap.scopes));
}
}
return result;
Expand All @@ -67,7 +73,7 @@ export async function applyFixRounds(
deps: ApplyDeps,
chooseNext: (fixable: Finding[]) => Promise<Finding[]>,
): Promise<ApplyResult> {
const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 };
const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] };
const attempted = new Set<string>();
let pending = selected;

Expand All @@ -77,9 +83,17 @@ export async function applyFixRounds(
total.applied += result.applied;
total.failed += result.failed;
total.alreadyFixed += result.alreadyFixed;
total.missingScopes.push(...result.missingScopes);

const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding)));
pending = exposed.length > 0 ? await chooseNext(exposed) : [];
}

total.missingScopes = [...new Set(total.missingScopes)].sort();
if (total.failed > 0) {
const scopes = total.missingScopes.length > 0 ? `, missing: ${total.missingScopes.join(", ")}` : "";
deps.ui.error(`${total.failed} fix(es) failed${scopes}`);
if (total.missingScopes.length > 0) for (const fix of scopeFixes(total.missingScopes)) deps.ui.detail(fix);
}
return total;
}
32 changes: 22 additions & 10 deletions internal/cli/data/universal-portals/_source/src/cli-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { spawn } from "node:child_process";

import { ApiClient, HttpMethod, toApiError } from "./api.js";
import { ApiClient, ApiError, HttpMethod, toApiError } from "./api.js";
import { PluginError } from "./plugin-error.js";

export interface CommandResult {
Expand Down Expand Up @@ -125,18 +125,23 @@ function explainFailure(stderr: string, what: string, tenant: string | undefined
// An API error comes first: its body can quote any text, including a scope message. The body runs
// to the end of the output, because the CLI prints it over several lines when it is long.
const api = /(?<!\d)(\d{3}): API request failed: ([\s\S]*)$/.exec(stderr);
if (api) return toApiError(Number(api[1]), parseBody(api[2].trim()));
if (api) return withRequest(toApiError(Number(api[1]), parseBody(api[2].trim())), what);

// The CLI keeps only the first missing scope and drops the API's body, so the message is rebuilt
// in the shape the permission check already reads.
// in the shape the permission check already reads. For an "all of" message the CLI names no
// scope at all ("lacks scope: ."), so the error says so instead of matching nothing.
const scope = /lacks scope: ([A-Za-z0-9_:,-]+)/i.exec(stderr)?.[1];
if (scope) {
return toApiError(403, {
statusCode: 403,
error: "Forbidden",
errorCode: "insufficient_scope",
message: `Insufficient scope, expected any of: ${scope}`,
});
const unnamed = !scope && /lacks scope:\s*\./i.test(stderr);
if (scope || unnamed) {
return withRequest(
toApiError(403, {
statusCode: 403,
error: "Forbidden",
errorCode: "insufficient_scope",
message: scope ? `Insufficient scope, expected any of: ${scope}` : "Insufficient scope. The Auth0 CLI did not say which.",
}),
what,
);
}

if (SIGN_IN_PROBLEM.test(stderr)) {
Expand All @@ -146,6 +151,13 @@ function explainFailure(stderr: string, what: string, tenant: string | undefined
return new Error(`${what} failed: ${firstMessage(stderr)}`);
}

// Names the request in the message, so a failure after the permission check says what failed. The
// scope message stays at the end, where parseScopeGap reads it.
function withRequest(error: ApiError, what: string): ApiError {
const reason = error.message === `HTTP ${error.status}` ? "no message" : error.message;
return new ApiError(error.status, error.errorCode, `${what} returned HTTP ${error.status}: ${reason}`, error.body);
}

function loginRequired(message: string): PluginError {
return new PluginError(`${message} Run auth0 login, then run this command again.`, "auth0_login_required", ["auth0 login"]);
}
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/data/universal-portals/_source/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ export async function main(argv: string[], env: NodeJS.ProcessEnv, io: IoStreams
}

if (args.command === "help") {
io.stdout.write(`${usage()}\n`);
io.stdout.write(`${usage(args.helpFor)}\n`);
return 0;
}

const agentMode = args.json || env.AUTH0_AGENT_MODE === "1" || env.AUTH0_AGENT_MODE === "true";
// Agent mode keeps the terminal output quiet, so the result has to come as JSON on stdout.
if (agentMode) args = { ...args, json: true };
const interactive = !args.noInput && !agentMode && io.isTTY;
const ui = new Ui(
io.stderr,
Expand Down
Loading