fix: configure default url templates (#10416)

# Which Problems Are Solved

Emails are still send only with URLs to login v1.

# How the Problems Are Solved

Add configuration for URLs as URL templates, so that links can point at
Login v2.

# Additional Changes

None

# Additional Context

Closes #10236

---------

Co-authored-by: Marco A. <marco@zitadel.com>
(cherry picked from commit 0a14c01412)
This commit is contained in:
Stefan Benz
2025-08-26 12:14:41 +02:00
committed by Livio Spring
parent e06df6e161
commit 1625e5f7bc
18 changed files with 370 additions and 77 deletions

View File

@@ -52,7 +52,43 @@ type Config struct {
AssetCache middleware.CacheConfig
// LoginV2
DefaultOTPEmailURLV2 string
DefaultPaths *DefaultPaths
}
type DefaultPaths struct {
BasePath string
PasswordSetPath string
EmailCodePath string
OTPEmailPath string
}
func (c *Config) defaultBaseURL(ctx context.Context) string {
loginV2 := authz.GetInstance(ctx).Features().LoginV2
if loginV2.Required {
// use the origin as default
baseURI := http_utils.DomainContext(ctx).Origin()
// use custom base URI if defined
if loginV2.BaseURI != nil && loginV2.BaseURI.String() != "" {
baseURI = loginV2.BaseURI.String()
}
return baseURI + c.DefaultPaths.BasePath
}
return ""
}
func (c *Config) DefaultEmailCodeURLTemplate(ctx context.Context) string {
basePath := c.defaultBaseURL(ctx)
if basePath == "" {
return ""
}
return basePath + c.DefaultPaths.EmailCodePath
}
func (c *Config) DefaultPasswordSetURLTemplate(ctx context.Context) string {
basePath := c.defaultBaseURL(ctx)
if basePath == "" {
return ""
}
return c.defaultBaseURL(ctx) + c.DefaultPaths.PasswordSetPath
}
const (

View File

@@ -0,0 +1,178 @@
package login
import (
"context"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/feature"
)
func TestConfig_defaultBaseURL(t *testing.T) {
t.Parallel()
config := &Config{
DefaultPaths: &DefaultPaths{BasePath: "/basepath"},
}
baseCustomURI, err := url.Parse("https://custom")
require.Nil(t, err)
tt := []struct {
name string
inputCtx context.Context
http.DomainCtx
expected string
}{
{
name: "LoginV2 not required",
inputCtx: authz.NewMockContext("instance1", "org1", "user1"),
expected: "",
},
{
name: "LoginV2 required, no custom BaseURI",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expected: "https://origin/basepath",
},
{
name: "LoginV2 required, custom BaseURI",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: baseCustomURI}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expected: "https://custom/basepath",
},
{
name: "LoginV2 required, custom BaseURI empty string",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: &url.URL{}}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expected: "https://origin/basepath",
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := config.defaultBaseURL(tc.inputCtx)
assert.Equal(t, tc.expected, result)
})
}
}
func TestConfig_DefaultEmailCodeURLTemplate(t *testing.T) {
t.Parallel()
tt := []struct {
testName string
inputCtx context.Context
expectedEmailURLTemplate string
}{
{
testName: "when base path is empty should return empty email url template",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: false, BaseURI: &url.URL{}}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expectedEmailURLTemplate: "",
},
{
testName: "when base path is not empty should return expected url template",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: &url.URL{}}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expectedEmailURLTemplate: "https://origin/basepath/email-code-path",
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
c := &Config{
DefaultPaths: &DefaultPaths{
BasePath: "/basepath",
EmailCodePath: "/email-code-path"},
}
// Test
res := c.DefaultEmailCodeURLTemplate(tc.inputCtx)
// Verify
assert.Equal(t, tc.expectedEmailURLTemplate, res)
})
}
}
func TestConfig_DefaultPasswordSetURLTemplate(t *testing.T) {
t.Parallel()
tt := []struct {
testName string
inputCtx context.Context
expectedEmailURLTemplate string
}{
{
testName: "when base path is empty should return empty email url template",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: false, BaseURI: &url.URL{}}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expectedEmailURLTemplate: "",
},
{
testName: "when base path is not empty should return expected url template",
inputCtx: http.WithDomainContext(
authz.NewMockContext("instance1", "org1", "user1",
authz.WithMockFeatures(feature.Features{LoginV2: feature.LoginV2{Required: true, BaseURI: &url.URL{}}}),
),
&http.DomainCtx{Protocol: "https", PublicHost: "origin"},
),
expectedEmailURLTemplate: "https://origin/basepath/password-set-path",
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
c := &Config{
DefaultPaths: &DefaultPaths{
BasePath: "/basepath",
PasswordSetPath: "/password-set-path",
},
}
// Test
res := c.DefaultPasswordSetURLTemplate(tc.inputCtx)
// Verify
assert.Equal(t, tc.expectedEmailURLTemplate, res)
})
}
}