From 71fab2e5748f31b2811142f2231714db2268a43d Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 23 Feb 2026 07:06:33 +0100 Subject: [PATCH] feat: allow adding trusted domains in instance setup (#11169) # Which Problems Are Solved When running Zitadel behind a reverse proxy and especially when the API and the login UI don't run on the same domain, Zitadel needs to be configured to trust the corresponding domains and use them in public responses, like email links and more. This can be done by adding a trusted domain. However it's currently only possible through the API and not in the instance setup process. # How the Problems Are Solved Added a possibility to configure multiple trusted domains in the first instance setup process. # Additional Changes None # Additional Context - closes #11153 --- cmd/setup/03.go | 2 + cmd/setup/steps.yaml | 5 ++ internal/command/instance.go | 10 ++++ internal/command/instance_test.go | 33 +++++++++++-- internal/command/instance_trusted_domain.go | 51 +++++++++++++++++---- 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 92e8e34c8..3037cb695 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -28,6 +28,7 @@ type FirstInstance struct { PatPath string LoginClientPatPath string Features *command.InstanceFeatures + TrustedDomains []string Skip bool @@ -102,6 +103,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error mig.instanceSetup.InstanceName = mig.InstanceName mig.instanceSetup.CustomDomain = mig.externalDomain + mig.instanceSetup.TrustedDomains = mig.TrustedDomains mig.instanceSetup.DefaultLanguage = mig.DefaultLanguage mig.instanceSetup.Org = mig.Org // check if username is email style or else append @. diff --git a/cmd/setup/steps.yaml b/cmd/setup/steps.yaml index 167367659..5b7be3523 100644 --- a/cmd/setup/steps.yaml +++ b/cmd/setup/steps.yaml @@ -121,6 +121,11 @@ FirstInstance: PatPath: # ZITADEL_FIRSTINSTANCE_PATPATH LoginClientPatPath: # ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH InstanceName: ZITADEL # ZITADEL_FIRSTINSTANCE_INSTANCENAME + # A list of trusted domains, which are allowed to be used for public responses like email links or when running + # ZITADEL or the login UI behind a reverse proxy. If no trusted domain is set, the instance domain is used. + TrustedDomains: # ZITADEL_FIRSTINSTANCE_TRUSTEDDOMAINS + # - login.example.com + # - api.example.com DefaultLanguage: en # ZITADEL_FIRSTINSTANCE_DEFAULTLANGUAGE Org: Name: ZITADEL # ZITADEL_FIRSTINSTANCE_ORG_NAME diff --git a/internal/command/instance.go b/internal/command/instance.go index c9bd43291..5b69ee9b5 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -40,6 +40,7 @@ type InstanceSetup struct { zitadel ZitadelConfig InstanceName string CustomDomain string + TrustedDomains []string DefaultLanguage language.Tag Org InstanceOrgSetup SecretGenerators *SecretGenerators @@ -294,6 +295,7 @@ func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (vali return nil, nil, nil, nil, err } setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) + setupTrustedDomains(c, &validations, instanceAgg, setup.TrustedDomains) // optional setting if set setupMessageTexts(&validations, setup.MessageTexts, instanceAgg) @@ -511,6 +513,14 @@ func setupGeneratedDomain(ctx context.Context, commands *Commands, validations * return nil } +func setupTrustedDomains(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, trustedDomains []string) { + for _, trustedDomain := range trustedDomains { + *validations = append(*validations, + commands.setupTrustedDomain(instanceAgg, trustedDomain), + ) + } +} + func setupMinimalInterfaces(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, orgAgg *org.Aggregate, projectOwner string, ids ZitadelConfig) { projectAgg := project.NewAggregate(ids.projectID, orgAgg.ID) diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index ca355d6fd..68fe79e33 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -365,19 +365,28 @@ func instanceElementsConfig() *SecretGenerators { } } -func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string) []expect { +func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string, trustedDomains []string) []expect { return slices.Concat( setupInstanceElementsFilters(instanceID), orgFilters(orgID, true, true, true), generatedDomainFilters(instanceID, orgID, projectID, appID, domain), + generatedTrustedDomainsFilters(trustedDomains), ) } -func setupInstanceEvents(ctx context.Context, instanceID, orgID, projectID, appID, instanceName, orgName string, defaultLanguage language.Tag, domain string, externalSecure bool) []eventstore.Command { +func setupInstanceEvents( + ctx context.Context, + instanceID, orgID, projectID, appID, instanceName, orgName string, + defaultLanguage language.Tag, + domain string, + externalSecure bool, + trustedDomains []string, +) []eventstore.Command { return slices.Concat( setupInstanceElementsEvents(ctx, instanceID, instanceName, defaultLanguage), orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true, true), generatedDomainEvents(ctx, instanceID, orgID, projectID, appID, domain), + generatedTrustedDomainEvents(ctx, instanceID, trustedDomains), instanceCreatedMilestoneEvent(ctx, instanceID), ) } @@ -391,6 +400,7 @@ func setupInstanceConfig() *InstanceSetup { LoginClient: instanceSetupLoginClientConfig(), } conf.CustomDomain = "" + conf.TrustedDomains = []string{"trusted-domain.com", "another-trusted-domain.com"} return conf } @@ -409,6 +419,14 @@ func generatedDomainEvents(ctx context.Context, instanceID, orgID, projectID, ap } } +func generatedTrustedDomainEvents(ctx context.Context, instanceID string, trustedDomains []string) []eventstore.Command { + commands := make([]eventstore.Command, len(trustedDomains)) + for i, trustedDomain := range trustedDomains { + commands[i] = instance.NewTrustedDomainAddedEvent(ctx, &instance.NewAggregate(instanceID).Aggregate, trustedDomain) + } + return commands +} + func instanceCreatedMilestoneEvent(ctx context.Context, instanceID string) []eventstore.Command { return []eventstore.Command{ milestone.NewReachedEvent(ctx, milestone.NewInstanceAggregate(instanceID), milestone.InstanceCreated), @@ -463,6 +481,14 @@ func generatedDomainFilters(instanceID, orgID, projectID, appID, generatedDomain } } +func generatedTrustedDomainsFilters(trustedDomains []string) []expect { + filters := make([]expect, len(trustedDomains)) + for i := range trustedDomains { + filters[i] = expectFilter() + } + return filters +} + func humanFilters(orgID string) []expect { return []expect{ expectFilter(), @@ -1324,7 +1350,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { fields: fields{ eventstore: expectEventstore( slices.Concat( - setupInstanceFilters("INSTANCE", "ORG", "PROJECT", "console-id", "DOMAIN"), + setupInstanceFilters("INSTANCE", "ORG", "PROJECT", "console-id", "DOMAIN", []string{"trusted-domain.com", "another-trusted-domain.com"}), []expect{ expectPush( setupInstanceEvents(context.Background(), @@ -1337,6 +1363,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { language.English, "DOMAIN", false, + []string{"trusted-domain.com", "another-trusted-domain.com"}, )..., ), }, diff --git a/internal/command/instance_trusted_domain.go b/internal/command/instance_trusted_domain.go index b1e2ed739..33f3d376d 100644 --- a/internal/command/instance_trusted_domain.go +++ b/internal/command/instance_trusted_domain.go @@ -5,21 +5,20 @@ import ( "slices" "strings" + "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (*domain.ObjectDetails, error) { - trustedDomain = strings.TrimSpace(trustedDomain) - if trustedDomain == "" || len(trustedDomain) > 253 { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-Stk21", "Errors.Invalid.Argument") - } - if !allowDomainRunes.MatchString(trustedDomain) { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-S3v3w", "Errors.Instance.Domain.InvalidCharacter") +func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (details *domain.ObjectDetails, err error) { + trustedDomain, err = validateTrustedDomain(trustedDomain) + if err != nil { + return nil, err } model := NewInstanceTrustedDomainsWriteModel(ctx) - err := c.eventstore.FilterToQueryReducer(ctx, model) + err = c.eventstore.FilterToQueryReducer(ctx, model) if err != nil { return nil, err } @@ -33,6 +32,42 @@ func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) ( return writeModelToObjectDetails(&model.WriteModel), nil } +// setupTrustedDomain is only used for the instance setup process where all events are prepared and pushed in a single transaction +func (c *Commands) setupTrustedDomain(a *instance.Aggregate, trustedDomain string) preparation.Validation { + return func() (_ preparation.CreateCommands, err error) { + trustedDomain, err = validateTrustedDomain(trustedDomain) + if err != nil { + return nil, err + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + model := NewInstanceTrustedDomainsWriteModel(ctx) + events, err := filter(ctx, model.Query()) + if err != nil { + return nil, err + } + err = AppendAndReduce(model, events...) + if err != nil { + return nil, err + } + if slices.Contains(model.Domains, trustedDomain) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-hg42a", "Errors.Instance.Domain.AlreadyExists") + } + return []eventstore.Command{instance.NewTrustedDomainAddedEvent(ctx, &a.Aggregate, trustedDomain)}, nil + }, nil + } +} + +func validateTrustedDomain(trustedDomain string) (string, error) { + trustedDomain = strings.TrimSpace(trustedDomain) + if trustedDomain == "" || len(trustedDomain) > 253 { + return "", zerrors.ThrowInvalidArgument(nil, "COMMA-Stk21", "Errors.Invalid.Argument") + } + if !allowDomainRunes.MatchString(trustedDomain) { + return "", zerrors.ThrowInvalidArgument(nil, "COMMA-S3v3w", "Errors.Instance.Domain.InvalidCharacter") + } + return trustedDomain, nil +} + func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string, errorIfNotFound bool) (*domain.ObjectDetails, error) { trustedDomain = strings.TrimSpace(trustedDomain) if trustedDomain == "" || len(trustedDomain) > 253 {