From dda34d55aa0f0f997a67a05eeb11de561a2c53dc Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Fri, 27 Feb 2026 16:25:12 +0100 Subject: [PATCH 1/3] feat: add multi-issuer support based on host header Dynamically select the OIDC issuer from the incoming X-Forwarded-Host (or Host) header, validated against a configured list of trusted issuers. This allows the auth service to serve multiple domains with correct issuer URLs in discovery and token endpoints. Adds --auth-issuers flag for specifying additional trusted issuer URLs. Co-Authored-By: Claude Opus 4.6 --- cmd/serve.go | 8 +++++++- pkg/api/module.go | 10 +++++++--- pkg/oidc/grant_type_bearer.go | 14 +++++++++----- pkg/oidc/issuer.go | 25 +++++++++++++++++++++++++ pkg/oidc/module.go | 4 ++-- pkg/oidc/oidc_test.go | 2 +- pkg/oidc/provider.go | 25 +++++++++++++++++++------ 7 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 pkg/oidc/issuer.go diff --git a/cmd/serve.go b/cmd/serve.go index de2b2cb..5fa1b66 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -44,6 +44,7 @@ const ( DelegatedClientSecretFlag = "delegated-client-secret" DelegatedIssuerFlag = "delegated-issuer" BaseUrlFlag = "base-url" + AuthIssuersFlag = "auth-issuers" ListenFlag = "listen" SigningKeyFlag = "signing-key" ConfigFlag = "config" @@ -108,6 +109,7 @@ func newServeCommand() *cobra.Command { cmd.Flags().String(DelegatedClientIDFlag, "", "Delegated OIDC client id") cmd.Flags().String(DelegatedClientSecretFlag, "", "Delegated OIDC client secret") cmd.Flags().String(BaseUrlFlag, "http://localhost:8080", "Base service url") + cmd.Flags().StringSlice(AuthIssuersFlag, []string{}, "Additional trusted issuer URLs for multi-domain support") cmd.Flags().String(SigningKeyFlag, defaultSigningKey, "Signing key") cmd.Flags().String(ListenFlag, ":8080", "Listening address") cmd.Flags().String(ConfigFlag, "", "Config file name without extension") @@ -130,6 +132,9 @@ func runServe(cmd *cobra.Command, _ []string) error { return errors.New("base url must be defined") } + additionalIssuers, _ := cmd.Flags().GetStringSlice(AuthIssuersFlag) + trustedIssuers := append([]string{baseUrl}, additionalIssuers...) + signingKey, _ := cmd.Flags().GetString(SigningKeyFlag) if signingKey == "" { return errors.New("signing key must be defined") @@ -182,10 +187,11 @@ func runServe(cmd *cobra.Command, _ []string) error { otlpHttpClientModule(service.IsDebug(cmd)), fx.Supply(fx.Annotate(cmd.Context(), fx.As(new(context.Context)))), sqlstorage.Module(*connectionOptions, key, service.IsDebug(cmd), o.Clients...), - oidc.Module(key, baseUrl, o.Clients...), + oidc.Module(key, baseUrl, trustedIssuers, o.Clients...), api.Module( listen, baseUrl, + trustedIssuers, sharedapi.ServiceInfo{ Version: Version, Debug: service.IsDebug(cmd), diff --git a/pkg/api/module.go b/pkg/api/module.go index 2b6d5d7..37a39e8 100644 --- a/pkg/api/module.go +++ b/pkg/api/module.go @@ -11,13 +11,15 @@ import ( "github.com/formancehq/go-libs/v3/health" "github.com/formancehq/go-libs/v3/httpserver" "github.com/formancehq/go-libs/v3/logging" + authoidc "github.com/formancehq/auth/pkg/oidc" "github.com/zitadel/oidc/v2/pkg/op" "go.uber.org/fx" ) func CreateRootRouter( logger logging.Logger, - issuer string, + defaultIssuer string, + trustedIssuers []string, debug bool, ) chi.Router { rootRouter := chi.NewRouter() @@ -31,6 +33,8 @@ func CreateRootRouter( }) rootRouter.Use(func(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := authoidc.HostFromRequest(r) + issuer := authoidc.IssuerForHost(host, defaultIssuer, trustedIssuers) handler.ServeHTTP(w, r.WithContext( op.ContextWithIssuer(r.Context(), issuer), )) @@ -43,12 +47,12 @@ func addInfoRoute(router chi.Router, serviceInfo api.ServiceInfo) { router.Get("/_info", api.InfoHandler(serviceInfo)) } -func Module(addr, issuer string, serviceInfo api.ServiceInfo, debug bool) fx.Option { +func Module(addr, defaultIssuer string, trustedIssuers []string, serviceInfo api.ServiceInfo, debug bool) fx.Option { return fx.Options( health.Module(), fx.Supply(serviceInfo), fx.Provide(func(logger logging.Logger) chi.Router { - return CreateRootRouter(logger, issuer, debug) + return CreateRootRouter(logger, defaultIssuer, trustedIssuers, debug) }), fx.Invoke( addInfoRoute, diff --git a/pkg/oidc/grant_type_bearer.go b/pkg/oidc/grant_type_bearer.go index 4ad7dcc..86c9453 100644 --- a/pkg/oidc/grant_type_bearer.go +++ b/pkg/oidc/grant_type_bearer.go @@ -67,11 +67,13 @@ type JWTProfileVerifier interface { type JWTAuthorizationGrantExchanger interface { op.Exchanger - JWTProfileVerifier() JWTProfileVerifier + JWTProfileVerifier(issuer string) JWTProfileVerifier } -func grantTypeBearer(issuer string, p JWTAuthorizationGrantExchanger) http.HandlerFunc { +func grantTypeBearer(p JWTAuthorizationGrantExchanger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + issuer := op.IssuerFromContext(r.Context()) + profileRequest, err := op.ParseJWTProfileGrantRequest(r, p.Decoder()) if err != nil { op.RequestError(w, r, err) @@ -95,7 +97,7 @@ func grantTypeBearer(issuer string, p JWTAuthorizationGrantExchanger) http.Handl } } - tokenRequest, err := VerifyJWTAssertion(r.Context(), profileRequest.Assertion, p.JWTProfileVerifier()) + tokenRequest, err := VerifyJWTAssertion(r.Context(), profileRequest.Assertion, p.JWTProfileVerifier(issuer)) if err != nil { op.RequestError(w, r, err) return @@ -115,7 +117,7 @@ func grantTypeBearer(issuer string, p JWTAuthorizationGrantExchanger) http.Handl tokenRequest.Scopes = tokens.Scopes - resp, err := CreateJWTTokenResponse(r.Context(), issuer, tokenRequest, p, client) + resp, err := CreateJWTTokenResponse(r.Context(), tokenRequest, p, client) if err != nil { op.RequestError(w, r, err) return @@ -136,7 +138,9 @@ func ParseAssertion(assertion string) (*oidc.AccessTokenClaims, error) { return claims, nil } -func CreateJWTTokenResponse(ctx context.Context, issuer string, tokenRequest *oidc.JWTTokenRequest, creator op.TokenCreator, client op.Client) (*oidc.AccessTokenResponse, error) { +func CreateJWTTokenResponse(ctx context.Context, tokenRequest *oidc.JWTTokenRequest, creator op.TokenCreator, client op.Client) (*oidc.AccessTokenResponse, error) { + issuer := op.IssuerFromContext(ctx) + id, exp, err := creator.Storage().CreateAccessToken(ctx, tokenRequest) if err != nil { return nil, err diff --git a/pkg/oidc/issuer.go b/pkg/oidc/issuer.go new file mode 100644 index 0000000..cbab5b3 --- /dev/null +++ b/pkg/oidc/issuer.go @@ -0,0 +1,25 @@ +package oidc + +import ( + "net/http" + "net/url" +) + +// HostFromRequest returns the effective host, preferring X-Forwarded-Host over Host. +func HostFromRequest(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" { + return fwd + } + return r.Host +} + +// IssuerForHost returns the trusted issuer matching the given host, or the default. +func IssuerForHost(host, defaultIssuer string, trustedIssuers []string) string { + for _, issuer := range trustedIssuers { + u, err := url.Parse(issuer) + if err == nil && u.Host == host { + return issuer + } + } + return defaultIssuer +} diff --git a/pkg/oidc/module.go b/pkg/oidc/module.go index 059ee4d..06c64c2 100644 --- a/pkg/oidc/module.go +++ b/pkg/oidc/module.go @@ -16,7 +16,7 @@ import ( "go.uber.org/fx" ) -func Module(privateKey *rsa.PrivateKey, issuer string, staticClients ...auth.StaticClient) fx.Option { +func Module(privateKey *rsa.PrivateKey, issuer string, trustedIssuers []string, staticClients ...auth.StaticClient) fx.Option { return fx.Options( fx.Invoke(fx.Annotate(func(router chi.Router, provider op.OpenIDProvider, storage Storage, relyingParty rp.RelyingParty) { @@ -37,7 +37,7 @@ func Module(privateKey *rsa.PrivateKey, issuer string, staticClients ...auth.Sta } } - return NewOpenIDProvider(storage, issuer, configuration.Issuer, keySet) + return NewOpenIDProvider(storage, issuer, trustedIssuers, configuration.Issuer, keySet) }, fx.ParamTags(``, ``, `optional:"true"`))), ) } diff --git a/pkg/oidc/oidc_test.go b/pkg/oidc/oidc_test.go index 13d75b5..456597c 100644 --- a/pkg/oidc/oidc_test.go +++ b/pkg/oidc/oidc_test.go @@ -107,7 +107,7 @@ func withServer(t *testing.T, fn func(m *mockoidc.MockOIDC, storage *sqlstorage. require.NoError(t, err) // Construct our oidc provider - provider, err := oidc.NewOpenIDProvider(storageFacade, serverUrl, mockOIDC.Issuer(), keySet) + provider, err := oidc.NewOpenIDProvider(storageFacade, serverUrl, []string{serverUrl}, mockOIDC.Issuer(), keySet) require.NoError(t, err) // Create the router diff --git a/pkg/oidc/provider.go b/pkg/oidc/provider.go index 27b429c..bbf46d0 100644 --- a/pkg/oidc/provider.go +++ b/pkg/oidc/provider.go @@ -47,12 +47,12 @@ type provider struct { op.OpenIDProvider delegatedIssuerJsonWebKeySet jose.JSONWebKeySet delegatedIssuer string - issuer string + trustedIssuers []string } -func (p provider) JWTProfileVerifier() JWTProfileVerifier { +func (p provider) JWTProfileVerifier(issuer string) JWTProfileVerifier { return &verifier{ - issuer: p.issuer, + issuer: issuer, delegatedIssuer: p.delegatedIssuer, mat: time.Hour, offset: 0, @@ -62,7 +62,7 @@ func (p provider) JWTProfileVerifier() JWTProfileVerifier { var _ JWTAuthorizationGrantExchanger = (*provider)(nil) -func NewOpenIDProvider(storage op.Storage, issuer, delegatedIssuer string, delegatedIssuerJsonWebKeySet *jose.JSONWebKeySet) (op.OpenIDProvider, error) { +func NewOpenIDProvider(storage op.Storage, issuer string, trustedIssuers []string, delegatedIssuer string, delegatedIssuerJsonWebKeySet *jose.JSONWebKeySet) (op.OpenIDProvider, error) { var p op.OpenIDProvider interceptors := make([]op.Option, 0) @@ -73,8 +73,8 @@ func NewOpenIDProvider(storage op.Storage, issuer, delegatedIssuer string, deleg // as the library does not implement what we needs if r.URL.Path == op.DefaultEndpoints.Token.Relative() && r.FormValue("grant_type") == string(oidc.GrantTypeBearer) { - grantTypeBearer(issuer, &provider{ - issuer: issuer, + grantTypeBearer(&provider{ + trustedIssuers: trustedIssuers, OpenIDProvider: p, delegatedIssuerJsonWebKeySet: *delegatedIssuerJsonWebKeySet, delegatedIssuer: delegatedIssuer, @@ -86,6 +86,19 @@ func NewOpenIDProvider(storage op.Storage, issuer, delegatedIssuer string, deleg })) } + + // Dynamic issuer interceptor: overrides ZITADEL's StaticIssuer with the + // correct issuer resolved from the incoming host header. + interceptors = append(interceptors, op.WithHttpInterceptors(func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := HostFromRequest(r) + resolved := IssuerForHost(host, issuer, trustedIssuers) + handler.ServeHTTP(w, r.WithContext( + op.ContextWithIssuer(r.Context(), resolved), + )) + }) + })) + interceptors = append(interceptors, op.WithAllowInsecure()) p, err := op.NewOpenIDProvider(issuer, &op.Config{ From 4a9f691a245f2c6d8196bbbfddeb81fa6ad6c8ff Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Fri, 27 Feb 2026 17:03:41 +0100 Subject: [PATCH 2/3] Debug Signed-off-by: Sylvain Rabot --- pkg/oidc/provider.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/oidc/provider.go b/pkg/oidc/provider.go index bbf46d0..270576c 100644 --- a/pkg/oidc/provider.go +++ b/pkg/oidc/provider.go @@ -93,6 +93,8 @@ func NewOpenIDProvider(storage op.Storage, issuer string, trustedIssuers []strin return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host := HostFromRequest(r) resolved := IssuerForHost(host, issuer, trustedIssuers) + w.Header().Set("X-Host", host) + w.Header().Set("X-Issuer", resolved) handler.ServeHTTP(w, r.WithContext( op.ContextWithIssuer(r.Context(), resolved), )) From 3c16e4375514ea329cb92b09b54237a6fa5a100e Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Fri, 27 Feb 2026 17:19:45 +0100 Subject: [PATCH 3/3] fix: use dynamic OIDC provider so discovery reads issuer from host The previous approach overrode the issuer in context, but ZITADEL's discovery handler calls config.IssuerFromRequest(r) which invokes the stored IssuerFromRequest function directly, bypassing context. Switch from op.NewOpenIDProvider (StaticIssuer) to op.NewDynamicOpenIDProvider (IssuerFromHost) and rewrite r.Host in chi middleware so ZITADEL constructs the correct issuer URL per-request. Co-Authored-By: Claude Opus 4.6 --- pkg/api/module.go | 5 +++++ pkg/oidc/issuer.go | 9 +++++++++ pkg/oidc/provider.go | 31 ++++++++++++++----------------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/pkg/api/module.go b/pkg/api/module.go index 37a39e8..ada7adf 100644 --- a/pkg/api/module.go +++ b/pkg/api/module.go @@ -35,6 +35,11 @@ func CreateRootRouter( return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host := authoidc.HostFromRequest(r) issuer := authoidc.IssuerForHost(host, defaultIssuer, trustedIssuers) + // Rewrite r.Host so ZITADEL's dynamic IssuerFromRequest reads + // the correct host for discovery and other OIDC endpoints. + if h := authoidc.HostFromIssuer(issuer); h != "" { + r.Host = h + } handler.ServeHTTP(w, r.WithContext( op.ContextWithIssuer(r.Context(), issuer), )) diff --git a/pkg/oidc/issuer.go b/pkg/oidc/issuer.go index cbab5b3..6d1f0e8 100644 --- a/pkg/oidc/issuer.go +++ b/pkg/oidc/issuer.go @@ -23,3 +23,12 @@ func IssuerForHost(host, defaultIssuer string, trustedIssuers []string) string { } return defaultIssuer } + +// HostFromIssuer extracts the host component from an issuer URL. +func HostFromIssuer(issuer string) string { + u, err := url.Parse(issuer) + if err != nil { + return "" + } + return u.Host +} diff --git a/pkg/oidc/provider.go b/pkg/oidc/provider.go index 270576c..897eef9 100644 --- a/pkg/oidc/provider.go +++ b/pkg/oidc/provider.go @@ -3,6 +3,7 @@ package oidc import ( "crypto/sha256" "net/http" + "net/url" "time" "github.com/zitadel/oidc/v2/pkg/oidc" @@ -65,6 +66,11 @@ var _ JWTAuthorizationGrantExchanger = (*provider)(nil) func NewOpenIDProvider(storage op.Storage, issuer string, trustedIssuers []string, delegatedIssuer string, delegatedIssuerJsonWebKeySet *jose.JSONWebKeySet) (op.OpenIDProvider, error) { var p op.OpenIDProvider + parsedIssuer, err := url.Parse(issuer) + if err != nil { + return nil, err + } + interceptors := make([]op.Option, 0) if delegatedIssuer != "" { interceptors = append(interceptors, op.WithHttpInterceptors(func(handler http.Handler) http.Handler { @@ -87,23 +93,14 @@ func NewOpenIDProvider(storage op.Storage, issuer string, trustedIssuers []strin })) } - // Dynamic issuer interceptor: overrides ZITADEL's StaticIssuer with the - // correct issuer resolved from the incoming host header. - interceptors = append(interceptors, op.WithHttpInterceptors(func(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host := HostFromRequest(r) - resolved := IssuerForHost(host, issuer, trustedIssuers) - w.Header().Set("X-Host", host) - w.Header().Set("X-Issuer", resolved) - handler.ServeHTTP(w, r.WithContext( - op.ContextWithIssuer(r.Context(), resolved), - )) - }) - })) - - interceptors = append(interceptors, op.WithAllowInsecure()) - - p, err := op.NewOpenIDProvider(issuer, &op.Config{ + if parsedIssuer.Scheme == "http" { + interceptors = append(interceptors, op.WithAllowInsecure()) + } + + // Use NewDynamicOpenIDProvider so ZITADEL reads the issuer from r.Host + // (which is set by the chi middleware based on trusted issuers) instead + // of using a static issuer string. + p, err = op.NewDynamicOpenIDProvider(parsedIssuer.Path, &op.Config{ CryptoKey: sha256.Sum256([]byte("test")), DefaultLogoutRedirectURI: pathLoggedOut, CodeMethodS256: true,