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
This commit is contained in:
Livio Spring
2026-02-23 06:06:33 +00:00
committed by GitHub
parent 2c260a1f10
commit 71fab2e574
5 changed files with 90 additions and 11 deletions
+2
View File
@@ -28,6 +28,7 @@ type FirstInstance struct {
PatPath string PatPath string
LoginClientPatPath string LoginClientPatPath string
Features *command.InstanceFeatures Features *command.InstanceFeatures
TrustedDomains []string
Skip bool Skip bool
@@ -102,6 +103,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error
mig.instanceSetup.InstanceName = mig.InstanceName mig.instanceSetup.InstanceName = mig.InstanceName
mig.instanceSetup.CustomDomain = mig.externalDomain mig.instanceSetup.CustomDomain = mig.externalDomain
mig.instanceSetup.TrustedDomains = mig.TrustedDomains
mig.instanceSetup.DefaultLanguage = mig.DefaultLanguage mig.instanceSetup.DefaultLanguage = mig.DefaultLanguage
mig.instanceSetup.Org = mig.Org mig.instanceSetup.Org = mig.Org
// check if username is email style or else append @<orgname>.<custom-domain> // check if username is email style or else append @<orgname>.<custom-domain>
+5
View File
@@ -121,6 +121,11 @@ FirstInstance:
PatPath: # ZITADEL_FIRSTINSTANCE_PATPATH PatPath: # ZITADEL_FIRSTINSTANCE_PATPATH
LoginClientPatPath: # ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH LoginClientPatPath: # ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH
InstanceName: ZITADEL # ZITADEL_FIRSTINSTANCE_INSTANCENAME 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 DefaultLanguage: en # ZITADEL_FIRSTINSTANCE_DEFAULTLANGUAGE
Org: Org:
Name: ZITADEL # ZITADEL_FIRSTINSTANCE_ORG_NAME Name: ZITADEL # ZITADEL_FIRSTINSTANCE_ORG_NAME
+10
View File
@@ -40,6 +40,7 @@ type InstanceSetup struct {
zitadel ZitadelConfig zitadel ZitadelConfig
InstanceName string InstanceName string
CustomDomain string CustomDomain string
TrustedDomains []string
DefaultLanguage language.Tag DefaultLanguage language.Tag
Org InstanceOrgSetup Org InstanceOrgSetup
SecretGenerators *SecretGenerators SecretGenerators *SecretGenerators
@@ -294,6 +295,7 @@ func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (vali
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain)
setupTrustedDomains(c, &validations, instanceAgg, setup.TrustedDomains)
// optional setting if set // optional setting if set
setupMessageTexts(&validations, setup.MessageTexts, instanceAgg) setupMessageTexts(&validations, setup.MessageTexts, instanceAgg)
@@ -511,6 +513,14 @@ func setupGeneratedDomain(ctx context.Context, commands *Commands, validations *
return nil 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) { func setupMinimalInterfaces(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, orgAgg *org.Aggregate, projectOwner string, ids ZitadelConfig) {
projectAgg := project.NewAggregate(ids.projectID, orgAgg.ID) projectAgg := project.NewAggregate(ids.projectID, orgAgg.ID)
+30 -3
View File
@@ -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( return slices.Concat(
setupInstanceElementsFilters(instanceID), setupInstanceElementsFilters(instanceID),
orgFilters(orgID, true, true, true), orgFilters(orgID, true, true, true),
generatedDomainFilters(instanceID, orgID, projectID, appID, domain), 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( return slices.Concat(
setupInstanceElementsEvents(ctx, instanceID, instanceName, defaultLanguage), setupInstanceElementsEvents(ctx, instanceID, instanceName, defaultLanguage),
orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true, true), orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true, true),
generatedDomainEvents(ctx, instanceID, orgID, projectID, appID, domain), generatedDomainEvents(ctx, instanceID, orgID, projectID, appID, domain),
generatedTrustedDomainEvents(ctx, instanceID, trustedDomains),
instanceCreatedMilestoneEvent(ctx, instanceID), instanceCreatedMilestoneEvent(ctx, instanceID),
) )
} }
@@ -391,6 +400,7 @@ func setupInstanceConfig() *InstanceSetup {
LoginClient: instanceSetupLoginClientConfig(), LoginClient: instanceSetupLoginClientConfig(),
} }
conf.CustomDomain = "" conf.CustomDomain = ""
conf.TrustedDomains = []string{"trusted-domain.com", "another-trusted-domain.com"}
return conf 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 { func instanceCreatedMilestoneEvent(ctx context.Context, instanceID string) []eventstore.Command {
return []eventstore.Command{ return []eventstore.Command{
milestone.NewReachedEvent(ctx, milestone.NewInstanceAggregate(instanceID), milestone.InstanceCreated), 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 { func humanFilters(orgID string) []expect {
return []expect{ return []expect{
expectFilter(), expectFilter(),
@@ -1324,7 +1350,7 @@ func TestCommandSide_setUpInstance(t *testing.T) {
fields: fields{ fields: fields{
eventstore: expectEventstore( eventstore: expectEventstore(
slices.Concat( 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{ []expect{
expectPush( expectPush(
setupInstanceEvents(context.Background(), setupInstanceEvents(context.Background(),
@@ -1337,6 +1363,7 @@ func TestCommandSide_setUpInstance(t *testing.T) {
language.English, language.English,
"DOMAIN", "DOMAIN",
false, false,
[]string{"trusted-domain.com", "another-trusted-domain.com"},
)..., )...,
), ),
}, },
+43 -8
View File
@@ -5,21 +5,20 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (*domain.ObjectDetails, error) { func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (details *domain.ObjectDetails, err error) {
trustedDomain = strings.TrimSpace(trustedDomain) trustedDomain, err = validateTrustedDomain(trustedDomain)
if trustedDomain == "" || len(trustedDomain) > 253 { if err != nil {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-Stk21", "Errors.Invalid.Argument") return nil, err
}
if !allowDomainRunes.MatchString(trustedDomain) {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-S3v3w", "Errors.Instance.Domain.InvalidCharacter")
} }
model := NewInstanceTrustedDomainsWriteModel(ctx) model := NewInstanceTrustedDomainsWriteModel(ctx)
err := c.eventstore.FilterToQueryReducer(ctx, model) err = c.eventstore.FilterToQueryReducer(ctx, model)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -33,6 +32,42 @@ func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) (
return writeModelToObjectDetails(&model.WriteModel), nil 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) { func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string, errorIfNotFound bool) (*domain.ObjectDetails, error) {
trustedDomain = strings.TrimSpace(trustedDomain) trustedDomain = strings.TrimSpace(trustedDomain)
if trustedDomain == "" || len(trustedDomain) > 253 { if trustedDomain == "" || len(trustedDomain) > 253 {