From fdd88e5f3badfd2339c2314b43205e0a6a94fd53 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 17:20:42 +0100 Subject: [PATCH 1/6] feat(cli): run universal-portals from an embedded tool Replace the Go setup command with a launcher for the Universal Portals setup and audit tool, which is built into the binary as one JavaScript file and run with Node.js 20.12 or newer. The tool covers what the old command did and more: business portals, reuse on a second run, an audit with fixes, and JSON output. The tool reaches the Management API by calling this CLI again, so the login and the token stay inside the CLI. The launcher passes the path of the CLI and the tenant, writes the tool to a private temporary file that is removed afterwards, hands over the terminal, and returns the tool's exit code. It lets the tool finish its own message on Ctrl+C instead of exiting at once. Flags are not parsed by the CLI: every argument goes to the tool, which has its own help. The old runner and the three helpers only it used are removed. The command is temporary and its header comment lists what to delete when the plugin framework takes over. Co-Authored-By: Claude --- internal/cli/data/README.md | 8 + internal/cli/universal_portals.go | 1072 ++--------------- internal/cli/universal_portals_embed.go | 11 + internal/cli/universal_portals_runner.go | 160 +++ internal/cli/universal_portals_runner_test.go | 313 +++++ internal/cli/utils_shared.go | 46 - 6 files changed, 568 insertions(+), 1042 deletions(-) create mode 100644 internal/cli/universal_portals_embed.go create mode 100644 internal/cli/universal_portals_runner.go create mode 100644 internal/cli/universal_portals_runner_test.go diff --git a/internal/cli/data/README.md b/internal/cli/data/README.md index 13eff4781..fe571a739 100644 --- a/internal/cli/data/README.md +++ b/internal/cli/data/README.md @@ -9,3 +9,11 @@ A series of JS files prefixed with `rule-template-`. Each file contains a single ### Action templates A series of JS files prefixed with `action-template-`, each containing an empty action for a given extensibility point. There's an empty action for each extensibility point. These are used as code templates for new actions created with `auth0 actions create`. + +### Universal Portals tool +The `universal-portals` folder holds the Universal Portals setup and audit tool. + +- `universal-portals/universal-portals.mjs` is the only file embedded into the binary. It is one JavaScript file that Node.js can run, generated from the sources next to it. Do not edit it by hand. +- `universal-portals/_source` is the TypeScript project the file is built from, with its own tests, `package.json` and lockfile. It is not embedded, and Go tooling skips it because of the leading underscore. + +To change the tool, edit `_source/src`, then run `npm test` and `npm run bundle` inside `_source`, and commit the sources together with the rebuilt `universal-portals.mjs`. A test fails when the file is older than its sources. diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index f4c3ecfa4..7c47deb14 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -1,1048 +1,128 @@ package cli import ( - "context" - "encoding/json" - "errors" "fmt" - "net/http" - "strings" + "os" + "os/exec" - "github.com/auth0/go-auth0/management" - "github.com/pkg/browser" "github.com/spf13/cobra" - - "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth0" ) -// Scopes granted to the portal client per audience. -var ( - myAccountAPIScopes = []string{ - "read:me:authentication_methods", - "delete:me:authentication_methods", - "update:me:authentication_methods", - "read:me:factors", - "create:me:authentication_methods", - } - - myOrgAPIScopes = []string{ - "read:my_org:configuration", - "read:my_org:details", - "update:my_org:details", - } +// Temporary. These commands run a Node.js tool that is built into the CLI, so that Universal Portals +// can be used before the plugin framework is available on this branch. When the framework installs +// the tool as a plugin, remove this: +// - universal_portals*.go in this folder, +// - the data/universal-portals folder, and its section in data/README.md, +// - the line in root.go that adds universalPortalsCmd, +// then run "make docs". A test fails if plugins.go appears, so this is not forgotten: the framework +// skips a plugin whose name matches a built-in command, and the plugin would be ignored. - managementAPIScopes = []string{ - "read:branding", - "read:organizations_summary", - "read:organizations", - "update:users", - "update:users_app_metadata", - } +// universalPortalsExit ends the process with the tool's exit code. Tests replace it. +var universalPortalsExit = os.Exit - portalGrantTypes = []string{ - "authorization_code", - "refresh_token", - "client_credentials", - "http://auth0.com/oauth/grant-type/mfa-oob", - "http://auth0.com/oauth/grant-type/mfa-otp", - "http://auth0.com/oauth/grant-type/mfa-recovery-code", - } -) - -var ( - upPortalName = Flag{ - Name: "Name", - LongForm: "name", - ShortForm: "n", - Help: "Display name of the portal.", - } - upPortalSlug = Flag{ - Name: "Slug", - LongForm: "slug", - ShortForm: "s", - Help: "URL-friendly identifier for the portal (e.g. my-portal).", - } -) - -// universalPortalsCmd groups Universal Portals management commands. +// universalPortalsCmd groups the Universal Portals commands. func universalPortalsCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "universal-portals", Aliases: []string{"up"}, - Short: "Manage Universal Portals resources", - Long: "Manage Auth0 Universal Portals resources.", + Short: "Set up and audit Universal Portals", + Long: `Set up and audit Auth0 Universal Portals. + +These commands run a tool that is built into the CLI. It needs Node.js 20.12 or newer, and it calls the +Auth0 Management API as the tenant you are logged in to.`, } cmd.SetUsageTemplate(namespaceUsageTemplate()) - cmd.AddCommand(universalPortalsSetupCmd(cli)) + cmd.AddCommand( + universalPortalsToolCmd(cli, "setup", + "Set up a Universal Portal", + `Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs. + +All flags are handled by the tool. Run "auth0 universal-portals setup --help" for the list.`, + ` auth0 universal-portals setup + auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 up setup --no-input --template consumer --name "Acme Account" --json`), + universalPortalsToolCmd(cli, "audit", + "Check a Universal Portal", + `Checks a Universal Portal and its application against the setup defaults, and fixes the errors the tool is able to fix. + +All flags are handled by the tool. Run "auth0 universal-portals audit --help" for the list.`, + ` auth0 universal-portals audit up_123 + auth0 universal-portals audit up_123 --fix + auth0 up audit up_123 --organization org_123 --strict --json`), + ) return cmd } -// universalPortalsSetupCmd provisions all Auth0 resources required by a Universal Portals application. -func universalPortalsSetupCmd(cli *cli) *cobra.Command { - var inputs struct { - PortalName string - Slug string - } - - cmd := &cobra.Command{ - Use: "setup", - Args: cobra.NoArgs, - Short: "Set up Auth0 resources for a Universal Portals application", - Long: `Provisions the Auth0 resources required by a Universal Portals application: - - - Auth0 My Account API and My Organization API (resource servers) - - A Regular Web App client with the required configuration - - Client grants for My Account API, My Organization API, and the Management API`, - Example: ` auth0 universal-portals setup - auth0 universal-portals setup --name "Acme" --slug "acme" - auth0 up setup -n "Acme" -s "acme"`, +// universalPortalsToolCmd is one command of the tool. Flag parsing is off, so every argument goes to the +// tool, which has its own help and flags. +func universalPortalsToolCmd(cli *cli, name, short, long, example string) *cobra.Command { + return &cobra.Command{ + Use: name, + Short: short, + Long: long, + Example: example, + DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runUniversalPortalsSetup(cmd, cli, inputs.PortalName, inputs.Slug) + return runUniversalPortals(cli, name, args) }, } - - upPortalName.RegisterString(cmd, &inputs.PortalName, "") - upPortalSlug.RegisterString(cmd, &inputs.Slug, "") - - return cmd } -// runUniversalPortalsSetup orchestrates the provisioning flow. -// It owns all display logic; business functions only return (value, error). -func runUniversalPortalsSetup(cmd *cobra.Command, cli *cli, portalName, slug string) error { - if err := cli.setupWithAuthentication(cmd.Context()); err != nil { - return fmt.Errorf("authentication required: %w", err) - } - - // Verify the token carries the scopes required by the EA setup endpoints. - // These are intentionally not in RequiredScopes to avoid breaking login for - // tenants without the feature flag. - // create:flows_vault_connections — POST /api/v2/flows/vault/connections - // create:forms, create:flows — POST /api/v2/forms/import - // create:portals — POST /api/v2/portals - // - // Scopes are read from the JWT directly so this works regardless of auth - // method (device code, client secret, etc.). - upRequiredScopes := []string{ - "create:flows_vault_connections", - "create:forms", - "create:flows", - "create:portals", - } - if tenant, err := cli.Config.GetTenant(cli.tenant); err == nil { - if granted := scopesFromToken(tenant.GetAccessToken()); granted != nil { - if missing := missingScopes(granted, upRequiredScopes); len(missing) > 0 { - // In --no-input mode we can't launch the interactive login, - // so surface the exact command the user needs to run. - if cli.noInput { - return fmt.Errorf( - "insufficient scopes to provision Universal Portals\nMissing: %s\nRe-authenticate to continue: auth0 login --scopes %s", - strings.Join(missing, ", "), - strings.Join(upRequiredScopes, ","), - ) - } - - // RunLoginAsUser gates on a "Press Enter / ^C" prompt before - // opening the browser, so warn here instead of double-confirming. - cli.renderer.Warnf( - "Universal Portals needs additional scopes (missing: %s).\nYou'll be asked to log in again to grant them.\n", - strings.Join(missing, ", "), - ) - - // Request the full scope set so one login grants everything. - if _, err := RunLoginAsUser(cmd.Context(), cli, upRequiredScopes, ""); err != nil { - return fmt.Errorf("failed to re-authenticate with the required scopes: %w", err) - } - - // Rebuild the API clients so subsequent calls use the new token. - if err := cli.setupWithAuthentication(cmd.Context()); err != nil { - return fmt.Errorf("authentication required: %w", err) - } - } - } - } - - ctx := cmd.Context() - - // Resolve the portal domain: default custom domain, or fall back to the tenant domain. - domain, isCustom := resolvePortalDomain(ctx, cli.api.CustomDomain, cli.tenant) - if isCustom { - cli.renderer.Infof("Domain: %s (custom domain)", ansi.Cyan(domain)) - } else { - cli.renderer.Infof("Domain: %s", ansi.Cyan(domain)) - } - cli.renderer.Newline() - - // Collect portal name. - defaultPortalName := portalName - if defaultPortalName == "" { - defaultPortalName = "My Portal" - } - if err := upPortalName.Ask(cmd, &portalName, &defaultPortalName); err != nil { - return fmt.Errorf("failed to enter portal name: %w", err) - } - if portalName == "" { - return fmt.Errorf("portal name cannot be empty") - } - - // Collect portal slug, defaulting to a slugified version of the portal name. - if slug == "" { - slug = toPortalSlug(portalName) - } - if err := upPortalSlug.Ask(cmd, &slug, &slug); err != nil { - return fmt.Errorf("failed to enter portal slug: %w", err) - } - if slug == "" { - return fmt.Errorf("portal slug cannot be empty") - } - - // The application name is derived automatically from the portal name. - appName := portalName + " (Universal Portals)" - - tenant := cli.tenant - - // Ensure the resource servers required by Universal Portals exist. - type rsResult struct { - name string - existed bool - } - var rsResults []rsResult - for _, rs := range portalResourceServers(tenant) { - existed, err := ensurePortalResourceServer(ctx, cli.api.ResourceServer, rs.name, rs.identifier) - if err != nil { - return err - } - rsResults = append(rsResults, rsResult{name: rs.name, existed: existed}) - } - cli.renderer.Successf("Resource servers ready") - for _, rs := range rsResults { - if rs.existed { - cli.renderer.Detailf("%s", ansi.Faint(rs.name+" (already exists)")) - } else { - cli.renderer.Detailf("%s", ansi.Faint(rs.name)) - } - } - - // Create the portal application client. - var client portalClientResult - if err := ansi.Waiting(func() error { - var err error - client, err = createPortalClient(ctx, cli.api.Client, appName, domain, tenant, isCustom) +func runUniversalPortals(cli *cli, command string, args []string) error { + if err := checkNodeInstallation(); err != nil { return err - }); err != nil { - return fmt.Errorf("failed to create application: %w", err) - } - - clientURL := portalManageClientURL(cli.tenant, cli.Config.Tenants[cli.tenant].Name, client.ClientID) - maskedSecret := client.ClientSecret[:4] + strings.Repeat("•", len(client.ClientSecret)-4) - - cli.renderer.Successf("Application %q created", appName) - cli.renderer.Detailf("Client ID: %s", ansi.Hyperlink(clientURL, ansi.Magenta(client.ClientID))) - cli.renderer.Detailf("Client secret: %s", ansi.Faint(maskedSecret)) - cli.renderer.Newline() - - // Create the three client grants. - grants := buildPortalGrants(client.ClientID, tenant) - for _, g := range grants { - g := g - if err := ansi.Waiting(func() error { - return createPortalGrant(ctx, cli.api.ClientGrant, g) - }); err != nil { - return fmt.Errorf("failed to create client grant for %q: %w", g.GetAudience(), err) - } - } - cli.renderer.Successf("Client grants created") - for _, g := range grants { - cli.renderer.Detailf("%s", ansi.Faint(g.GetAudience())) - } - cli.renderer.Newline() - - // Create the vault connection used by the portal forms to call the Management API. - var vaultConnID string - if err := ansi.Waiting(func() error { - setup := map[string]interface{}{ - "domain": tenant, - "client_id": client.ClientID, - "client_secret": client.ClientSecret, - "type": "OAUTH_APP", - } - appID := "AUTH0" - connName := appName - conn := &management.FlowVaultConnection{ - AppID: &appID, - Name: &connName, - Setup: &setup, - } - if err := cli.api.FlowVaultConnection.CreateConnection(ctx, conn); err != nil { - return err - } - if conn.ID != nil { - vaultConnID = *conn.ID - } - return nil - }); err != nil { - return fmt.Errorf("failed to create vault connection: %w", err) - } - cli.renderer.Successf("Vault connection created") - cli.renderer.Detailf("%s", ansi.Faint(appName)) - cli.renderer.Newline() - - // Import the portal forms. - var forms portalFormIDs - if err := ansi.Waiting(func() error { - var err error - forms, err = createPortalForms(ctx, cli.api.HTTPClient, vaultConnID) - return err - }); err != nil { - return fmt.Errorf("failed to create forms: %w", err) - } - cli.renderer.Successf("Forms created") - cli.renderer.Detailf("%s", ansi.Faint("Profile update (Universal Portals)")) - cli.renderer.Detailf("%s", ansi.Faint("Marketing communication preferences (Universal Portals)")) - cli.renderer.Detailf("%s", ansi.Faint("Privacy settings (Universal Portals)")) - cli.renderer.Newline() - - // Create the portal. On slug conflict, re-prompt and retry this step only. - var portal portalResult - for { - err := ansi.Waiting(func() error { - var err error - portal, err = createPortal(ctx, cli.api.HTTPClient, slug, portalName, client.ClientID, client.ClientSecret, forms) - return err - }) - if err == nil { - break - } - var conflict *errAPIConflict - if !errors.As(err, &conflict) { - return fmt.Errorf("failed to create portal: %w", err) - } - cli.renderer.Warnf("Slug %q is already taken.", slug) - prevSlug := slug - slug = "" - if err := upPortalSlug.Ask(cmd, &slug, &prevSlug); err != nil { - return fmt.Errorf("failed to enter portal slug: %w", err) - } - } - cli.renderer.Successf("Portal %q created", portal.Name) - - portalURL := "https://" + domain + "/portals/" + portal.Slug - - cli.renderer.Newline() - cli.renderer.Infof("Portal: %s", ansi.Cyan(portalURL)) - cli.renderer.Newline() - - if cli.noInput { - return nil - } - - cli.renderer.Infof("%s to open the portal or %s to quit...", ansi.Green("Press Enter"), ansi.Red("^C")) - if _, err := fmt.Scanln(); err != nil { - return nil - } - - if err := browser.OpenURL(portalURL); err != nil { - cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", portalURL) - } - - return nil -} - -// portalResourceServer describes a resource server that Universal Portals requires. -type portalResourceServer struct { - name string - identifier string -} - -// portalResourceServers returns the two resource servers required by Universal Portals. -func portalResourceServers(tenant string) []portalResourceServer { - return []portalResourceServer{ - {name: "Auth0 My Account API", identifier: "https://" + tenant + "/me/"}, - {name: "Auth0 My Organization API", identifier: "https://" + tenant + "/my-org/"}, - } -} - -// resolvePortalDomain returns the domain for portal URLs. -// Uses the default custom domain if active; falls back to tenantDomain. -func resolvePortalDomain(ctx context.Context, api auth0.CustomDomainAPI, tenantDomain string) (domain string, isCustom bool) { - cd, err := api.ReadDefault(ctx) - if err == nil && cd.GetStatus() == "ready" { - return cd.GetDomain(), true - } - return tenantDomain, false -} - -// ensurePortalResourceServer creates a resource server idempotently. -// Returns (true, nil) when the server already existed, (false, nil) when created. -func ensurePortalResourceServer(ctx context.Context, api auth0.ResourceServerAPI, name, identifier string) (alreadyExisted bool, err error) { - skipConsent := true - tokenDialect := "rfc9068_profile" - - err = api.Create(ctx, &management.ResourceServer{ - Name: &name, - Identifier: &identifier, - SkipConsentForVerifiableFirstPartyClients: &skipConsent, - TokenDialect: &tokenDialect, - }) - if err == nil { - return false, nil - } - if mErr, ok := err.(management.Error); ok && mErr.Status() == http.StatusConflict { - return true, nil - } - return false, fmt.Errorf("failed to ensure resource server %q: %w", name, err) -} - -type portalClientResult struct { - ClientID string - ClientSecret string -} - -// createPortalClient creates the portal application client via the typed SDK. -// Backchannel logout is omitted when isCustomDomain is false because -// Auth0 domains are rejected by the payload validation. -func createPortalClient(ctx context.Context, api auth0.ClientAPI, name, domain, tenant string, isCustomDomain bool) (portalClientResult, error) { - myAccountScopes := myAccountAPIScopes - myOrgScopes := myOrgAPIScopes - - c := &management.Client{ - Name: auth0.String(name), - IsFirstParty: auth0.Bool(true), - AppType: auth0.String("regular_web"), - OIDCConformant: auth0.Bool(true), - TokenEndpointAuthMethod: auth0.String("client_secret_post"), - Callbacks: &[]string{"https://" + domain + "/portals/auth/callback"}, - AllowedLogoutURLs: &[]string{"https://" + domain}, - OrganizationRequireBehavior: auth0.String("no_prompt"), - OrganizationUsage: auth0.String("allow"), - GrantTypes: &portalGrantTypes, - RefreshToken: &management.ClientRefreshToken{ - ExpirationType: auth0.String("expiring"), - Leeway: auth0.Int(0), - InfiniteTokenLifetime: auth0.Bool(false), - InfiniteIdleTokenLifetime: auth0.Bool(true), - TokenLifetime: auth0.Int(86400), - IdleTokenLifetime: auth0.Int(86399), - RotationType: auth0.String("non-rotating"), - Policies: &[]management.ClientRefreshTokenPolicy{ - {Audience: auth0.String("https://" + tenant + "/me/"), Scope: &myAccountScopes}, - {Audience: auth0.String("https://" + tenant + "/my-org/"), Scope: &myOrgScopes}, - }, - }, - } - - if isCustomDomain { - initiators := []string{ - "idp-logout", "rp-logout", "session-expired", - "session-revoked", "account-deleted", "account-deactivated", - } - c.OIDCLogout = &management.OIDCLogout{ - BackChannelLogoutURLs: &[]string{"https://" + domain + "/portals/auth/backchannel-logout"}, - BackChannelLogoutInitiators: &management.BackChannelLogoutInitiators{ - Mode: auth0.String("custom"), - SelectedInitiators: &initiators, - }, - } } - if err := api.Create(ctx, c); err != nil { - return portalClientResult{}, err - } - return portalClientResult{ - ClientID: c.GetClientID(), - ClientSecret: c.GetClientSecret(), - }, nil -} - -// buildPortalGrants returns the three grants required by Universal Portals. -// Pure function: no I/O, fully testable. -func buildPortalGrants(clientID, tenant string) []*management.ClientGrant { - myAccountScopes := myAccountAPIScopes - myOrgScopes := myOrgAPIScopes - mgmtScopes := managementAPIScopes - - return []*management.ClientGrant{ - {ClientID: auth0.String(clientID), Audience: auth0.String("https://" + tenant + "/me/"), SubjectType: auth0.String("user"), Scope: &myAccountScopes}, - {ClientID: auth0.String(clientID), Audience: auth0.String("https://" + tenant + "/my-org/"), SubjectType: auth0.String("user"), Scope: &myOrgScopes}, - {ClientID: auth0.String(clientID), Audience: auth0.String("https://" + tenant + "/api/v2/"), SubjectType: auth0.String("client"), Scope: &mgmtScopes}, - } -} - -// createPortalGrant creates a single client grant via the typed SDK. -func createPortalGrant(ctx context.Context, api auth0.ClientGrantAPI, grant *management.ClientGrant) error { - return api.Create(ctx, grant) -} - -// errAPIConflict is returned by rawAPIPost when the server responds 409. -// Callers that want to handle conflicts (e.g. slug already taken) can use -// errors.As to detect and recover from this case. -type errAPIConflict struct{ message string } - -func (e *errAPIConflict) Error() string { return e.message } - -// rawAPIPost is a deep helper that hides the New/Do/decode HTTP pattern. -// Pass path as one or more segments — they are joined by the URI builder so -// slashes are not URL-encoded. Pass a non-nil result to decode the response. -func rawAPIPost(ctx context.Context, h auth0.HTTPClientAPI, payload, result any, path ...string) error { - req, err := h.NewRequest(ctx, http.MethodPost, h.URI(path...), payload) + node, err := exec.LookPath("node") if err != nil { - return err + return fmt.Errorf("node is required but was not found on the PATH: %w", err) } - resp, err := h.Do(req) - if err != nil { + if err := checkUniversalPortalsNodeVersion(node); err != nil { return err } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode >= http.StatusBadRequest { - var apiErr struct { - StatusCode int `json:"statusCode"` - Message string `json:"message"` - } - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if resp.StatusCode == http.StatusConflict { - return &errAPIConflict{message: apiErr.Message} - } - return fmt.Errorf("API error %d: %s", resp.StatusCode, apiErr.Message) - } - if result != nil { - return json.NewDecoder(resp.Body).Decode(result) - } - return nil -} - -type portalFormResult struct { - Form struct { - ID string `json:"id"` - } `json:"form"` -} - -// buildCommunicationPreferencesFormPayload returns the form document for the -// "Marketing communication preferences" form with connID injected. -func buildCommunicationPreferencesFormPayload(connID string) map[string]any { - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Marketing communication preferences (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_4Td2", - "type": "STEP", - "coordinates": map[string]any{"x": 217, "y": -218}, - "config": map[string]any{ - "components": []any{ - map[string]any{ - "id": "communication_preferences", - "category": "FIELD", - "type": "CHOICE", - "required": false, - "sensitive": false, - "config": map[string]any{ - "multiple": true, - "options": []any{ - map[string]any{"label": "Product Announcements", "value": "Product Announcements"}, - map[string]any{"label": "Featured Content", "value": "Featured Content"}, - map[string]any{"label": "Digest News", "value": "Digest News"}, - map[string]any{"label": "Events", "value": "Events"}, - }, - }, - }, - map[string]any{ - "id": "next_button_US5N", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_etvH", - }, - }, - map[string]any{ - "id": "flow_etvH", - "type": "FLOW", - "coordinates": map[string]any{"x": 791, "y": -81}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_4Td2", "coordinates": map[string]any{"x": -25, "y": -98}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1194, "y": -59}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update preferences", - "actions": []any{ - map[string]any{ - "id": "update_user_HitP", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "app_metadata": map[string]any{ - "communication_preferences": "{{fields.communication_preferences}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} - -// buildPersonalInfoFormPayload returns the form document for the -// "Profile update" form with connID injected. -func buildPersonalInfoFormPayload(connID string) map[string]any { - textField := func(id, label string) map[string]any { - return map[string]any{ - "id": id, - "category": "FIELD", - "type": "TEXT", - "label": label, - "required": false, - "sensitive": false, - "config": map[string]any{"multiline": false}, - } - } - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Profile update (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_BL3V", - "type": "STEP", - "coordinates": map[string]any{"x": 346, "y": -221}, - "config": map[string]any{ - "components": []any{ - textField("full_name", "Full name"), - textField("job_title", "Job title"), - map[string]any{ - "id": "mobile_number", - "category": "FIELD", - "type": "TEL", - "label": "Mobile number", - "required": false, - "sensitive": false, - "config": map[string]any{"country_picker": true}, - }, - map[string]any{ - "id": "date_of_birth", - "category": "FIELD", - "type": "DATE", - "label": "Date of birth", - "required": false, - "sensitive": false, - "config": map[string]any{"format": "DATE"}, - }, - map[string]any{ - "id": "linkedin", - "category": "FIELD", - "type": "URL", - "label": "LinkedIn", - "required": false, - "sensitive": false, - }, - map[string]any{ - "id": "next_button_UmEY", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_DcHK", - }, - }, - map[string]any{ - "id": "flow_DcHK", - "type": "FLOW", - "coordinates": map[string]any{"x": 988, "y": 9}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_BL3V", "coordinates": map[string]any{"x": 0, "y": 0}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1367, "y": -7}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update profile", - "actions": []any{ - map[string]any{ - "id": "update_user_v4C0", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "user_metadata": map[string]any{ - "linkedin": "{{fields.linkedin}}", - "full_name": "{{fields.full_name}}", - "job_title": "{{fields.job_title}}", - "date_of_birth": "{{fields.date_of_birth}}", - "mobile_number": "{{fields.mobile_number.international_number}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} - -// buildPrivacySettingsFormPayload returns the form document for the -// "Privacy settings" form with connID injected. -func buildPrivacySettingsFormPayload(connID string) map[string]any { - boolField := func(id, label, hint string) map[string]any { - return map[string]any{ - "id": id, - "category": "FIELD", - "type": "BOOLEAN", - "label": label, - "hint": hint, - "required": true, - "sensitive": false, - "config": map[string]any{"default_value": false}, - } - } - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Privacy settings (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_y0Ri", - "type": "STEP", - "coordinates": map[string]any{"x": 500, "y": 0}, - "config": map[string]any{ - "components": []any{ - boolField("data_sharing", "Data sharing", `

Allow app usage data to improve features. Learn more.

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

Show my profile in search results. Learn more.

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

Allow location tracking for personalized recommendations. Learn more.

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

Use my data to personalize ads. Learn more.

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

Sessions & devices management coming soon.

"}, - }, - ), - }, - }, - { - Title: "Organization", - Slug: "organization", - Components: []portalComponent{ - section( - "Organization details", - "Update your organization's name and other details visible to its members.", - builtin("page:component:auth0:my_organization:details_edit"), - ), - }, - }, - { - Title: "Legal & privacy", - Slug: "legal-privacy", - Components: []portalComponent{ - section( - "Privacy & data consent", - "Control how your personal data is collected and used across our services.", - form(forms.PrivacyConsent, "Your privacy preferences have been saved."), - ), - section( - "Communication preferences", - "Choose which emails and notifications you'd like to receive from us.", - form(forms.CommunicationPreferences, "Your communication preferences have been updated."), - ), - }, - }, - }, - }, + tenant := universalPortalsTenantFlag(args) + if tenant == "" { + tenant = loggedInTenant } -} - -// createPortal POSTs a new portal with the full default page structure. -func createPortal(ctx context.Context, h auth0.HTTPClientAPI, slug, name, clientID, clientSecret string, forms portalFormIDs) (portalResult, error) { - payload := buildDefaultPortal(slug, name, clientID, clientSecret, forms) - var result portalResult - if err := rawAPIPost(ctx, h, payload, &result, "portals"); err != nil { - return portalResult{}, err + if tenant != "" { + env = append(env, "AUTH0_CLI_TENANT_DOMAIN="+tenant) } - return result, nil -} -// portalManageClientURL builds the Management Dashboard URL for the given client. -// Pure function: takes only the values it needs, no struct access. -func portalManageClientURL(tenantDomain, tenantName, clientID string) string { - parts := strings.Split(tenantDomain, ".") - region := "us" - if len(parts) > 3 { - region = parts[1] + return universalPortalsRun{ + Node: node, + Script: universalPortalsScript, + Args: append([]string{command}, args...), + Env: env, } - return fmt.Sprintf("%s/dashboard/%s/%s/applications/%s/settings", - deriveServiceURL("manage", tenantDomain), region, tenantName, clientID) } diff --git a/internal/cli/universal_portals_embed.go b/internal/cli/universal_portals_embed.go new file mode 100644 index 000000000..74182686d --- /dev/null +++ b/internal/cli/universal_portals_embed.go @@ -0,0 +1,11 @@ +package cli + +import ( + _ "embed" +) + +// universalPortalsScript is the Universal Portals setup and audit tool, built into one file that +// Node.js can run. It is generated from the sources in data/universal-portals/_source. +// +//go:embed data/universal-portals/universal-portals.mjs +var universalPortalsScript []byte diff --git a/internal/cli/universal_portals_runner.go b/internal/cli/universal_portals_runner.go new file mode 100644 index 000000000..001755197 --- /dev/null +++ b/internal/cli/universal_portals_runner.go @@ -0,0 +1,160 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" +) + +// The oldest Node.js the tool runs on. +const ( + universalPortalsNodeMajor = 20 + universalPortalsNodeMinor = 12 +) + +var nodeVersionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)`) + +// universalPortalsRun describes one run of the embedded tool. +type universalPortalsRun struct { + // Node is the path of the node executable. + Node string + // Script is the text of the tool. + Script []byte + // Args follow the script path: the command (setup or audit) and its flags. + Args []string + // Env is added to the environment, as KEY=value. + Env []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// runUniversalPortalsScript writes the tool to a private temporary file, runs it with Node.js +// and returns its exit code. The tool talks to the Auth0 Management API by calling this CLI +// again, so the login and the token stay inside the CLI. +func runUniversalPortalsScript(run universalPortalsRun) (int, error) { + // The folder is private to the current user, and removed when the run ends. + dir, err := os.MkdirTemp("", "auth0-universal-portals-") + if err != nil { + return 0, fmt.Errorf("failed to create a temporary folder for the tool: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + path := filepath.Join(dir, "universal-portals.mjs") + if err := os.WriteFile(path, run.Script, 0o600); err != nil { + return 0, fmt.Errorf("failed to write the tool to a temporary file: %w", err) + } + + cmd := exec.Command(run.Node, append([]string{path}, run.Args...)...) + cmd.Env = universalPortalsEnv(os.Environ(), run.Env) + cmd.Stdin, cmd.Stdout, cmd.Stderr = run.Stdin, run.Stdout, run.Stderr + + // The root command exits as soon as it gets Ctrl+C, which would cut off the tool's last message. + // The terminal sends Ctrl+C to the tool too, so this process only has to wait for it. + signal.Reset(os.Interrupt, syscall.SIGTERM) + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start Node.js: %w", err) + } + + finished := make(chan struct{}) + go func() { + for { + select { + case received := <-signals: + // Only a SIGTERM sent to this process alone needs passing on. + if received == syscall.SIGTERM { + _ = cmd.Process.Signal(received) + } + case <-finished: + return + } + } + }() + + err = cmd.Wait() + close(finished) + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // A tool ended by a signal has no exit code, so use the one a shell reports: 128 plus the + // signal number, which is 130 for Ctrl+C. + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()), nil + } + return exitErr.ExitCode(), nil + } + if err != nil { + return 0, fmt.Errorf("node stopped unexpectedly: %w", err) + } + + return 0, nil +} + +// universalPortalsEnv adds extra to base. A proxy left over in the environment is dropped, so it +// cannot switch the tool to another way of calling the API. +func universalPortalsEnv(base, extra []string) []string { + env := make([]string, 0, len(base)+len(extra)) + for _, entry := range base { + if strings.HasPrefix(entry, "AUTH0_CLI_PROXY_URL=") || strings.HasPrefix(entry, "AUTH0_CLI_PROXY_TOKEN=") { + continue + } + env = append(env, entry) + } + + // When a name appears twice, the last value is used. + return append(env, extra...) +} + +// universalPortalsTenantFlag returns the value of --tenant in the arguments, or "". The flags +// belong to the tool, so the CLI only reads this one to tell the tool which tenant to use. +func universalPortalsTenantFlag(args []string) string { + tenant := "" + for i := 0; i < len(args); i++ { + switch { + case args[i] == "--tenant" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-"): + tenant = args[i+1] + i++ + case strings.HasPrefix(args[i], "--tenant="): + tenant = strings.TrimPrefix(args[i], "--tenant=") + } + } + + return tenant +} + +// checkUniversalPortalsNodeVersion fails if node is older than the tool supports. +func checkUniversalPortalsNodeVersion(node string) error { + output, err := exec.Command(node, "--version").Output() + if err != nil { + return fmt.Errorf("could not read the Node.js version: %w", err) + } + + version := strings.TrimSpace(string(output)) + match := nodeVersionPattern.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("could not read the Node.js version from %q", version) + } + + major, _ := strconv.Atoi(match[1]) + minor, _ := strconv.Atoi(match[2]) + if major < universalPortalsNodeMajor || (major == universalPortalsNodeMajor && minor < universalPortalsNodeMinor) { + return fmt.Errorf( + "node %s is too old: this command needs Node.js %d.%d or newer", + version, universalPortalsNodeMajor, universalPortalsNodeMinor, + ) + } + + return nil +} diff --git a/internal/cli/universal_portals_runner_test.go b/internal/cli/universal_portals_runner_test.go new file mode 100644 index 000000000..e673a341d --- /dev/null +++ b/internal/cli/universal_portals_runner_test.go @@ -0,0 +1,313 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeNode writes a stand-in for the node executable. It answers --version, and for a script it +// reports what it was given: the script path, the permissions of the script and its folder, the +// script's text, the arguments, the environment the tool relies on, and optionally its stdin. +func fakeNode(t *testing.T, version string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the fake node is a shell script") + } + + script := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +script="$1" +echo "script=$script" +echo "folder_mode=$(ls -ld "$(dirname "$script")" | cut -c1-10)" +echo "file_mode=$(ls -l "$script" | cut -c1-10)" +echo "content=$(cat "$script")" +shift +echo "args=$*" +echo "bin=$AUTH0_CLI_BIN" +echo "tenant=$AUTH0_CLI_TENANT_DOMAIN" +echo "proxy_url=${AUTH0_CLI_PROXY_URL:-unset}" +echo "proxy_token=${AUTH0_CLI_PROXY_TOKEN:-unset}" +if [ -n "$FAKE_NODE_READ_STDIN" ]; then read line; echo "stdin=$line"; fi +echo "to-stderr" >&2 +exit "${FAKE_NODE_EXIT:-0}" +` + path := filepath.Join(t.TempDir(), "node") + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + return path +} + +// reported turns the fake node's "key=value" lines into a map. +func reported(output string) map[string]string { + values := map[string]string{} + for _, line := range strings.Split(output, "\n") { + if key, value, found := strings.Cut(line, "="); found { + values[key] = value + } + } + return values +} + +func TestUniversalPortalsTenantFlag(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "no flag", args: []string{"setup", "--name", "Acme"}, want: ""}, + {name: "separate value", args: []string{"setup", "--tenant", "acme.us.auth0.com"}, want: "acme.us.auth0.com"}, + {name: "equals form", args: []string{"audit", "up_1", "--tenant=acme.eu.auth0.com"}, want: "acme.eu.auth0.com"}, + {name: "the last one wins", args: []string{"--tenant", "a.us.auth0.com", "--tenant", "b.us.auth0.com"}, want: "b.us.auth0.com"}, + {name: "no value at the end", args: []string{"setup", "--tenant"}, want: ""}, + {name: "a flag is not a value", args: []string{"setup", "--tenant", "--json"}, want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, universalPortalsTenantFlag(test.args)) + }) + } +} + +func TestCheckUniversalPortalsNodeVersion(t *testing.T) { + tests := []struct { + version string + wantErr string + }{ + {version: "v22.1.0"}, + {version: "v20.12.0"}, + {version: "v20.19.4"}, + {version: "v21.0.0"}, + {version: "v20.11.1", wantErr: "20.12"}, + {version: "v18.19.0", wantErr: "20.12"}, + {version: "not a version", wantErr: "could not read"}, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + err := checkUniversalPortalsNodeVersion(fakeNode(t, test.version)) + if test.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestRunUniversalPortalsScript(t *testing.T) { + t.Run("runs the script with the arguments and the environment the tool relies on", func(t *testing.T) { + // A proxy left in the environment must not switch the tool to another way of calling the API. + t.Setenv("AUTH0_CLI_PROXY_URL", "https://127.0.0.1:1") + t.Setenv("AUTH0_CLI_PROXY_TOKEN", "should-not-reach-the-tool") + var stdout, stderr bytes.Buffer + + code, err := runUniversalPortalsScript(universalPortalsRun{ + Node: fakeNode(t, "v22.1.0"), + Script: []byte("console.log('hi')"), + Args: []string{"setup", "--json"}, + Env: []string{"AUTH0_CLI_BIN=/opt/bin/auth0", "AUTH0_CLI_TENANT_DOMAIN=acme.us.auth0.com"}, + Stdout: &stdout, + Stderr: &stderr, + }) + + require.NoError(t, err) + assert.Equal(t, 0, code) + got := reported(stdout.String()) + assert.Equal(t, "setup --json", got["args"]) + assert.Equal(t, "/opt/bin/auth0", got["bin"]) + assert.Equal(t, "acme.us.auth0.com", got["tenant"]) + assert.Equal(t, "unset", got["proxy_url"]) + assert.Equal(t, "unset", got["proxy_token"]) + assert.Equal(t, "console.log('hi')", got["content"]) + assert.Contains(t, stderr.String(), "to-stderr", "the tool's stderr must reach the user") + }) + + t.Run("keeps the script private and removes it afterwards", func(t *testing.T) { + var stdout bytes.Buffer + + _, err := runUniversalPortalsScript(universalPortalsRun{ + Node: fakeNode(t, "v22.1.0"), + Script: []byte("x"), + Stdout: &stdout, + }) + + require.NoError(t, err) + got := reported(stdout.String()) + assert.Equal(t, "drwx------", got["folder_mode"], "only the current user may enter the folder") + assert.Equal(t, "-rw-------", got["file_mode"], "only the current user may read the script") + assert.NoDirExists(t, filepath.Dir(got["script"]), "the temporary folder must be gone after the run") + }) + + t.Run("hands the terminal's input to the tool", func(t *testing.T) { + t.Setenv("FAKE_NODE_READ_STDIN", "1") + var stdout bytes.Buffer + + _, err := runUniversalPortalsScript(universalPortalsRun{ + Node: fakeNode(t, "v22.1.0"), + Script: []byte("x"), + Stdin: strings.NewReader("an answer\n"), + Stdout: &stdout, + }) + + require.NoError(t, err) + assert.Equal(t, "an answer", reported(stdout.String())["stdin"]) + }) + + t.Run("returns the tool's exit code, so the CLI can exit with it", func(t *testing.T) { + t.Setenv("FAKE_NODE_EXIT", "7") + + code, err := runUniversalPortalsScript(universalPortalsRun{Node: fakeNode(t, "v22.1.0"), Script: []byte("x")}) + + require.NoError(t, err) + assert.Equal(t, 7, code) + }) + + t.Run("fails with a clear message when node cannot be started", func(t *testing.T) { + _, err := runUniversalPortalsScript(universalPortalsRun{ + Node: filepath.Join(t.TempDir(), "no-such-node"), + Script: []byte("x"), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to start Node.js") + }) + + t.Run("reports a tool that Ctrl+C stopped with the code a shell would", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("signals are not delivered the same way on Windows") + } + interrupted := filepath.Join(t.TempDir(), "node") + require.NoError(t, os.WriteFile(interrupted, []byte("#!/bin/sh\nkill -INT $$\nsleep 5\n"), 0o755)) + + code, err := runUniversalPortalsScript(universalPortalsRun{Node: interrupted, Script: []byte("x")}) + + require.NoError(t, err) + assert.Equal(t, 130, code, "128 plus the signal number, so scripts can tell it from a failure") + }) + + t.Run("keeps waiting for the tool when Ctrl+C reaches the CLI as well", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("signals are not delivered the same way on Windows") + } + // The terminal sends Ctrl+C to both processes. The CLI's own handler would exit at once and + // cut off the tool's last message, so the runner must outlive the signal and report the + // tool's exit code. + slow := filepath.Join(t.TempDir(), "node") + require.NoError(t, os.WriteFile(slow, []byte("#!/bin/sh\nsleep 1\nexit 3\n"), 0o755)) + time.AfterFunc(200*time.Millisecond, func() { + process, findErr := os.FindProcess(os.Getpid()) + if findErr == nil { + _ = process.Signal(syscall.SIGINT) + } + }) + + code, err := runUniversalPortalsScript(universalPortalsRun{Node: slow, Script: []byte("x")}) + + require.NoError(t, err) + assert.Equal(t, 3, code) + }) +} + +func TestEmbeddedUniversalPortalsScriptRuns(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + if checkUniversalPortalsNodeVersion(node) != nil { + t.Skip("node is older than the tool supports") + } + require.NotEmpty(t, universalPortalsScript, "the bundle is not embedded") + var stdout, stderr bytes.Buffer + + code, err := runUniversalPortalsScript(universalPortalsRun{ + Node: node, + Script: universalPortalsScript, + Args: []string{"--help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + + require.NoError(t, err) + assert.Equal(t, 0, code, stderr.String()) + assert.Contains(t, stdout.String(), "Usage: auth0 universal-portals setup") + assert.Contains(t, stdout.String(), "audit") +} + +func TestUniversalPortalsCommand(t *testing.T) { + cmd := universalPortalsCmd(&cli{}) + + assert.Equal(t, "universal-portals", cmd.Name()) + assert.Contains(t, cmd.Aliases, "up") + + names := []string{} + for _, sub := range cmd.Commands() { + names = append(names, sub.Name()) + // Every flag belongs to the tool, so the CLI must hand all arguments over unparsed. + assert.True(t, sub.DisableFlagParsing, "%s must pass its arguments through", sub.Name()) + assert.NotNil(t, sub.RunE, sub.Name()) + } + assert.ElementsMatch(t, []string{"setup", "audit"}, names) + assert.False(t, cmd.DisableFlagParsing, "the group itself still shows help") +} + +func TestUniversalPortalsRunFor(t *testing.T) { + tests := []struct { + name string + command string + args []string + tenant string + wantArgs []string + wantTenant string + }{ + { + name: "the logged-in tenant is used when no flag names one", + command: "setup", + args: []string{"--template", "business"}, + tenant: "acme.us.auth0.com", + wantArgs: []string{"setup", "--template", "business"}, + wantTenant: "acme.us.auth0.com", + }, + { + name: "--tenant in the arguments wins", + command: "audit", + args: []string{"up_1", "--tenant", "other.eu.auth0.com"}, + tenant: "acme.us.auth0.com", + wantArgs: []string{"audit", "up_1", "--tenant", "other.eu.auth0.com"}, + wantTenant: "other.eu.auth0.com", + }, + { + name: "no tenant at all leaves the choice to the tool", + command: "setup", + wantArgs: []string{"setup"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + run := universalPortalsRunFor(test.command, test.args, test.tenant, "/opt/bin/auth0", "/usr/bin/node") + + assert.Equal(t, test.wantArgs, run.Args) + assert.Equal(t, "/usr/bin/node", run.Node) + assert.NotEmpty(t, run.Script) + assert.Contains(t, run.Env, "AUTH0_CLI_BIN=/opt/bin/auth0") + if test.wantTenant == "" { + for _, entry := range run.Env { + assert.False(t, strings.HasPrefix(entry, "AUTH0_CLI_TENANT_DOMAIN="), entry) + } + return + } + assert.Contains(t, run.Env, "AUTH0_CLI_TENANT_DOMAIN="+test.wantTenant) + }) + } +} diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 76134cc96..8b484fc2b 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -337,52 +337,6 @@ func openManageURL(cli *cli, tenant string, path string) { } } -// missingScopes returns the elements of required absent from granted. -func missingScopes(granted, required []string) []string { - have := make(map[string]struct{}, len(granted)) - for _, s := range granted { - have[s] = struct{}{} - } - var missing []string - for _, s := range required { - if _, ok := have[s]; !ok { - missing = append(missing, s) - } - } - return missing -} - -// scopesFromToken returns the granted scopes decoded from a JWT access token's -// payload, or nil if the token is malformed or carries no scope claim (callers -// then skip the pre-flight check and let the API surface a 403). -func scopesFromToken(tokenStr string) []string { - parts := strings.SplitN(tokenStr, ".", 3) - if len(parts) != 3 { - return nil - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil - } - var claims struct { - Scope string `json:"scope"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return nil - } - return strings.Fields(claims.Scope) -} - -// deriveServiceURL returns the base URL for a service (e.g. "manage") from the -// tenant domain, dropping the 2-letter region code that public envs carry. -func deriveServiceURL(service, tenantDomain string) string { - parts := strings.Split(tenantDomain, ".") - if len(parts) >= 4 && len(parts[1]) == 2 { - return "https://" + service + "." + strings.Join(parts[2:], ".") - } - return "https://" + service + "." + strings.Join(parts[1:], ".") -} - func formatManageTenantURL(tenant string, cfg *config.Config) string { if len(tenant) == 0 { return "" From df4e14634fa700d35a3e56d1d5f2d585eeeaa230 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 17:21:44 +0100 Subject: [PATCH 2/6] test(cli): fail when the embedded tool is out of date The binary ships the embedded JavaScript file, so a file older than its sources would ship old code. A test recomputes a hash of the lockfile, the build script and the sources, and compares it with the hash the build wrote into the first lines of the file. The hash definition is pinned with a hand-computed value that the tool's own tests check too, so the two implementations cannot drift apart. A second test fails if plugins.go appears in this folder. The plugin framework skips a plugin whose name matches a built-in command, so once the framework is here the embedded command has to go first, and the message points at the list of what to delete. Co-Authored-By: Claude --- internal/cli/universal_portals_bundle_test.go | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/cli/universal_portals_bundle_test.go diff --git a/internal/cli/universal_portals_bundle_test.go b/internal/cli/universal_portals_bundle_test.go new file mode 100644 index 000000000..2a6911da4 --- /dev/null +++ b/internal/cli/universal_portals_bundle_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const universalPortalsSourceDir = "data/universal-portals/_source" + +// universalPortalsSourceHash is a hash of everything the embedded file is built from: the lockfile, +// the build script and every file under src. The tool's own tests compute the same value, and the +// file records it in its first lines. Paths use "/" and are sorted, and line endings are LF, so +// the value is the same on every machine. +func universalPortalsSourceHash(t *testing.T, root string) string { + t.Helper() + + files := []string{"package-lock.json", "scripts/bundle.mjs"} + err := filepath.WalkDir(filepath.Join(root, "src"), func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() { + return walkErr + } + relative, relErr := filepath.Rel(root, path) + files = append(files, filepath.ToSlash(relative)) + return relErr + }) + require.NoError(t, err) + sort.Strings(files) + + hash := sha256.New() + for _, file := range files { + content, readErr := os.ReadFile(filepath.Join(root, filepath.FromSlash(file))) + require.NoError(t, readErr) + text := strings.ReplaceAll(string(content), "\r\n", "\n") + hash.Write([]byte(file + "\x00" + text + "\x00")) + } + + return hex.EncodeToString(hash.Sum(nil)) +} + +// This pins the definition above. The tool's tests check the same value, written out by hand, so +// the two implementations cannot drift apart. +func TestUniversalPortalsSourceHashDefinition(t *testing.T) { + root := t.TempDir() + for name, text := range map[string]string{ + "package-lock.json": "{}", + "scripts/bundle.mjs": "x", + "src/a.ts": "a", + "src/deep/b.ts": "b", + "test/ignored.ts": "not part of the hash", + } { + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, name), []byte(text), 0o644)) + } + + want := sha256.Sum256([]byte("package-lock.json\x00{}\x00scripts/bundle.mjs\x00x\x00src/a.ts\x00a\x00src/deep/b.ts\x00b\x00")) + + assert.Equal(t, hex.EncodeToString(want[:]), universalPortalsSourceHash(t, root)) +} + +// The binary ships the embedded file, so a file that is older than its sources would ship old code. +func TestEmbeddedUniversalPortalsScriptMatchesItsSources(t *testing.T) { + if _, err := os.Stat(filepath.Join(universalPortalsSourceDir, "src")); errors.Is(err, fs.ErrNotExist) { + t.Skip("the sources are not in this checkout") + } + + head := string(universalPortalsScript) + if len(head) > 600 { + head = head[:600] + } + match := regexp.MustCompile(`(?m)^// source-sha256: ([0-9a-f]{64})$`).FindStringSubmatch(head) + require.NotNil(t, match, "the embedded file does not start with its source hash") + + assert.Equal(t, + match[1], + universalPortalsSourceHash(t, universalPortalsSourceDir), + `the sources changed after the embedded file was built: run "npm run bundle" in %s and commit the result`, + universalPortalsSourceDir, + ) +} + +// The plugin framework skips a plugin whose name matches a built-in command. Once the framework is on +// this branch, the embedded command must go, or the installed plugin is silently ignored. +func TestUniversalPortalsCommandIsRemovedWhenThePluginFrameworkArrives(t *testing.T) { + _, err := os.Stat("plugins.go") + + require.True(t, errors.Is(err, fs.ErrNotExist), + "plugins.go exists, so the plugin framework is on this branch. Remove the embedded universal-portals "+ + "command first: the list is at the top of universal_portals.go") +} From 8a3bc4a5b83749d249904f8ccba37e8e9682d35c Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 17:22:49 +0100 Subject: [PATCH 3/6] docs: regenerate the universal-portals command docs Run `make docs` for the new universal-portals commands. The setup page no longer lists flags, because the tool handles them and shows them with --help. The audit command has a new page. Co-Authored-By: Claude --- docs/auth0_universal-portals.md | 8 +++-- docs/auth0_universal-portals_audit.md | 43 +++++++++++++++++++++++++++ docs/auth0_universal-portals_setup.md | 19 ++++-------- docs/index.md | 2 +- 4 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 docs/auth0_universal-portals_audit.md diff --git a/docs/auth0_universal-portals.md b/docs/auth0_universal-portals.md index ab905bfbd..e0879e43a 100644 --- a/docs/auth0_universal-portals.md +++ b/docs/auth0_universal-portals.md @@ -5,9 +5,13 @@ has_children: true --- # auth0 universal-portals -Manage Auth0 Universal Portals resources. +Set up and audit Auth0 Universal Portals. + +These commands run a tool that is built into the CLI. It needs Node.js 20.12 or newer, and it calls the +Auth0 Management API as the tenant you are logged in to. ## Commands -- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/auth0_universal-portals_audit.md b/docs/auth0_universal-portals_audit.md new file mode 100644 index 000000000..ee4e50dc2 --- /dev/null +++ b/docs/auth0_universal-portals_audit.md @@ -0,0 +1,43 @@ +--- +layout: default +parent: auth0 universal-portals +has_toc: false +--- +# auth0 universal-portals audit + +Checks a Universal Portal and its application against the setup defaults, and fixes the errors the tool is able to fix. + +All flags are handled by the tool. Run "auth0 universal-portals audit --help" for the list. + +## Usage +``` +auth0 universal-portals audit [flags] +``` + +## Examples + +``` + auth0 universal-portals audit up_123 + auth0 universal-portals audit up_123 --fix + auth0 up audit up_123 --organization org_123 --strict --json +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal + + diff --git a/docs/auth0_universal-portals_setup.md b/docs/auth0_universal-portals_setup.md index 23c70fdf6..09a3bec8c 100644 --- a/docs/auth0_universal-portals_setup.md +++ b/docs/auth0_universal-portals_setup.md @@ -5,11 +5,9 @@ has_toc: false --- # auth0 universal-portals setup -Provisions the Auth0 resources required by a Universal Portals application: +Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs. - - Auth0 My Account API and My Organization API (resource servers) - - A Regular Web App client with the required configuration - - Client grants for My Account API, My Organization API, and the Management API +All flags are handled by the tool. Run "auth0 universal-portals setup --help" for the list. ## Usage ``` @@ -20,17 +18,11 @@ 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 --no-input --template consumer --name "Acme Account" --json ``` -## Flags - -``` - -n, --name string Display name of the portal. - -s, --slug string URL-friendly identifier for the portal (e.g. my-portal). -``` ## Inherited Flags @@ -45,6 +37,7 @@ auth0 universal-portals setup [flags] ## Related Commands -- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/index.md b/docs/index.md index 29ca5e41d..7a47f03d6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,6 +105,6 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead - [auth0 test](auth0_test.md) - Try your Universal Login box or get a token - [auth0 token-exchange](auth0_token-exchange.md) - Manage token exchange profiles - [auth0 universal-login](auth0_universal-login.md) - Manage the Universal Login experience -- [auth0 universal-portals](auth0_universal-portals.md) - Manage Universal Portals resources +- [auth0 universal-portals](auth0_universal-portals.md) - Set up and audit Universal Portals - [auth0 users](auth0_users.md) - Manage resources for users From 1fb971bf17ba3c1cae68f5c22c890f7cb6ee2272 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 18:31:27 +0100 Subject: [PATCH 4/6] refactor(cli): tidy the universal-portals command Remove an exit hook that no test replaced, and the second Node probe. The command asked ACUL's check whether node runs, then looked it up on the PATH again and checked its version, so Node was started twice and the lookup error could not be reached. ACUL's message also asked for Node v22, but this command runs on 20.12. Now there is one lookup and one version check, and the message names the version this command needs. A test covers the missing-Node case. The header comment is shorter and keeps only what is needed to remove the command. Co-Authored-By: Claude --- internal/cli/universal_portals.go | 26 +++++++------------ internal/cli/universal_portals_runner_test.go | 11 ++++++++ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index 7c47deb14..479c52c43 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -8,17 +8,10 @@ import ( "github.com/spf13/cobra" ) -// Temporary. These commands run a Node.js tool that is built into the CLI, so that Universal Portals -// can be used before the plugin framework is available on this branch. When the framework installs -// the tool as a plugin, remove this: -// - universal_portals*.go in this folder, -// - the data/universal-portals folder, and its section in data/README.md, -// - the line in root.go that adds universalPortalsCmd, -// then run "make docs". A test fails if plugins.go appears, so this is not forgotten: the framework -// skips a plugin whose name matches a built-in command, and the plugin would be ignored. - -// universalPortalsExit ends the process with the tool's exit code. Tests replace it. -var universalPortalsExit = os.Exit +// Temporary: these commands run a Node.js tool built into the CLI, until the plugin framework can +// install it. When it can, remove universal_portals*.go, the data/universal-portals folder (and its +// section in data/README.md) and the universalPortalsCmd line in root.go, then run "make docs". +// The framework ignores a plugin named like a built-in command, so a test fails once plugins.go exists. // universalPortalsCmd groups the Universal Portals commands. func universalPortalsCmd(cli *cli) *cobra.Command { @@ -71,13 +64,12 @@ func universalPortalsToolCmd(cli *cli, name, short, long, example string) *cobra } func runUniversalPortals(cli *cli, command string, args []string) error { - if err := checkNodeInstallation(); err != nil { - return err - } - node, err := exec.LookPath("node") if err != nil { - return fmt.Errorf("node is required but was not found on the PATH: %w", err) + return fmt.Errorf( + "node is required but was not found. Install Node.js %d.%d or newer and try again", + universalPortalsNodeMajor, universalPortalsNodeMinor, + ) } if err := checkUniversalPortalsNodeVersion(node); err != nil { @@ -100,7 +92,7 @@ func runUniversalPortals(cli *cli, command string, args []string) error { // The tool has already shown its own message, so the CLI only passes on the exit code. if code != 0 { - universalPortalsExit(code) + os.Exit(code) } return nil diff --git a/internal/cli/universal_portals_runner_test.go b/internal/cli/universal_portals_runner_test.go index e673a341d..9435a1811 100644 --- a/internal/cli/universal_portals_runner_test.go +++ b/internal/cli/universal_portals_runner_test.go @@ -311,3 +311,14 @@ func TestUniversalPortalsRunFor(t *testing.T) { }) } } + +func TestRunUniversalPortalsWithoutNode(t *testing.T) { + // Nothing is on the PATH, so nothing can be started and the user is told what to install. + t.Setenv("PATH", t.TempDir()) + + err := runUniversalPortals(&cli{}, "setup", []string{"--json"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "node is required but was not found") + assert.Contains(t, err.Error(), "Node.js 20.12 or newer", "the message must name the version this command needs") +} From c626a314e13c73a2c9cefde8fac1413a84303f0e Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 18:46:37 +0100 Subject: [PATCH 5/6] test(cli): make the Ctrl+C test wait for the tool The test sent SIGINT after a fixed 200 ms, which could land before the fake tool had started on a slow machine. The tool now creates a marker file when it starts and waits for the test to release it, so the signal always arrives while the tool is running. Also rename the tripwire test so no Go name mentions a plugin. Co-Authored-By: Claude --- internal/cli/universal_portals_bundle_test.go | 2 +- internal/cli/universal_portals_runner_test.go | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/cli/universal_portals_bundle_test.go b/internal/cli/universal_portals_bundle_test.go index 2a6911da4..8e0a7bb27 100644 --- a/internal/cli/universal_portals_bundle_test.go +++ b/internal/cli/universal_portals_bundle_test.go @@ -91,7 +91,7 @@ func TestEmbeddedUniversalPortalsScriptMatchesItsSources(t *testing.T) { // The plugin framework skips a plugin whose name matches a built-in command. Once the framework is on // this branch, the embedded command must go, or the installed plugin is silently ignored. -func TestUniversalPortalsCommandIsRemovedWhenThePluginFrameworkArrives(t *testing.T) { +func TestUniversalPortalsCommandIsRemovedWhenTheFrameworkArrives(t *testing.T) { _, err := os.Stat("plugins.go") require.True(t, errors.Is(err, fs.ErrNotExist), diff --git a/internal/cli/universal_portals_runner_test.go b/internal/cli/universal_portals_runner_test.go index 9435a1811..e1ef9cc27 100644 --- a/internal/cli/universal_portals_runner_test.go +++ b/internal/cli/universal_portals_runner_test.go @@ -203,14 +203,27 @@ func TestRunUniversalPortalsScript(t *testing.T) { // The terminal sends Ctrl+C to both processes. The CLI's own handler would exit at once and // cut off the tool's last message, so the runner must outlive the signal and report the // tool's exit code. - slow := filepath.Join(t.TempDir(), "node") - require.NoError(t, os.WriteFile(slow, []byte("#!/bin/sh\nsleep 1\nexit 3\n"), 0o755)) - time.AfterFunc(200*time.Millisecond, func() { + dir := t.TempDir() + started, released := filepath.Join(dir, "started"), filepath.Join(dir, "released") + slow := filepath.Join(dir, "node") + // The tool says it has started, then waits until the test has sent the signal (or gives up + // after about ten seconds, so a broken test cannot hang the run). + fakeTool := "#!/bin/sh\ntouch '" + started + "'\ni=0\n" + + "while [ ! -f '" + released + "' ] && [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done\nexit 3\n" + require.NoError(t, os.WriteFile(slow, []byte(fakeTool), 0o755)) + go func() { + for i := 0; i < 200; i++ { + if _, statErr := os.Stat(started); statErr == nil { + break + } + time.Sleep(50 * time.Millisecond) + } process, findErr := os.FindProcess(os.Getpid()) if findErr == nil { _ = process.Signal(syscall.SIGINT) } - }) + _ = os.WriteFile(released, nil, 0o600) + }() code, err := runUniversalPortalsScript(universalPortalsRun{Node: slow, Script: []byte("x")}) From 3054aa7c10970abca1fe997eccce56e9368f4026 Mon Sep 17 00:00:00 2001 From: Bharath Natarajan Date: Fri, 30 Oct 2026 15:05:42 +0530 Subject: [PATCH 6/6] feat(portals): add --dry-run to universal-portals setup Plans the setup without writing to the tenant. GET requests run as usual; every other request is recorded and printed in order with client secrets redacted and placeholder IDs for resources that would be created. With --json the output is the setup result plus dryRun and requests. A dry run still needs a login and reads the tenant to decide created versus reused, and it skips the create-scope probes, so it can pass where a real run stops with missing_scopes. Co-Authored-By: Claude --- docs/auth0_universal-portals_setup.md | 1 + .../universal-portals/_source/src/args.ts | 5 ++ .../universal-portals/_source/src/dry-run.ts | 63 ++++++++++++++++ .../_source/src/setup-command.ts | 27 ++++++- .../_source/src/setup/run.ts | 7 +- .../_source/test/cli.test.ts | 57 +++++++++++++++ .../_source/test/run.test.ts | 65 +++++++++++++++++ .../universal-portals/universal-portals.mjs | 72 +++++++++++++++++-- internal/cli/universal_portals.go | 1 + 9 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 internal/cli/data/universal-portals/_source/src/dry-run.ts diff --git a/docs/auth0_universal-portals_setup.md b/docs/auth0_universal-portals_setup.md index 09a3bec8c..0c4e0724c 100644 --- a/docs/auth0_universal-portals_setup.md +++ b/docs/auth0_universal-portals_setup.md @@ -19,6 +19,7 @@ auth0 universal-portals setup [flags] ``` auth0 universal-portals setup auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 up setup --dry-run --template consumer --name "Acme Account" --json auth0 up setup --no-input --template consumer --name "Acme Account" --json ``` diff --git a/internal/cli/data/universal-portals/_source/src/args.ts b/internal/cli/data/universal-portals/_source/src/args.ts index 1e2b706a4..df4eab241 100644 --- a/internal/cli/data/universal-portals/_source/src/args.ts +++ b/internal/cli/data/universal-portals/_source/src/args.ts @@ -24,6 +24,8 @@ export interface ParsedSetupArgs { organization?: string; json: boolean; noInput: boolean; + // Setup only: show what a run would create and change, without writing to the tenant. + dryRun?: boolean; } // Parses the arguments the plugin receives from the CLI. Interactive questions @@ -44,6 +46,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { organization: { type: "string", short: "o" }, json: { type: "boolean" }, "no-input": { type: "boolean" }, + "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" }, strict: { type: "boolean" }, fix: { type: "boolean" }, @@ -101,6 +104,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { organization: values.organization, json: Boolean(values.json), noInput: Boolean(values["no-input"]), + dryRun: Boolean(values["dry-run"]), }; } @@ -126,6 +130,7 @@ export function usage(): string { " -u, --user Business only: the user who will test the portal.", " -o, --organization Business only: the organization, when the user has several.", " --no-input Never prompt; fail on anything missing.", + " --dry-run Show what setup would create, without changing the tenant.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", "", diff --git a/internal/cli/data/universal-portals/_source/src/dry-run.ts b/internal/cli/data/universal-portals/_source/src/dry-run.ts new file mode 100644 index 000000000..c4deb1951 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/dry-run.ts @@ -0,0 +1,63 @@ +import type { ApiClient, HttpMethod } from "./api.js"; + +// A request a real run would send. Secrets in the body are replaced by CLIENT_SECRET_PLACEHOLDER. +export interface PlannedRequest { + method: Exclude; + path: string; + body?: unknown; +} + +export const CLIENT_SECRET_PLACEHOLDER = ""; + +// Sends every read to the tenant and records each other request instead of sending it. Setup runs +// as it does for real, so the created, reused and updated decisions and the payloads all come from +// the same code. The IDs of resources a real run would create are placeholders, and a read about one +// of them returns an empty list, because that resource does not exist yet. +export function planWrites(api: ApiClient): { api: ApiClient; requests: () => PlannedRequest[] } { + const requests: PlannedRequest[] = []; + const placeholders: string[] = []; + let counter = 0; + + return { + api: { + async request(method: HttpMethod, path: string, body?: unknown): Promise { + if (method === "GET") { + if (placeholders.some((id) => path.includes(encodeURIComponent(id)))) return [] as T; + return api.request(method, path, body); + } + + requests.push({ method: method as PlannedRequest["method"], path, body: redact(body) }); + if (method !== "POST") return {} as T; + + const id = ``; + placeholders.push(id); + return placeholderResponse(path, body, id) as T; + }, + }, + requests: () => JSON.parse(JSON.stringify(requests)) as PlannedRequest[], + }; +} + +// The part of a create response that setup reads back: the ID, and for clients the secret. +function placeholderResponse(path: string, body: unknown, id: string): unknown { + const fields = isRecord(body) ? body : {}; + if (path === "clients") return { ...fields, client_id: id, client_secret: CLIENT_SECRET_PLACEHOLDER }; + if (path === "forms/import") return { form: { id, nodes: [] }, flows: [] }; + return { ...fields, id }; +} + +// Copies the value and replaces every client secret, so a secret read from the tenant for a reused +// application can never reach the output. +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, key === "client_secret" ? CLIENT_SECRET_PLACEHOLDER : redact(item)]), + ); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup-command.ts b/internal/cli/data/universal-portals/_source/src/setup-command.ts index 901767a0b..8752f46c4 100644 --- a/internal/cli/data/universal-portals/_source/src/setup-command.ts +++ b/internal/cli/data/universal-portals/_source/src/setup-command.ts @@ -4,8 +4,9 @@ import { openBrowser } from "./browser.js"; import { CancelledError, type Prompter } from "./prompt.js"; import type { TemplateId } from "./templates/types.js"; import type { IoStreams } from "./cli.js"; +import { type PlannedRequest, planWrites } from "./dry-run.js"; import { reportError } from "./errors.js"; -import { runSetup, SetupOptions, SlugRaceError } from "./setup/run.js"; +import { runSetup, SetupOptions, SetupResult, SlugRaceError } from "./setup/run.js"; import { toPortalSlug } from "./slug.js"; import { trackWrites } from "./write-tracker.js"; import type { Ui } from "./ui.js"; @@ -28,19 +29,24 @@ export async function runSetupCommand( } } const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run })); + // A dry run reads the tenant as a real run does, but keeps the requests that would change it. + const planned = args.dryRun ? planWrites(tracked.api) : undefined; ui.banner("Universal Portals setup", context.tenantDomain); + if (planned) ui.info(DRY_RUN_NOTE); try { const options = prompter ? await askForMissing(args, prompter, context.tenantDomain) : optionsFromArgs(args); const result = await runSetup(options, { - api: tracked.api, + api: planned?.api ?? tracked.api, ui, prompt: prompter ?? unansweredPrompter(), interactive, tenantDomain: context.tenantDomain, + dryRun: planned !== undefined, }); + if (planned) return reportPlan(result, planned.requests(), args.json, io, ui); if (args.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return 0; @@ -65,6 +71,23 @@ export async function runSetupCommand( } } +// The statuses in a dry run are what a real run would report. Writes are not checked here, so +// the note says so: a real run is the only check of the permissions for creating things. +const DRY_RUN_NOTE = + "Dry run: nothing is created or changed. Statuses show what a real run would do. Write permissions are checked by a real run only."; + +// The result of a real run, plus the requests it would send in the order it would send them. The +// portal is not opened, because nothing was created. +function reportPlan(result: SetupResult, requests: PlannedRequest[], json: boolean, io: IoStreams, ui: Ui): number { + if (json) { + io.stdout.write(`${JSON.stringify({ dryRun: true, ...result, requests }, null, 2)}\n`); + return 0; + } + ui.result(`Dry run complete. ${requests.length} request(s) would change the tenant.`); + ui.detail("Run the same command without --dry-run to set up the portal."); + return 0; +} + // Enter opens the portal. "n" or Ctrl+C means no: the portal is already set up, // so cancelling this question is not a cancelled setup. async function declinesToOpen(prompter: Prompter): Promise { diff --git a/internal/cli/data/universal-portals/_source/src/setup/run.ts b/internal/cli/data/universal-portals/_source/src/setup/run.ts index bf04f720a..640678949 100644 --- a/internal/cli/data/universal-portals/_source/src/setup/run.ts +++ b/internal/cli/data/universal-portals/_source/src/setup/run.ts @@ -76,6 +76,8 @@ export interface SetupDeps { prompt: Prompter; interactive: boolean; tenantDomain: string; + // A dry run only reads, so the probes that send writes are skipped. Their scopes are not checked. + dryRun?: boolean; } export interface SetupResult { @@ -92,7 +94,10 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise< const resources: SetupResult["resources"] = []; const { ui, api, tenantDomain } = deps; - const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); + const probes = SETUP_PROBES.filter( + (probe) => + (!probe.businessOnly || options.template === "business") && (!deps.dryRun || probe.method === "GET"), + ); await checkPermissions(probes, deps); let slug = options.slug; diff --git a/internal/cli/data/universal-portals/_source/test/cli.test.ts b/internal/cli/data/universal-portals/_source/test/cli.test.ts index d5a58f415..aaf63c677 100644 --- a/internal/cli/data/universal-portals/_source/test/cli.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -82,6 +82,11 @@ test("parses the setup flags", () => { assert.equal(args.json, true); }); +test("--dry-run is a setup flag and is off unless it is given", () => { + assert.equal(parseSetupArgs(["setup", "--dry-run"]).dryRun, true); + assert.equal(parseSetupArgs(["setup"]).dryRun, false); +}); + test("rejects an unknown template and an unknown command", () => { assert.throws(() => parseSetupArgs(["setup", "--template", "both"]), UsageError); assert.throws(() => parseSetupArgs(["publish"]), /Unknown command "publish"/); @@ -381,6 +386,58 @@ test("a non-interactive consumer run prints the result as JSON and never the ful assert.ok(!stdout().includes(secret) && !stderr().includes(secret), "the full secret must not be printed"); }); +test("a dry run prints the plan as JSON and changes nothing in the tenant", async () => { + const tenant = new FakeTenant(); + const { io, stdout } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--dry-run", "--no-input", "--json", "--template", "consumer", "--name", "Acme Account"], + ENV, + io, + ); + + assert.equal(code, 0); + const plan = JSON.parse(stdout()); + assert.equal(plan.dryRun, true); + assert.equal(plan.portal.status, "created"); + assert.equal(plan.portal.url, `https://${DOMAIN}/portals/acme-account`); + assert.ok(plan.requests.some((request: { method: string; path: string }) => request.method === "POST" && request.path === "portals")); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []); + assert.equal(tenant.portals.size + tenant.clients.size, 0); +}); + +test("a dry run without --json writes its summary to stderr and nothing to stdout", async () => { + const tenant = new FakeTenant(); + const { io, stdout, stderr } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--dry-run", "--no-input", "--template", "consumer", "--name", "Acme Account"], ENV, io); + + assert.equal(code, 0); + assert.equal(stdout(), ""); + assert.match(stderr(), /Dry run complete/); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []); +}); + +test("a dry run with missing flags gives the same usage error as a real run", async () => { + const tenant = new FakeTenant(); + const { io, stderr } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--dry-run", "--no-input", "--template", "consumer"], ENV, io); + + assert.equal(code, 2); + assert.match(stderr(), /Missing required flags for a non-interactive run: --name/); + assert.equal(tenant.calls.length, 0); +}); + +test("a dry run without a login stops with the login error and reads nothing", async () => { + const tenant = new FakeTenant(); + const { io, stdout, stderr } = captureIo(fetchFor(tenant)); + io.run = async () => ({ exitCode: 1, stdout: "", stderr: "Error: failed to find tenant" }); + const code = await main(["setup", "--dry-run", "--json", "--template", "consumer", "--name", "Acme Account"], {}, io); + + assert.equal(code, 1); + assert.equal(stdout(), ""); + assert.equal(JSON.parse(stderr()).error, "auth0_login_required"); + assert.equal(tenant.calls.length, 0); +}); + test("in agent mode a missing scope is reported as a JSON error on stderr", async () => { const tenant = new FakeTenant(); tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); diff --git a/internal/cli/data/universal-portals/_source/test/run.test.ts b/internal/cli/data/universal-portals/_source/test/run.test.ts index 0803332d3..f0d0d37a4 100644 --- a/internal/cli/data/universal-portals/_source/test/run.test.ts +++ b/internal/cli/data/universal-portals/_source/test/run.test.ts @@ -7,6 +7,7 @@ import { BusinessAccessError } from "../src/setup/business-types.js"; import { MissingScopesError, runSetup, SetupDeps, SetupOptions } from "../src/setup/run.js"; import { FORMS_VAULT_NAME } from "../src/setup/vault.js"; import { FORMS_VAULT_SCOPES, MY_ACCOUNT_SCOPES, MY_ORGANIZATION_SCOPES } from "../src/setup/payload.js"; +import { CLIENT_SECRET_PLACEHOLDER, planWrites } from "../src/dry-run.js"; import { FakeTenant } from "./fake-tenant.js"; import { captureOutput } from "./capture-output.js"; @@ -394,3 +395,67 @@ test("leaving the organization empty puts a placeholder in the link", async () = assert.equal(result.portal.url, `https://${DOMAIN}/portals/acme-admin?organization=`); }); + +test("a dry run reports what a new consumer portal would create and sends no writes", async () => { + const tenant = new FakeTenant(); + const plan = planWrites(tenant); + const { deps } = harness(tenant, { api: plan.api, dryRun: true }); + const result = await runSetup(CONSUMER, deps); + + assert.equal(result.portal.status, "created"); + assert.equal(result.application.status, "created"); + assert.ok(result.resources.every((resource) => resource.status === "created")); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), [], "a dry run must not write"); + assert.equal(tenant.portals.size + tenant.clients.size + tenant.forms.size + tenant.vaults.size, 0); + + const requests = plan.requests().map((request) => `${request.method} ${request.path}`); + for (const expected of ["POST resource-servers", "POST clients", "POST client-grants", "POST forms/import", "POST portals"]) { + assert.ok(requests.includes(expected), `the plan includes ${expected}`); + } + assert.equal( + plan.requests().filter((request) => JSON.stringify(request.body) === "{}").length, + 0, + "the permission probes that send writes are not sent in a dry run", + ); +}); + +test("a dry run after setup reuses what exists and plans the portal with a placeholder secret", async () => { + const tenant = new FakeTenant(); + await runSetup(CONSUMER, harness(tenant).deps); + const secret = [...tenant.clients.values()].find((client) => client.name?.startsWith("Acme Account"))?.client_secret; + assert.ok(secret); + tenant.portals.clear(); + const callsBefore = tenant.calls.length; + + const plan = planWrites(tenant); + const { deps, output } = harness(tenant, { api: plan.api, dryRun: true }); + const result = await runSetup(CONSUMER, deps); + + assert.equal(result.application.status, "reused"); + assert.equal(result.portal.status, "created"); + assert.deepEqual(tenant.calls.slice(callsBefore).filter((call) => !call.startsWith("GET ")), []); + + const planned = plan.requests(); + assert.ok(planned.some((request) => request.method === "PATCH" && request.path.startsWith("clients/"))); + const portalRequest = planned.find((request) => request.method === "POST" && request.path === "portals"); + assert.equal((portalRequest?.body as { client: { client_secret: string } }).client.client_secret, CLIENT_SECRET_PLACEHOLDER); + assert.ok(!JSON.stringify(planned).includes(secret), "the planned requests never carry the real secret"); + assert.ok(!output().includes(secret), "the output never shows the real secret"); +}); + +test("a dry run of a business portal plans its organization access and writes nothing", async () => { + const tenant = new FakeTenant(); + seedBusinessUser(tenant, { withOrg: true, withRole: false }); + const plan = planWrites(tenant); + const { deps } = harness(tenant, { api: plan.api, dryRun: true }); + const result = await runSetup( + { ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin", userQuery: "jane@acme.com" }, + deps, + ); + + assert.equal(result.organization?.id, "org_1"); + assert.equal(result.organization?.hasRole, false); + assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), [], "a dry run must not write"); + assert.equal(tenant.portals.size, 0); + assert.ok(plan.requests().some((request) => request.path === "resource-servers")); +}); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index ae35bd3e5..8f941d5e8 100755 --- a/internal/cli/data/universal-portals/universal-portals.mjs +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generated by "npm run bundle" in the _source folder. Do not edit. -// source-sha256: 229a16a14dba7461826d1b828358f469e9f41e78b501a4dc5ffabb54f89513f9 +// source-sha256: 4db9c1862b6dc457ba8738cbeb2dbb9d5491b005a5f44ffa312c9d6421f09103 import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create; @@ -128,6 +128,7 @@ function parseSetupArgs(argv) { organization: { type: "string", short: "o" }, json: { type: "boolean" }, "no-input": { type: "boolean" }, + "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" }, strict: { type: "boolean" }, fix: { type: "boolean" } @@ -181,7 +182,8 @@ function parseSetupArgs(argv) { user: values.user, organization: values.organization, json: Boolean(values.json), - noInput: Boolean(values["no-input"]) + noInput: Boolean(values["no-input"]), + dryRun: Boolean(values["dry-run"]) }; } function missingFlags(args) { @@ -204,6 +206,7 @@ function usage() { " -u, --user Business only: the user who will test the portal.", " -o, --organization Business only: the organization, when the user has several.", " --no-input Never prompt; fail on anything missing.", + " --dry-run Show what setup would create, without changing the tenant.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", "", @@ -3304,6 +3307,48 @@ function openBrowser(url, platform = process.platform) { child.unref(); } +// src/dry-run.ts +var CLIENT_SECRET_PLACEHOLDER = ""; +function planWrites(api) { + const requests = []; + const placeholders = []; + let counter = 0; + return { + api: { + async request(method, path, body) { + if (method === "GET") { + if (placeholders.some((id2) => path.includes(encodeURIComponent(id2)))) return []; + return api.request(method, path, body); + } + requests.push({ method, path, body: redact(body) }); + if (method !== "POST") return {}; + const id = ``; + placeholders.push(id); + return placeholderResponse(path, body, id); + } + }, + requests: () => JSON.parse(JSON.stringify(requests)) + }; +} +function placeholderResponse(path, body, id) { + const fields = isRecord2(body) ? body : {}; + if (path === "clients") return { ...fields, client_id: id, client_secret: CLIENT_SECRET_PLACEHOLDER }; + if (path === "forms/import") return { form: { id, nodes: [] }, flows: [] }; + return { ...fields, id }; +} +function redact(value) { + if (Array.isArray(value)) return value.map(redact); + if (isRecord2(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, key === "client_secret" ? CLIENT_SECRET_PLACEHOLDER : redact(item)]) + ); + } + return value; +} +function isRecord2(value) { + return typeof value === "object" && value !== null; +} + // src/dashboard.ts function tenantLocation(tenantDomain) { const parts = tenantDomain.split("."); @@ -3902,7 +3947,9 @@ var MissingScopesError = class extends PluginError { async function runSetup(options, deps) { const resources = []; const { ui, api, tenantDomain } = deps; - const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); + const probes = SETUP_PROBES.filter( + (probe) => (!probe.businessOnly || options.template === "business") && (!deps.dryRun || probe.method === "GET") + ); await checkPermissions(probes, deps); let slug = options.slug; if (!isValidPortalSlug(slug)) throw new Error(`The slug "${slug}" must match ^[a-z][a-z0-9-]{0,99}$.`); @@ -4091,16 +4138,20 @@ async function runSetupCommand(args, env, io, ui, interactive, prompter) { } } const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run })); + const planned = args.dryRun ? planWrites(tracked.api) : void 0; ui.banner("Universal Portals setup", context.tenantDomain); + if (planned) ui.info(DRY_RUN_NOTE); try { const options = prompter ? await askForMissing(args, prompter, context.tenantDomain) : optionsFromArgs(args); const result = await runSetup(options, { - api: tracked.api, + api: planned?.api ?? tracked.api, ui, prompt: prompter ?? unansweredPrompter(), interactive, - tenantDomain: context.tenantDomain + tenantDomain: context.tenantDomain, + dryRun: planned !== void 0 }); + if (planned) return reportPlan(result, planned.requests(), args.json, io, ui); if (args.json) { io.stdout.write(`${JSON.stringify(result, null, 2)} `); @@ -4123,6 +4174,17 @@ async function runSetupCommand(args, env, io, ui, interactive, prompter) { return reportError(error, args.json, io, ui); } } +var DRY_RUN_NOTE = "Dry run: nothing is created or changed. Statuses show what a real run would do. Write permissions are checked by a real run only."; +function reportPlan(result, requests, json, io, ui) { + if (json) { + io.stdout.write(`${JSON.stringify({ dryRun: true, ...result, requests }, null, 2)} +`); + return 0; + } + ui.result(`Dry run complete. ${requests.length} request(s) would change the tenant.`); + ui.detail("Run the same command without --dry-run to set up the portal."); + return 0; +} async function declinesToOpen(prompter) { try { return !await prompter.confirm("Open the portal in your browser?", true); diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index 479c52c43..ab0ebcf86 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -34,6 +34,7 @@ Auth0 Management API as the tenant you are logged in to.`, All flags are handled by the tool. Run "auth0 universal-portals setup --help" for the list.`, ` auth0 universal-portals setup auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 up setup --dry-run --template consumer --name "Acme Account" --json auth0 up setup --no-input --template consumer --name "Acme Account" --json`), universalPortalsToolCmd(cli, "audit", "Check a Universal Portal",