From fdd88e5f3badfd2339c2314b43205e0a6a94fd53 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 17:20:42 +0100 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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", From fcc5f7aca7222b0f8f975321143c334a160a029c Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Thu, 5 Nov 2026 18:19:43 +0530 Subject: [PATCH 7/8] feat(fix): enhance error handling for missing scopes in applyFixes --- .../data/universal-portals/_source/src/api.ts | 3 +- .../universal-portals/_source/src/args.ts | 15 +- .../_source/src/audit-command.ts | 9 +- .../_source/src/audit/fix.ts | 20 +- .../universal-portals/_source/src/cli-api.ts | 32 +- .../data/universal-portals/_source/src/cli.ts | 4 +- .../_source/src/scope-fixes.ts | 15 +- .../universal-portals/_source/src/scopes.ts | 11 +- .../_source/src/setup-command.ts | 9 +- .../_source/src/setup/preflight.ts | 50 ++- .../_source/src/setup/run.ts | 58 ++- .../_source/test/audit-fix.test.ts | 19 +- .../_source/test/cli-api.test.ts | 22 +- .../_source/test/cli.test.ts | 35 +- .../_source/test/errors.test.ts | 7 +- .../_source/test/fake-tenant.ts | 3 +- .../_source/test/preflight.test.ts | 14 + .../_source/test/run.test.ts | 62 ++- .../_source/test/scope-fixes.test.ts | 22 +- .../universal-portals/universal-portals.mjs | 359 ++++++++++++------ 20 files changed, 560 insertions(+), 209 deletions(-) diff --git a/internal/cli/data/universal-portals/_source/src/api.ts b/internal/cli/data/universal-portals/_source/src/api.ts index 58e7f4de9..7e8773252 100644 --- a/internal/cli/data/universal-portals/_source/src/api.ts +++ b/internal/cli/data/universal-portals/_source/src/api.ts @@ -44,7 +44,8 @@ export function createApiClient(baseUrl: string, token: string, fetchImpl: Fetch export function toApiError(status: number, body: unknown): ApiError { const record = isRecord(body) ? body : {}; - const message = typeof record.message === "string" ? record.message : `HTTP ${status}`; + const text = typeof body === "string" ? body.trim() : ""; + const message = typeof record.message === "string" ? record.message : text || `HTTP ${status}`; const errorCode = typeof record.errorCode === "string" ? record.errorCode : undefined; return new ApiError(status, errorCode, message, body); } diff --git a/internal/cli/data/universal-portals/_source/src/args.ts b/internal/cli/data/universal-portals/_source/src/args.ts index df4eab241..2ee9556a5 100644 --- a/internal/cli/data/universal-portals/_source/src/args.ts +++ b/internal/cli/data/universal-portals/_source/src/args.ts @@ -11,6 +11,8 @@ export class UsageError extends PluginError { export interface ParsedSetupArgs { command: "setup" | "audit" | "help"; + // With --help after a command name, the help shows only that command. + helpFor?: "setup" | "audit"; portalId?: string; strict: boolean; fix?: boolean; @@ -57,6 +59,7 @@ export function parseSetupArgs(argv: string[]): ParsedSetupArgs { if (values.help || first === undefined) { return { command: "help", + helpFor: first === "setup" || first === "audit" ? first : undefined, strict: false, tenant: globals.tenant, noColor: globals.noColor, @@ -117,8 +120,8 @@ export function missingFlags(args: ParsedSetupArgs): string[] { return missing; } -export function usage(): string { - return [ +export function usage(command?: "setup" | "audit"): string { + const setup = [ "Usage: auth0 universal-portals setup [flags]", "", "Creates an Auth0 Portal with its application, forms and pages.", @@ -133,7 +136,8 @@ export function usage(): string { " --dry-run Show what setup would create, without changing the tenant.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", - "", + ]; + const audit = [ "Usage: auth0 universal-portals audit [flags]", "", "Checks a portal and its application against the setup defaults.", @@ -144,7 +148,10 @@ export function usage(): string { " --strict Also exit 1 when there are warnings.", " --json Print the result as JSON on stdout.", " -h, --help Show this help.", - ].join("\n"); + ]; + if (command === "setup") return setup.join("\n"); + if (command === "audit") return audit.join("\n"); + return [...setup, "", ...audit].join("\n"); } function splitGlobalFlags(argv: string[]): { globals: { tenant?: string; noColor: boolean }; rest: string[] } { diff --git a/internal/cli/data/universal-portals/_source/src/audit-command.ts b/internal/cli/data/universal-portals/_source/src/audit-command.ts index 98120067c..36a12cd2a 100644 --- a/internal/cli/data/universal-portals/_source/src/audit-command.ts +++ b/internal/cli/data/universal-portals/_source/src/audit-command.ts @@ -30,23 +30,25 @@ export async function runAuditCommand( const fixable = fixableFindings(report.findings); const applyDeps = { api, ui, readSnapshot }; let result = report; + let fixesFailed = 0; if (args.fix) { // Scripts: apply the errors the CLI can fix, without asking. const errors = fixable.filter((finding) => finding.severity === "error"); if (errors.length > 0) { ui.section("Fix"); - await applyFixRounds(errors, applyDeps, async (exposed) => exposed); + fixesFailed = (await applyFixRounds(errors, applyDeps, async (exposed) => exposed)).failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (prompter && fixable.length > 0) { const selected = await chooseFixes(prompter, fixable, ui); if (selected.length > 0) { ui.section("Fix"); - await applyFixRounds(selected, applyDeps, async (exposed) => { + const applied = await applyFixRounds(selected, applyDeps, async (exposed) => { ui.detail("These errors appeared after the fixes above."); return chooseFixes(prompter, exposed, ui); }); + fixesFailed = applied.failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (fixable.length > 0) { @@ -54,7 +56,8 @@ export async function runAuditCommand( } if (args.json) io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return result.failed ? 1 : 0; + // A fix that failed fails the run, even when the final audit no longer sees the error. + return result.failed || fixesFailed > 0 ? 1 : 0; } catch (error) { if (error instanceof CancelledError && !args.json) { ui.warn("Cancelled. Fixes already applied stay in your tenant."); diff --git a/internal/cli/data/universal-portals/_source/src/audit/fix.ts b/internal/cli/data/universal-portals/_source/src/audit/fix.ts index a6671cbc8..349b6f2a0 100644 --- a/internal/cli/data/universal-portals/_source/src/audit/fix.ts +++ b/internal/cli/data/universal-portals/_source/src/audit/fix.ts @@ -1,4 +1,6 @@ -import type { ApiClient } from "../api.js"; +import { ApiClient, ApiError } from "../api.js"; +import { scopeFixes } from "../scope-fixes.js"; +import { parseScopeGap } from "../setup/preflight.js"; import type { Ui } from "../ui.js"; import { auditPortal, Finding } from "./checks.js"; import type { PortalSnapshot } from "./snapshot.js"; @@ -16,6 +18,8 @@ export interface ApplyResult { failed: number; // Fixes that were no longer needed by the time they ran. alreadyFixed: number; + // Scopes the failed fixes needed, so the fix for them is shown once at the end. + missingScopes: string[]; } // Only errors are fixed. Warnings may be intentional, so they stay in the @@ -32,7 +36,7 @@ export function findingKey(finding: Finding): string { export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise { const { ui } = deps; - const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 }; + const result: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; for (const finding of selected) { const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding)); @@ -50,6 +54,8 @@ export async function applyFixes(selected: Finding[], deps: ApplyDeps): Promise< } catch (error) { result.failed += 1; ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`); + const gap = error instanceof ApiError ? parseScopeGap(label, error) : undefined; + if (gap) result.missingScopes.push(...(gap.requirement === "any" ? gap.scopes.slice(0, 1) : gap.scopes)); } } return result; @@ -67,7 +73,7 @@ export async function applyFixRounds( deps: ApplyDeps, chooseNext: (fixable: Finding[]) => Promise, ): Promise { - const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0 }; + const total: ApplyResult = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; const attempted = new Set(); let pending = selected; @@ -77,9 +83,17 @@ export async function applyFixRounds( total.applied += result.applied; total.failed += result.failed; total.alreadyFixed += result.alreadyFixed; + total.missingScopes.push(...result.missingScopes); const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding))); pending = exposed.length > 0 ? await chooseNext(exposed) : []; } + + total.missingScopes = [...new Set(total.missingScopes)].sort(); + if (total.failed > 0) { + const scopes = total.missingScopes.length > 0 ? `, missing: ${total.missingScopes.join(", ")}` : ""; + deps.ui.error(`${total.failed} fix(es) failed${scopes}`); + if (total.missingScopes.length > 0) for (const fix of scopeFixes(total.missingScopes)) deps.ui.detail(fix); + } return total; } diff --git a/internal/cli/data/universal-portals/_source/src/cli-api.ts b/internal/cli/data/universal-portals/_source/src/cli-api.ts index f7b9d48d6..9c39fbbce 100644 --- a/internal/cli/data/universal-portals/_source/src/cli-api.ts +++ b/internal/cli/data/universal-portals/_source/src/cli-api.ts @@ -5,7 +5,7 @@ import { spawn } from "node:child_process"; -import { ApiClient, HttpMethod, toApiError } from "./api.js"; +import { ApiClient, ApiError, HttpMethod, toApiError } from "./api.js"; import { PluginError } from "./plugin-error.js"; export interface CommandResult { @@ -125,18 +125,23 @@ function explainFailure(stderr: string, what: string, tenant: string | undefined // An API error comes first: its body can quote any text, including a scope message. The body runs // to the end of the output, because the CLI prints it over several lines when it is long. const api = /(? `one of ${choice.scopes.join(" or ")}`)]; super( - scopes.length > 0 - ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` - : "The CLI session is missing a scope it needs.", + `The CLI session is missing ${needs.length} permission(s) setup needs: ${needs.join(", ")}. Nothing was created.`, "missing_scopes", - scopes.length > 0 ? scopeFixes(scopes) : undefined, + needs.length > 0 ? scopeFixes([...scopes, ...choices.map((choice) => choice.scopes[0])]) : undefined, + ); + } +} + +// A 403 that names no scope. The Management API sends one when Universal Portals is not enabled, +// and the CLI can drop the reason, so setup stops here rather than after creating resources. +export class PermissionCheckError extends PluginError { + constructor(probes: string[], tenantDomain: string) { + super( + `The Management API refused ${probes.map((probe) => `"${probe}"`).join(", ")} with HTTP 403 and gave no reason. ` + + `Nothing was created. Usually Universal Portals is not enabled for ${tenantDomain}, or the session is missing a permission.`, + "permission_check_failed", + ["Check that Universal Portals is enabled for this tenant.", ...scopeFixes([])], ); } } @@ -76,8 +89,9 @@ 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; + // Sends the permission check. A dry run passes the real client here, because the probes + // change nothing. Defaults to api. + probeApi?: ApiClient; } export interface SetupResult { @@ -94,10 +108,7 @@ 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") && (!deps.dryRun || probe.method === "GET"), - ); + const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); await checkPermissions(probes, deps); let slug = options.slug; @@ -222,20 +233,25 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise< async function checkPermissions(probes: Probe[], deps: SetupDeps): Promise { deps.ui.section("Permissions"); - const result = await deps.ui.task("Checking permissions", () => runPreflight(deps.api, probes, deps.tenantDomain)); - for (const issue of result.unexpected) { - deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message} (HTTP ${issue.status})`); - } + const api = deps.probeApi ?? deps.api; + const result = await deps.ui.task("Checking permissions", () => runPreflight(api, probes, deps.tenantDomain)); + const refused = result.unexpected.filter((issue) => issue.status === 403); + if (refused.length > 0) throw new PermissionCheckError(refused.map((issue) => issue.probe), deps.tenantDomain); + const required = requiredMissingScopes(result); const choices = choiceGaps(result); - if (required.length === 0 && choices.length === 0) { - deps.ui.result("The CLI session has every permission setup needs"); - return; + // The error lists the scopes and the fixes once, through reportError. + if (required.length > 0 || choices.length > 0) throw new MissingScopesError(required, choices); + + for (const issue of result.unexpected) { + const status = issue.message.includes(String(issue.status)) ? "" : ` (HTTP ${issue.status})`; + deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message}${status}`); } - deps.ui.error("The CLI session is missing permissions setup needs"); - for (const scope of required) deps.ui.detail(scope); - for (const choice of choices) deps.ui.detail(`one of: ${choice.scopes.join(", ")}`); - throw new MissingScopesError(required, choices); + deps.ui.result( + result.unexpected.length > 0 + ? "No permission is missing, but the checks above could not be verified" + : "The CLI session has every permission setup needs", + ); } function resourceServersFor(template: TemplateId, tenantDomain: string): { name: string; identifier: string }[] { diff --git a/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts b/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts index 0c8700f16..aaf02895b 100644 --- a/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts +++ b/internal/cli/data/universal-portals/_source/test/audit-fix.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { runAudit } from "../src/audit/run.js"; -import { applyFixes, findingKey, fixableFindings } from "../src/audit/fix.js"; +import { applyFixes, applyFixRounds, findingKey, fixableFindings } from "../src/audit/fix.js"; import { auditPortal } from "../src/audit/checks.js"; import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; import { runSetup } from "../src/setup/run.js"; @@ -119,3 +119,20 @@ test("warnings are reported but never offered for fixing", async () => { assert.equal(fixableFindings(findings).length, 0, "warnings are never fixable"); assert.ok(findings.some((finding) => finding.id === "P017" && finding.severity === "warning")); }); + +test("fixes that fail for a missing scope are summed up once, with the login command", async () => { + const tenant = new FakeTenant(); + const { portalId } = await createPortal(tenant); + tenant.portals.get(portalId)!.pages.default = undefined; + tenant.deny(`PATCH portals/${portalId}`, "Insufficient scope, expected any of: update:portals"); + + const captured = captureOutput(); + const readSnapshot = () => fetchPortalSnapshot(tenant, portalId, DOMAIN); + const errors = fixableFindings(auditPortal(await readSnapshot())).filter((finding) => finding.severity === "error"); + const result = await applyFixRounds(errors, { api: tenant, ui: new Ui(captured.stream, false, false), readSnapshot }, async () => []); + + assert.ok(result.failed > 0); + assert.deepEqual(result.missingScopes, ["update:portals"]); + assert.match(captured.text(), /fix\(es\) failed, missing: update:portals/); + assert.match(captured.text(), /auth0 login --scopes \S*update:portals/); +}); diff --git a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts index b2d06eb1f..c5a8840ab 100644 --- a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts @@ -106,7 +106,7 @@ test("a 404 becomes an ApiError with the status, code and message from the API", assert.ok(error instanceof ApiError); assert.equal(error.status, 404); assert.equal(error.errorCode, "inexistent_client"); - assert.equal(error.message, "The client does not exist"); + assert.equal(error.message, "auth0 api GET clients/x returned HTTP 404: The client does not exist"); }); test("a 400 and a 409 keep their status and error code", async () => { @@ -137,7 +137,7 @@ test("an API error body printed over several lines keeps its status, code and me const error = await failure(`\n=== acme.us.auth0.com error\n\n ▸ 404: API request failed: ${body}.\n\n`); assert.ok(error instanceof ApiError); - assert.deepEqual([error.status, error.errorCode, error.message], [404, "inexistent_portal", "The portal does not exist"]); + assert.deepEqual([error.status, error.errorCode, error.message], [404, "inexistent_portal", "auth0 api GET clients/x returned HTTP 404: The portal does not exist"]); }); test("a number that only ends in three digits is not read as a status", async () => { @@ -152,7 +152,23 @@ test("a missing scope becomes the 403 the preflight already understands", async assert.ok(error instanceof ApiError); assert.equal(error.status, 403); assert.equal(error.errorCode, "insufficient_scope"); - assert.equal(error.message, "Insufficient scope, expected any of: read:portals"); + assert.equal(error.message, "auth0 api GET clients/x returned HTTP 403: Insufficient scope, expected any of: read:portals"); +}); + +test("a missing scope the CLI does not name is still a 403 about scopes", async () => { + const error = await failure("\n=== acme.us.auth0.com error\n\n ▸ Request failed because access token lacks scope: .\n\n"); + + assert.ok(error instanceof ApiError); + assert.deepEqual([error.status, error.errorCode], [403, "insufficient_scope"]); + assert.match(error.message, /The Auth0 CLI did not say which/); +}); + +test("a 403 with no JSON body keeps the CLI's text and names the request", async () => { + const error = await failure("\n=== acme.us.auth0.com error\n\n ▸ 403: API request failed: Forbidden.\n\n"); + + assert.ok(error instanceof ApiError); + assert.equal(error.status, 403); + assert.equal(error.message, "auth0 api GET clients/x returned HTTP 403: Forbidden."); }); test("an expired session or an unknown tenant asks the user to log in", async () => { diff --git a/internal/cli/data/universal-portals/_source/test/cli.test.ts b/internal/cli/data/universal-portals/_source/test/cli.test.ts index aaf63c677..d957fefaf 100644 --- a/internal/cli/data/universal-portals/_source/test/cli.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -14,8 +14,13 @@ import { scopeFixes } from "../src/scope-fixes.js"; import { toPortalSlug } from "../src/slug.js"; import type { Prompter } from "../src/prompt.js"; import { captureOutput } from "./capture-output.js"; +import { SETUP_PROBES } from "../src/setup/preflight.js"; import { FakeTenant } from "./fake-tenant.js"; +// The writes a dry run may send: the permission probes, which the API rejects and which change nothing. +const PROBE_WRITES = new Set(SETUP_PROBES.filter((probe) => probe.method !== "GET").map((probe) => `${probe.method} ${probe.path}`)); +const writesBeyondProbes = (calls: string[]) => calls.filter((call) => !call.startsWith("GET ") && !PROBE_WRITES.has(call)); + const DOMAIN = "acme.us.auth0.com"; const ENV = { AUTH0_CLI_PROXY_URL: "https://127.0.0.1:4433", @@ -104,6 +109,8 @@ test("audit takes exactly one portal ID, plus --organization and --strict", () = test("no command prints help", () => { assert.equal(parseSetupArgs([]).command, "help"); assert.equal(parseSetupArgs(["--help"]).command, "help"); + assert.equal(parseSetupArgs(["setup", "--help"]).helpFor, "setup"); + assert.equal(parseSetupArgs(["audit", "-h"]).helpFor, "audit"); }); test("missingFlags names what a non-interactive run still needs", () => { @@ -249,7 +256,7 @@ test("audit tells the user how to get a missing scope, for a user login and a cl const code = await main(["audit", portalId], ENV, io); assert.equal(code, 1); - assert.match(stderr(), /Signed in as a user: auth0 login --scopes read:portals/); + assert.match(stderr(), /Signed in as a user: auth0 login --scopes \S*read:portals/); assert.match(stderr(), /Signed in with client credentials: add read:portals to the application's Management API grant/); }); @@ -401,7 +408,7 @@ test("a dry run prints the plan as JSON and changes nothing in the tenant", asyn 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.deepEqual(writesBeyondProbes(tenant.calls), [], "only the permission probes, which change nothing"); assert.equal(tenant.portals.size + tenant.clients.size, 0); }); @@ -413,7 +420,7 @@ test("a dry run without --json writes its summary to stderr and nothing to stdou assert.equal(code, 0); assert.equal(stdout(), ""); assert.match(stderr(), /Dry run complete/); - assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), []); + assert.deepEqual(writesBeyondProbes(tenant.calls), [], "only the permission probes, which change nothing"); }); test("a dry run with missing flags gives the same usage error as a real run", async () => { @@ -493,3 +500,25 @@ test("a --tenant that differs from the CLI's tenant stops the run before any API assert.match(stderr(), /does not match the tenant/); assert.equal(tenant.calls.length, 0); }); + +test("each command's --help shows only that command", async () => { + for (const [command, other] of [["setup", "audit"], ["audit", "setup"]]) { + const { io, stdout } = captureIo(fetchFor(new FakeTenant())); + assert.equal(await main([command, "--help"], ENV, io), 0); + assert.match(stdout(), new RegExp(`Usage: auth0 universal-portals ${command}`)); + assert.doesNotMatch(stdout(), new RegExp(`Usage: auth0 universal-portals ${other}`)); + } +}); + +test("AUTH0_AGENT_MODE prints the result as JSON on stdout without --json", async () => { + const tenant = new FakeTenant(); + const { io, stdout } = captureIo(fetchFor(tenant)); + const code = await main( + ["setup", "--dry-run", "--template", "consumer", "--name", "Acme Account"], + { ...ENV, AUTH0_AGENT_MODE: "true" }, + io, + ); + + assert.equal(code, 0); + assert.equal(JSON.parse(stdout()).dryRun, true); +}); diff --git a/internal/cli/data/universal-portals/_source/test/errors.test.ts b/internal/cli/data/universal-portals/_source/test/errors.test.ts index b81a58fed..2ac0a209d 100644 --- a/internal/cli/data/universal-portals/_source/test/errors.test.ts +++ b/internal/cli/data/universal-portals/_source/test/errors.test.ts @@ -16,12 +16,7 @@ test("each error reports its own code, and the fixes it names", () => { assert.deepEqual(describeError(new BusinessAccessError("no user", ["Rerun with --user."])).details, { fixes: ["Rerun with --user."], }); - assert.deepEqual(describeError(new MissingScopesError(["read:clients"], [])).details, { - fixes: [ - "Signed in as a user: auth0 login --scopes read:clients", - "Signed in with client credentials: add read:clients to the application's Management API grant", - ], - }); + assert.deepEqual(describeError(new MissingScopesError(["read:clients"], [])).details, { fixes: scopeFixes(["read:clients"]) }); }); test("an error with no fixes leaves fixes out of the JSON", () => { diff --git a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts index 5e81d5432..4ab00b588 100644 --- a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts +++ b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts @@ -144,7 +144,8 @@ export class FakeTenant implements ApiClient { return undefined; } if (method === "PATCH" && parts[0] === "client-grants" && parts.length === 2) { - Object.assign(this.grants.get(parts[1]) ?? {}, body); + if (!this.grants.has(parts[1])) throw toApiError(404, { message: "The grant does not exist" }); + Object.assign(this.grants.get(parts[1])!, body); return this.grants.get(parts[1]); } diff --git a/internal/cli/data/universal-portals/_source/test/preflight.test.ts b/internal/cli/data/universal-portals/_source/test/preflight.test.ts index 90f133733..c0e37288a 100644 --- a/internal/cli/data/universal-portals/_source/test/preflight.test.ts +++ b/internal/cli/data/universal-portals/_source/test/preflight.test.ts @@ -91,6 +91,20 @@ test("a 403 without a scope message is kept as unexpected, not as a scope gap", assert.equal(result.unexpected[0].status, 403); }); +test("a 404 means the scope is present only for a probe on a fake resource", async () => { + const probes: Probe[] = [ + { name: "update client grant", method: "PATCH", path: "client-grants/cgr_fake", body: {}, fakeTarget: true }, + { name: "list portals", method: "GET", path: "portals" }, + ]; + const api = tenantWith({ + "PATCH client-grants/cgr_fake": { status: 404, body: { message: "The grant does not exist" } }, + "GET portals": { status: 404, body: { message: "Not Found" } }, + }); + const result = await runPreflight(api, probes, "acme.us.auth0.com"); + + assert.deepEqual(result.unexpected.map((issue) => issue.probe), ["list portals"]); +}); + test("parseScopeGap ignores errors that are not 403", () => { const error = new ApiError(500, undefined, "expected any of: create:clients", {}); assert.equal(parseScopeGap("create client", error), undefined); diff --git a/internal/cli/data/universal-portals/_source/test/run.test.ts b/internal/cli/data/universal-portals/_source/test/run.test.ts index f0d0d37a4..885afa797 100644 --- a/internal/cli/data/universal-portals/_source/test/run.test.ts +++ b/internal/cli/data/universal-portals/_source/test/run.test.ts @@ -4,13 +4,18 @@ import { test } from "node:test"; import { CancelledError, type Prompter } from "../src/prompt.js"; import { Ui } from "../src/ui.js"; import { BusinessAccessError } from "../src/setup/business-types.js"; -import { MissingScopesError, runSetup, SetupDeps, SetupOptions } from "../src/setup/run.js"; +import { MissingScopesError, PermissionCheckError, runSetup, SetupDeps, SetupOptions } from "../src/setup/run.js"; import { FORMS_VAULT_NAME } from "../src/setup/vault.js"; import { FORMS_VAULT_SCOPES, MY_ACCOUNT_SCOPES, MY_ORGANIZATION_SCOPES } from "../src/setup/payload.js"; import { CLIENT_SECRET_PLACEHOLDER, planWrites } from "../src/dry-run.js"; +import { SETUP_PROBES } from "../src/setup/preflight.js"; import { FakeTenant } from "./fake-tenant.js"; import { captureOutput } from "./capture-output.js"; +// The writes a dry run may send: the permission probes, which the API rejects and which change nothing. +const PROBE_WRITES = new Set(SETUP_PROBES.filter((probe) => probe.method !== "GET").map((probe) => `${probe.method} ${probe.path}`)); +const writesBeyondProbes = (calls: string[]) => calls.filter((call) => !call.startsWith("GET ") && !PROBE_WRITES.has(call)); + const DOMAIN = "acme.us.auth0.com"; function harness( @@ -201,16 +206,53 @@ test("a taken slug is re-prompted when a terminal is available", async () => { test("missing scopes stop setup before anything is created and name what to grant", async () => { const tenant = new FakeTenant(); tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); - tenant.deny("POST forms/import", "Insufficient scope, expected all of: create:forms,create:flows"); + tenant.deny( + "POST forms/import", + "Insufficient scope, expected all of: create:forms,create:flows,create:flows_vault_connections,read:flows_vault_connections", + ); await assert.rejects(runSetup(CONSUMER, harness(tenant).deps), (error: unknown) => { assert.ok(error instanceof MissingScopesError); - assert.deepEqual(error.scopes, ["create:flows", "create:forms", "create:portals"]); + assert.deepEqual(error.scopes, [ + "create:flows", + "create:flows_vault_connections", + "create:forms", + "create:portals", + "read:flows_vault_connections", + ]); return true; }); assert.equal(tenant.clients.size, 0, "preflight must run before any create"); }); +test("a 403 that names no scope stops setup before anything is created", async () => { + const tenant = new FakeTenant(); + tenant.failNext("GET portals", 403); + const { deps, output } = harness(tenant); + + await assert.rejects(runSetup(CONSUMER, deps), (error: unknown) => { + assert.ok(error instanceof PermissionCheckError); + assert.match(error.message, /"list portals" with HTTP 403.*Nothing was created.*not enabled for acme\.us\.auth0\.com/); + return true; + }); + assert.equal(tenant.clients.size + tenant.resourceServers.size, 0, "nothing is created after the refusal"); + assert.doesNotMatch(output(), /every permission/, "a refused check is never reported as a pass"); +}); + +test("missing scopes are listed once, by the error, with one login command", async () => { + const tenant = new FakeTenant(); + tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); + const { deps, output } = harness(tenant); + + await assert.rejects(runSetup(CONSUMER, deps), (error: unknown) => { + assert.ok(error instanceof MissingScopesError); + assert.match(error.message, /missing 1 permission\(s\) setup needs: create:portals\. Nothing was created\./); + assert.equal(error.fixes?.filter((fix) => fix.includes("auth0 login --scopes")).length, 1); + return true; + }); + assert.doesNotMatch(output(), /create:portals/, "the scopes are not printed before the error reports them"); +}); + test("a custom domain is used for the portal URL and the back-channel logout", async () => { const tenant = new FakeTenant(); tenant.customDomain = { domain: "auth.acme.com", status: "ready" }; @@ -399,13 +441,13 @@ test("leaving the organization empty puts a placeholder in the link", async () = 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 { deps } = harness(tenant, { api: plan.api, probeApi: tenant }); const result = await runSetup(CONSUMER, deps); assert.equal(result.portal.status, "created"); assert.equal(result.application.status, "created"); assert.ok(result.resources.every((resource) => resource.status === "created")); - assert.deepEqual(tenant.calls.filter((call) => !call.startsWith("GET ")), [], "a dry run must not write"); + assert.deepEqual(writesBeyondProbes(tenant.calls), [], "a dry run sends no write beyond the permission probes"); assert.equal(tenant.portals.size + tenant.clients.size + tenant.forms.size + tenant.vaults.size, 0); const requests = plan.requests().map((request) => `${request.method} ${request.path}`); @@ -415,7 +457,7 @@ test("a dry run reports what a new consumer portal would create and sends no wri 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", + "the permission probes go to the tenant, not into the plan", ); }); @@ -428,12 +470,12 @@ test("a dry run after setup reuses what exists and plans the portal with a place const callsBefore = tenant.calls.length; const plan = planWrites(tenant); - const { deps, output } = harness(tenant, { api: plan.api, dryRun: true }); + const { deps, output } = harness(tenant, { api: plan.api, probeApi: tenant }); const result = await runSetup(CONSUMER, deps); assert.equal(result.application.status, "reused"); assert.equal(result.portal.status, "created"); - assert.deepEqual(tenant.calls.slice(callsBefore).filter((call) => !call.startsWith("GET ")), []); + assert.deepEqual(writesBeyondProbes(tenant.calls.slice(callsBefore)), []); const planned = plan.requests(); assert.ok(planned.some((request) => request.method === "PATCH" && request.path.startsWith("clients/"))); @@ -447,7 +489,7 @@ test("a dry run of a business portal plans its organization access and writes no const tenant = new FakeTenant(); seedBusinessUser(tenant, { withOrg: true, withRole: false }); const plan = planWrites(tenant); - const { deps } = harness(tenant, { api: plan.api, dryRun: true }); + const { deps } = harness(tenant, { api: plan.api, probeApi: tenant }); const result = await runSetup( { ...CONSUMER, template: "business", name: "Acme Admin", slug: "acme-admin", userQuery: "jane@acme.com" }, deps, @@ -455,7 +497,7 @@ test("a dry run of a business portal plans its organization access and writes no 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.deepEqual(writesBeyondProbes(tenant.calls), [], "a dry run sends no write beyond the permission probes"); assert.equal(tenant.portals.size, 0); assert.ok(plan.requests().some((request) => request.path === "resource-servers")); }); diff --git a/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts b/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts index 56f240e3a..a3e83f64a 100644 --- a/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts +++ b/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts @@ -2,10 +2,24 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { scopeFixes } from "../src/scope-fixes.js"; +import { PLUGIN_SCOPES } from "../src/scopes.js"; -test("a missing scope has one fix for a user login and one for a client-credentials login", () => { - assert.deepEqual(scopeFixes(["read:portals", "create:portals"]), [ - "Signed in as a user: auth0 login --scopes read:portals,create:portals", +test("a user login asks for every scope the tool uses, so it does not drop the ones granted before", () => { + const [login] = scopeFixes(["read:portals"]); + const scopes = login.replace("Signed in as a user: auth0 login --scopes ", "").split(","); + + assert.deepEqual(scopes, PLUGIN_SCOPES); +}); + +test("a scope outside the tool's list is still asked for", () => { + const [login] = scopeFixes(["read:something_new"]); + + assert.ok(login.endsWith(",read:something_new")); +}); + +test("a client-credentials login only needs the missing scopes added to its grant", () => { + assert.equal( + scopeFixes(["read:portals", "create:portals"])[1], "Signed in with client credentials: add read:portals, create:portals to the application's Management API grant", - ]); + ); }); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index 8f941d5e8..dd6df603f 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: 4db9c1862b6dc457ba8738cbeb2dbb9d5491b005a5f44ffa312c9d6421f09103 +// source-sha256: 648a32ab11106bbdac0bcf18458f502abd0a2410ceb2b2ee2f080f12bf2f28a2 import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create; @@ -138,6 +138,7 @@ function parseSetupArgs(argv) { if (values.help || first === void 0) { return { command: "help", + helpFor: first === "setup" || first === "audit" ? first : void 0, strict: false, tenant: globals.tenant, noColor: globals.noColor, @@ -193,8 +194,8 @@ function missingFlags(args) { if (args.template === "business" && !args.user) missing.push("--user"); return missing; } -function usage() { - return [ +function usage(command) { + const setup = [ "Usage: auth0 universal-portals setup [flags]", "", "Creates an Auth0 Portal with its application, forms and pages.", @@ -208,8 +209,9 @@ function usage() { " --no-input Never prompt; fail on anything missing.", " --dry-run Show what setup would create, without changing the tenant.", " --json Print the result as JSON on stdout.", - " -h, --help Show this help.", - "", + " -h, --help Show this help." + ]; + const audit = [ "Usage: auth0 universal-portals audit [flags]", "", "Checks a portal and its application against the setup defaults.", @@ -220,7 +222,10 @@ function usage() { " --strict Also exit 1 when there are warnings.", " --json Print the result as JSON on stdout.", " -h, --help Show this help." - ].join("\n"); + ]; + if (command === "setup") return setup.join("\n"); + if (command === "audit") return audit.join("\n"); + return [...setup, "", ...audit].join("\n"); } function splitGlobalFlags(argv) { const globals = { noColor: false }; @@ -1750,7 +1755,8 @@ function createApiClient(baseUrl, token, fetchImpl = fetch) { } function toApiError(status, body) { const record = isRecord(body) ? body : {}; - const message = typeof record.message === "string" ? record.message : `HTTP ${status}`; + const text2 = typeof body === "string" ? body.trim() : ""; + const message = typeof record.message === "string" ? record.message : text2 || `HTTP ${status}`; const errorCode = typeof record.errorCode === "string" ? record.errorCode : void 0; return new ApiError(status, errorCode, message, body); } @@ -1870,21 +1876,30 @@ function parseOutput(stdout2, what) { } function explainFailure(stderr, what, tenant) { const api = /(? { + try { + await api.request(probe.method, probe.path, probe.body); + return void 0; + } catch (error) { + return { probe, error }; + } + }) + ); + for (const failure of failures) { + if (!failure) continue; + const { probe, error } = failure; + if (!(error instanceof ApiError)) throw error; + if (error.status === 400) continue; + if (error.status === 404 && probe.fakeTarget) continue; + if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); + const gap = parseScopeGap(probe.name, error); + if (gap) { + gaps.push(gap); + } else { + unexpected.push({ probe: probe.name, status: error.status, message: error.message }); + } + } + return { gaps, unexpected }; +} +function parseScopeGap(probe, error) { + if (error.status !== 403) return void 0; + const match = /expected (any|all) of:\s*(.+)$/.exec(error.message); + if (!match) return void 0; + const requirement = match[1] === "all" ? "all" : "any"; + const scopes = match[2].split(",").map((scope) => scope.trim()).filter(Boolean); + return { probe, requirement, scopes }; +} +function requiredMissingScopes(result) { + const required = result.gaps.filter((gap) => gap.requirement === "all" || gap.scopes.length === 1); + return [...new Set(required.flatMap((gap) => gap.scopes))].sort(); +} +function choiceGaps(result) { + return result.gaps.filter((gap) => gap.requirement === "any" && gap.scopes.length > 1); +} + // src/templates/icons.ts var SIDEBAR_ICONS = { profile: "user", @@ -3035,7 +3212,7 @@ function findingKey(finding) { } async function applyFixes(selected, deps) { const { ui } = deps; - const result = { applied: 0, failed: 0, alreadyFixed: 0 }; + const result = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; for (const finding of selected) { const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding)); if (!fresh?.remedy?.apply) { @@ -3051,13 +3228,15 @@ async function applyFixes(selected, deps) { } catch (error) { result.failed += 1; ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`); + const gap = error instanceof ApiError ? parseScopeGap(label, error) : void 0; + if (gap) result.missingScopes.push(...gap.requirement === "any" ? gap.scopes.slice(0, 1) : gap.scopes); } } return result; } var MAX_FIX_ROUNDS = 3; async function applyFixRounds(selected, deps, chooseNext) { - const total = { applied: 0, failed: 0, alreadyFixed: 0 }; + const total = { applied: 0, failed: 0, alreadyFixed: 0, missingScopes: [] }; const attempted = /* @__PURE__ */ new Set(); let pending = selected; for (let round = 0; round < MAX_FIX_ROUNDS && pending.length > 0; round += 1) { @@ -3066,9 +3245,16 @@ async function applyFixRounds(selected, deps, chooseNext) { total.applied += result.applied; total.failed += result.failed; total.alreadyFixed += result.alreadyFixed; + total.missingScopes.push(...result.missingScopes); const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding))); pending = exposed.length > 0 ? await chooseNext(exposed) : []; } + total.missingScopes = [...new Set(total.missingScopes)].sort(); + if (total.failed > 0) { + const scopes = total.missingScopes.length > 0 ? `, missing: ${total.missingScopes.join(", ")}` : ""; + deps.ui.error(`${total.failed} fix(es) failed${scopes}`); + if (total.missingScopes.length > 0) for (const fix of scopeFixes(total.missingScopes)) deps.ui.detail(fix); + } return total; } @@ -3114,90 +3300,6 @@ async function runAudit(options, deps) { }; } -// src/scope-fixes.ts -function scopeFixes(scopes) { - return [ - `Signed in as a user: auth0 login --scopes ${scopes.join(",")}`, - `Signed in with client credentials: add ${scopes.join(", ")} to the application's Management API grant` - ]; -} - -// src/setup/preflight.ts -var SETUP_PROBES = [ - { name: "list resource servers", method: "GET", path: "resource-servers?per_page=1" }, - { name: "create resource server", method: "POST", path: "resource-servers", body: {} }, - { name: "list clients", method: "GET", path: "clients?per_page=1" }, - { name: "create client", method: "POST", path: "clients", body: {} }, - { name: "list client grants", method: "GET", path: "client-grants?per_page=1" }, - { name: "create client grant", method: "POST", path: "client-grants", body: {} }, - { name: "list vault connections", method: "GET", path: "flows/vault/connections" }, - { name: "create vault connection", method: "POST", path: "flows/vault/connections", body: {} }, - { name: "list forms", method: "GET", path: "forms?per_page=1" }, - { name: "import forms", method: "POST", path: "forms/import", body: {} }, - { name: "list portals", method: "GET", path: "portals" }, - { name: "create portal", method: "POST", path: "portals", body: {} }, - { - name: "look up users by email", - method: "GET", - path: "users?q=email%3A%22preflight%40example.invalid%22&search_engine=v3", - businessOnly: true - }, - { name: "list organizations", method: "GET", path: "organizations?per_page=1", businessOnly: true } -]; -var FeatureNotEnabledError = class extends PluginError { - constructor(tenantDomain) { - super( - `Universal Portals is not enabled for ${tenantDomain}. Ask Auth0 to enable it for this tenant, then run the command again.`, - "feature_not_enabled" - ); - this.tenantDomain = tenantDomain; - } - tenantDomain; -}; -async function runPreflight(api, probes, tenantDomain) { - const gaps = []; - const unexpected = []; - const failures = await Promise.all( - probes.map(async (probe) => { - try { - await api.request(probe.method, probe.path, probe.body); - return void 0; - } catch (error) { - return { probe, error }; - } - }) - ); - for (const failure of failures) { - if (!failure) continue; - const { probe, error } = failure; - if (!(error instanceof ApiError)) throw error; - if (error.status === 400) continue; - if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); - const gap = parseScopeGap(probe.name, error); - if (gap) { - gaps.push(gap); - } else { - unexpected.push({ probe: probe.name, status: error.status, message: error.message }); - } - } - return { gaps, unexpected }; -} -function parseScopeGap(probe, error) { - if (error.status !== 403) return void 0; - const match = /expected (any|all) of:\s*(.+)$/.exec(error.message); - if (!match) return void 0; - const requirement = match[1] === "all" ? "all" : "any"; - const scopes = match[2].split(",").map((scope) => scope.trim()).filter(Boolean); - return { probe, requirement, scopes }; -} -function requiredMissingScopes(result) { - const required = result.gaps.filter((gap) => gap.requirement === "all" || gap.scopes.length === 1); - return [...new Set(required.flatMap((gap) => gap.scopes))].sort(); -} -function choiceGaps(result) { - return result.gaps.filter((gap) => gap.requirement === "any" && gap.scopes.length > 1); -} - // src/errors.ts function reportError(error, json, io, ui) { const { code, message, details } = describeError(error); @@ -3238,21 +3340,23 @@ async function runAuditCommand(args, env, io, ui, prompter) { const fixable = fixableFindings(report.findings); const applyDeps = { api, ui, readSnapshot }; let result = report; + let fixesFailed = 0; if (args.fix) { const errors = fixable.filter((finding) => finding.severity === "error"); if (errors.length > 0) { ui.section("Fix"); - await applyFixRounds(errors, applyDeps, async (exposed) => exposed); + fixesFailed = (await applyFixRounds(errors, applyDeps, async (exposed) => exposed)).failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (prompter && fixable.length > 0) { const selected = await chooseFixes(prompter, fixable, ui); if (selected.length > 0) { ui.section("Fix"); - await applyFixRounds(selected, applyDeps, async (exposed) => { + const applied = await applyFixRounds(selected, applyDeps, async (exposed) => { ui.detail("These errors appeared after the fixes above."); return chooseFixes(prompter, exposed, ui); }); + fixesFailed = applied.failed; result = await finalAudit(options, ui, api, context.tenantDomain); } } else if (fixable.length > 0) { @@ -3260,7 +3364,7 @@ async function runAuditCommand(args, env, io, ui, prompter) { } if (args.json) io.stdout.write(`${JSON.stringify(result, null, 2)} `); - return result.failed ? 1 : 0; + return result.failed || fixesFailed > 0 ? 1 : 0; } catch (error) { if (error instanceof CancelledError && !args.json) { ui.warn("Cancelled. Fixes already applied stay in your tenant."); @@ -3933,10 +4037,11 @@ var SlugRaceError = class extends PluginError { }; var MissingScopesError = class extends PluginError { constructor(scopes, choices) { + const needs = [...scopes, ...choices.map((choice) => `one of ${choice.scopes.join(" or ")}`)]; super( - scopes.length > 0 ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` : "The CLI session is missing a scope it needs.", + `The CLI session is missing ${needs.length} permission(s) setup needs: ${needs.join(", ")}. Nothing was created.`, "missing_scopes", - scopes.length > 0 ? scopeFixes(scopes) : void 0 + needs.length > 0 ? scopeFixes([...scopes, ...choices.map((choice) => choice.scopes[0])]) : void 0 ); this.scopes = scopes; this.choices = choices; @@ -3944,12 +4049,19 @@ var MissingScopesError = class extends PluginError { scopes; choices; }; +var PermissionCheckError = class extends PluginError { + constructor(probes, tenantDomain) { + super( + `The Management API refused ${probes.map((probe) => `"${probe}"`).join(", ")} with HTTP 403 and gave no reason. Nothing was created. Usually Universal Portals is not enabled for ${tenantDomain}, or the session is missing a permission.`, + "permission_check_failed", + ["Check that Universal Portals is enabled for this tenant.", ...scopeFixes([])] + ); + } +}; async function runSetup(options, deps) { const resources = []; const { ui, api, tenantDomain } = deps; - const probes = SETUP_PROBES.filter( - (probe) => (!probe.businessOnly || options.template === "business") && (!deps.dryRun || probe.method === "GET") - ); + const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); await checkPermissions(probes, deps); let slug = options.slug; if (!isValidPortalSlug(slug)) throw new Error(`The slug "${slug}" must match ^[a-z][a-z0-9-]{0,99}$.`); @@ -4058,20 +4170,20 @@ async function runSetup(options, deps) { } async function checkPermissions(probes, deps) { deps.ui.section("Permissions"); - const result = await deps.ui.task("Checking permissions", () => runPreflight(deps.api, probes, deps.tenantDomain)); - for (const issue of result.unexpected) { - deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message} (HTTP ${issue.status})`); - } + const api = deps.probeApi ?? deps.api; + const result = await deps.ui.task("Checking permissions", () => runPreflight(api, probes, deps.tenantDomain)); + const refused = result.unexpected.filter((issue) => issue.status === 403); + if (refused.length > 0) throw new PermissionCheckError(refused.map((issue) => issue.probe), deps.tenantDomain); const required = requiredMissingScopes(result); const choices = choiceGaps(result); - if (required.length === 0 && choices.length === 0) { - deps.ui.result("The CLI session has every permission setup needs"); - return; + if (required.length > 0 || choices.length > 0) throw new MissingScopesError(required, choices); + for (const issue of result.unexpected) { + const status = issue.message.includes(String(issue.status)) ? "" : ` (HTTP ${issue.status})`; + deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message}${status}`); } - deps.ui.error("The CLI session is missing permissions setup needs"); - for (const scope of required) deps.ui.detail(scope); - for (const choice of choices) deps.ui.detail(`one of: ${choice.scopes.join(", ")}`); - throw new MissingScopesError(required, choices); + deps.ui.result( + result.unexpected.length > 0 ? "No permission is missing, but the checks above could not be verified" : "The CLI session has every permission setup needs" + ); } function resourceServersFor(template, tenantDomain) { const account = { name: "Auth0 My Account API", identifier: myAccountAudience(tenantDomain) }; @@ -4149,7 +4261,7 @@ async function runSetupCommand(args, env, io, ui, interactive, prompter) { prompt: prompter ?? unansweredPrompter(), interactive, tenantDomain: context.tenantDomain, - dryRun: planned !== void 0 + probeApi: tracked.api }); if (planned) return reportPlan(result, planned.requests(), args.json, io, ui); if (args.json) { @@ -4174,7 +4286,7 @@ 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."; +var DRY_RUN_NOTE = "Dry run: nothing is created or changed. Statuses show what a real run would do."; function reportPlan(result, requests, json, io, ui) { if (json) { io.stdout.write(`${JSON.stringify({ dryRun: true, ...result, requests }, null, 2)} @@ -4408,11 +4520,12 @@ async function main(argv, env, io) { return error instanceof UsageError ? 2 : 1; } if (args.command === "help") { - io.stdout.write(`${usage()} + io.stdout.write(`${usage(args.helpFor)} `); return 0; } const agentMode = args.json || env.AUTH0_AGENT_MODE === "1" || env.AUTH0_AGENT_MODE === "true"; + if (agentMode) args = { ...args, json: true }; const interactive = !args.noInput && !agentMode && io.isTTY; const ui = new Ui( io.stderr, From 8b8323785a9262ad12c0f350ee55881388232265 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Thu, 5 Nov 2026 20:59:01 +0530 Subject: [PATCH 8/8] feat(cli): improve tenant handling in universal-portals command --- .../universal-portals/universal-portals.mjs | 5 +-- internal/cli/universal_portals.go | 15 +++++++ internal/cli/universal_portals_runner_test.go | 44 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index dd6df603f..780bb23f6 100755 --- a/internal/cli/data/universal-portals/universal-portals.mjs +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generated by "npm run bundle" in the _source folder. Do not edit. -// source-sha256: 648a32ab11106bbdac0bcf18458f502abd0a2410ceb2b2ee2f080f12bf2f28a2 +// source-sha256: 52f998e5b79a1293b85e19b7f1c66facbd057186637fd35d3ef516f9eef6967d import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create; @@ -1880,13 +1880,12 @@ function explainFailure(stderr, what, tenant) { const scope = /lacks scope: ([A-Za-z0-9_:,-]+)/i.exec(stderr)?.[1]; const unnamed = !scope && /lacks scope:\s*\./i.test(stderr); if (scope || unnamed) { - const requirement = scope?.includes(",") ? "all" : "any"; return withRequest( toApiError(403, { statusCode: 403, error: "Forbidden", errorCode: "insufficient_scope", - message: scope ? `Insufficient scope, expected ${requirement} of: ${scope}` : "Insufficient scope. The Auth0 CLI did not say which." + message: scope ? `Insufficient scope, expected any of: ${scope}` : "Insufficient scope. The Auth0 CLI did not say which." }), what ); diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index ab0ebcf86..38180ec53 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "slices" "github.com/spf13/cobra" ) @@ -25,6 +26,20 @@ These commands run a tool that is built into the CLI. It needs Node.js 20.12 or Auth0 Management API as the tenant you are logged in to.`, } + // Flag parsing is off for the tool's commands, so the login check would miss --tenant and + // --no-input, and would run for --help, which the tool prints without a tenant. + cmd.PersistentPreRunE = func(command *cobra.Command, args []string) error { + if slices.Contains(args, "--help") || slices.Contains(args, "-h") { + cli.executedCommandPath = command.CommandPath() + return nil + } + if tenant := universalPortalsTenantFlag(args); tenant != "" { + cli.tenant = tenant + } + cli.noInput = cli.noInput || slices.Contains(args, "--no-input") + return command.Root().PersistentPreRunE(command, args) + } + cmd.SetUsageTemplate(namespaceUsageTemplate()) cmd.AddCommand( universalPortalsToolCmd(cli, "setup", diff --git a/internal/cli/universal_portals_runner_test.go b/internal/cli/universal_portals_runner_test.go index e1ef9cc27..83ca1e2cd 100644 --- a/internal/cli/universal_portals_runner_test.go +++ b/internal/cli/universal_portals_runner_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -257,6 +258,49 @@ func TestEmbeddedUniversalPortalsScriptRuns(t *testing.T) { assert.Contains(t, stdout.String(), "audit") } +func TestUniversalPortalsLoginCheck(t *testing.T) { + tests := []struct { + name string + args []string + wantChecked bool + wantTenant string + wantNoInput bool + }{ + {name: "help skips it", args: []string{"--help"}}, + {name: "short help skips it", args: []string{"up_1", "-h"}}, + {name: "it uses the default tenant", args: []string{"--name", "Acme"}, wantChecked: true, wantTenant: "default.us.auth0.com"}, + { + name: "it sees --tenant and --no-input", + args: []string{"up_1", "--tenant", "other.eu.auth0.com", "--no-input"}, + wantChecked: true, + wantTenant: "other.eu.auth0.com", + wantNoInput: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := &cli{tenant: "default.us.auth0.com"} + checked := false + root := &cobra.Command{Use: "auth0", PersistentPreRunE: func(*cobra.Command, []string) error { + checked = true + return nil + }} + root.AddCommand(universalPortalsCmd(c)) + setup, _, err := root.Find([]string{"universal-portals", "setup"}) + require.NoError(t, err) + + require.NoError(t, setup.Parent().PersistentPreRunE(setup, test.args)) + + assert.Equal(t, test.wantChecked, checked) + if test.wantChecked { + assert.Equal(t, test.wantTenant, c.tenant) + assert.Equal(t, test.wantNoInput, c.noInput) + } + }) + } +} + func TestUniversalPortalsCommand(t *testing.T) { cmd := universalPortalsCmd(&cli{})