diff --git a/docs/auth0_universal-portals.md b/docs/auth0_universal-portals.md new file mode 100644 index 000000000..ab905bfbd --- /dev/null +++ b/docs/auth0_universal-portals.md @@ -0,0 +1,13 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 universal-portals + +Manage Auth0 Universal Portals resources. + +## Commands + +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application + diff --git a/docs/auth0_universal-portals_setup.md b/docs/auth0_universal-portals_setup.md new file mode 100644 index 000000000..23c70fdf6 --- /dev/null +++ b/docs/auth0_universal-portals_setup.md @@ -0,0 +1,50 @@ +--- +layout: default +parent: auth0 universal-portals +has_toc: false +--- +# auth0 universal-portals setup + +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 + +## Usage +``` +auth0 universal-portals setup [flags] +``` + +## Examples + +``` + auth0 universal-portals setup + auth0 universal-portals setup --name "Acme" --slug "acme" + auth0 up setup -n "Acme" -s "acme" +``` + + +## Flags + +``` + -n, --name string Display name of the portal. + -s, --slug string URL-friendly identifier for the portal (e.g. my-portal). +``` + + +## 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 setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application + + diff --git a/docs/index.md b/docs/index.md index 55e1a3fa8..29ca5e41d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,5 +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 users](auth0_users.md) - Manage resources for users diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go index f84c0047e..f8757148f 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -71,6 +71,13 @@ func URL(text string) string { return color.Sprintf(color.Underline(text)) } +// Hyperlink wraps text in an OSC8 terminal hyperlink. +// Terminals that support OSC8 (iTerm2, Warp, GNOME Terminal, etc.) render the +// text as an underlined, clickable link. Unsupported terminals show plain text. +func Hyperlink(url, text string) string { + return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\" +} + // Red returns text colored red. func Red(text string) string { return color.Sprintf(color.Red(text)) diff --git a/internal/cli/root.go b/internal/cli/root.go index cbe0a451d..f8371894a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -190,6 +190,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(terraformCmd(cli)) rootCmd.AddCommand(eventStreamsCmd(cli)) rootCmd.AddCommand(experimentationCmd(cli)) + rootCmd.AddCommand(universalPortalsCmd(cli)) rootCmd.AddCommand(networkACLCmd(cli)) rootCmd.AddCommand(tenantSettingsCmd(cli)) rootCmd.AddCommand(tokenExchangeCmd(cli)) diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go new file mode 100644 index 000000000..f97c93f97 --- /dev/null +++ b/internal/cli/universal_portals.go @@ -0,0 +1,1026 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "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", + } + + managementAPIScopes = []string{ + "read:branding", + "read:organizations_summary", + "read:organizations", + "update:users", + "update:users_app_metadata", + } + + 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. +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.", + } + + cmd.SetUsageTemplate(namespaceUsageTemplate()) + cmd.AddCommand(universalPortalsSetupCmd(cli)) + + 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"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runUniversalPortalsSetup(cmd, cli, inputs.PortalName, inputs.Slug) + }, + } + + 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 { + return fmt.Errorf( + "insufficient scopes to provision Universal Portals\nMissing: %s\nRe-authenticate to continue: auth0 login", + strings.Join(missing, ", "), + ) + } + } + } + + 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) + 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) + if err != nil { + return err + } + + resp, err := h.Do(req) + if 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)) + if err != nil { + return portalFormIDs{}, fmt.Errorf("failed to create personal info form: %w", err) + } + + commPrefID, err := importForm(buildCommunicationPreferencesFormPayload(vaultConnID)) + if err != nil { + return portalFormIDs{}, fmt.Errorf("failed to create communication preferences form: %w", err) + } + + privacyID, err := importForm(buildPrivacySettingsFormPayload(vaultConnID)) + if err != nil { + return portalFormIDs{}, fmt.Errorf("failed to create privacy settings form: %w", 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"` + Content []portalPage `json:"content"` +} + +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" + } + return slug +} + +// portalFormIDs holds the IDs of the three forms required by the portal. +type portalFormIDs struct { + PersonalInfo string + PrivacyConsent string + CommunicationPreferences string +} + +// 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}, + } + } + + 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", + Content: []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."), + ), + }, + }, + }, + }, + } +} + +// 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 + } + 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 fmt.Sprintf("%s/dashboard/%s/%s/applications/%s/settings", + deriveServiceURL("manage", tenantDomain), region, tenantName, clientID) +} diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 8b484fc2b..30b88a42d 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -337,6 +337,61 @@ func openManageURL(cli *cli, tenant string, path string) { } } +// deriveServiceURL builds the base URL for a named service (e.g. "manage", "forms") from the +// tenant domain. Public envs use 2-letter region codes (us, eu, ca, jp, uk, …) — the region +// appears only in the path, not the host. All other domains keep the full suffix after the +// tenant name. +// +// my-tenant.us.auth0.com → https://manage.auth0.com +// my-tenant.auth0.com → https://manage.auth0.com (legacy PUS1) +// +// missingScopes returns elements of required that are absent from granted. +// Used to surface a clear re-authentication prompt before API calls that need +// scopes not in RequiredScopes (e.g. feature-flagged EA endpoints). +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 extracts the granted scopes from a JWT access token by +// base64-decoding the payload. Returns nil if the token is malformed or +// carries no scope claim, in which case the caller should skip the scope +// pre-flight check and let the API surface a 403 instead. +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) +} + +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 ""