Repository: zitadel/zitadel
Pull request #9854: feat: handle federated logout requests and SAML SLO reponses
Author: login: livio-a
Created: 2025-05-06T11:50:06Z | Merged: 2025-05-21T05:08:20Z

# Which Problems Are Solved

Currently if a user signs in using an IdP, once they sign out of Zitadel, the corresponding IdP session is not terminated. This can be the desired behavior. In some cases, e.g. when using a shared computer it results in a potential security risk, since a follower user might be able to sign in as the previous using the still open IdP session.

# How the Problems Are Solved

#9841 already enabled the management of a federated logout option on SAML IdPs. This PR implements it the following way:
- During the termination of a login V1 session using OIDC end_session endpoint, Zitadel will check if an IdP was used to authenticate that session.
- In case there was a SAML IdP used with Federated Logout enabled, it will intercept the logout process, store the information into the shared cache and redirect to the federated logout endpoint in the V1 login.
- The V1 login federated logout endpoint checks every request on an existing cache entry. On success it will create a SAML logout request for the used IdP and either redirect or POST to the configured SLO endpoint. The cache entry is updated with a `redirected` state.
- A SLO endpoint is added to the `/idp` handlers, which will handle the SAML logout responses. At the moment it will check again for an existing federated logout entry (with state `redirected`) in the cache. On success, the user is redirected to the initially provided `post_logout_redirect_uri` from the end_session request.

# Additional Changes

None

# Additional Context

- part of #9228 
- backport to 3.x



Diff:
diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml
index 0d71b4d8174..2934f5dd784 100644
--- a/cmd/defaults.yaml
+++ b/cmd/defaults.yaml
@@ -312,6 +312,16 @@ Caches:
       AddSource: true
       Formatter:
         Format: text
+  # Federated logouts store the information needed to handle federated logout and their state transfer
+  FederatedLogouts:
+    Connector: "postgres"
+    MaxAge: 1h
+    LastUseAge: 10m
+    Log:
+      Level: error
+      AddSource: true
+      Formatter:
+        Format: text
 
 Machine:
   # Cloud-hosted VMs need to specify their metadata endpoint so that the machine can be uniquely identified.
diff --git a/cmd/start/start.go b/cmd/start/start.go
index e3d84625b48..86d20781054 100644
--- a/cmd/start/start.go
+++ b/cmd/start/start.go
@@ -72,12 +72,14 @@ import (
 	"github.com/zitadel/zitadel/internal/authz"
 	authz_repo "github.com/zitadel/zitadel/internal/authz/repository"
 	authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore"
+	"github.com/zitadel/zitadel/internal/cache"
 	"github.com/zitadel/zitadel/internal/cache/connector"
 	"github.com/zitadel/zitadel/internal/command"
 	"github.com/zitadel/zitadel/internal/crypto"
 	cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
 	"github.com/zitadel/zitadel/internal/database"
 	"github.com/zitadel/zitadel/internal/domain"
+	"github.com/zitadel/zitadel/internal/domain/federatedlogout"
 	"github.com/zitadel/zitadel/internal/eventstore"
 	old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
 	new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
@@ -503,7 +505,12 @@ func startAPIs(
 	assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge)
 	apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle))
 
-	apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, instanceInterceptor.Handler))
+	federatedLogoutsCache, err := connector.StartCache[federatedlogout.Index, string, *federatedlogout.FederatedLogout](ctx, []federatedlogout.Index{federatedlogout.IndexRequestID}, cache.PurposeFederatedLogout, cacheConnectors.Config.FederatedLogouts, cacheConnectors)
+	if err != nil {
+		return nil, err
+	}
+
+	apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, instanceInterceptor.Handler, federatedLogoutsCache))
 
 	userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources, login.EndpointExternalLoginCallbackFormPost, login.EndpointSAMLACS)
 	if err != nil {
@@ -524,7 +531,25 @@ func startAPIs(
 	}
 	apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler)
 
-	oidcServer, err := oidc.NewServer(ctx, config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, config.Log.Slog(), config.SystemDefaults.SecretHasher)
+	oidcServer, err := oidc.NewServer(
+		ctx,
+		config.OIDC,
+		login.DefaultLoggedOutPath,
+		config.ExternalSecure,
+		commands,
+		queries,
+		authRepo,
+		keys.OIDC,
+		keys.OIDCKey,
+		eventstore,
+		dbClient,
+		userAgentInterceptor,
+		instanceInterceptor.Handler,
+		limitingAccessInterceptor,
+		config.Log.Slog(),
+		config.SystemDefaults.SecretHasher,
+		federatedLogoutsCache,
+	)
 	if err != nil {
 		return nil, fmt.Errorf("unable to start oidc provider: %w", err)
 	}
@@ -573,6 +598,7 @@ func startAPIs(
 		keys.IDPConfig,
 		keys.CSRFCookieKey,
 		cacheConnectors,
+		federatedLogoutsCache,
 	)
 	if err != nil {
 		return nil, fmt.Errorf("unable to start login: %w", err)
diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go
index ebf904a395e..f688ba2352a 100644
--- a/internal/api/idp/idp.go
+++ b/internal/api/idp/idp.go
@@ -15,10 +15,13 @@ import (
 	"github.com/muhlemmer/gu"
 	"github.com/zitadel/logging"
 
+	"github.com/zitadel/zitadel/internal/api/authz"
 	http_utils "github.com/zitadel/zitadel/internal/api/http"
 	"github.com/zitadel/zitadel/internal/api/ui/login"
+	"github.com/zitadel/zitadel/internal/cache"
 	"github.com/zitadel/zitadel/internal/command"
 	"github.com/zitadel/zitadel/internal/crypto"
+	"github.com/zitadel/zitadel/internal/domain/federatedlogout"
 	"github.com/zitadel/zitadel/internal/form"
 	"github.com/zitadel/zitadel/internal/idp"
 	"github.com/zitadel/zitadel/internal/idp/providers/apple"
@@ -44,6 +47,7 @@ const (
 	metadataPath    = idpPrefix + "/saml/metadata"
 	acsPath         = idpPrefix + "/saml/acs"
 	certificatePath = idpPrefix + "/saml/certificate"
+	sloPath         = idpPrefix + "/saml/slo"
 
 	paramIntentID         = "id"
 	paramToken            = "token"
@@ -62,6 +66,7 @@ type Handler struct {
 	callbackURL         func(ctx context.Context) string
 	samlRootURL         func(ctx context.Context, idpID string) string
 	loginSAMLRootURL    func(ctx context.Context) string
+	caches              *Caches
 }
 
 type externalIDPCallbackData struct {
@@ -104,6 +109,7 @@ func NewHandler(
 	queries *query.Queries,
 	encryptionAlgorithm crypto.EncryptionAlgorithm,
 	instanceInterceptor func(next http.Handler) http.Handler,
+	federatedLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout],
 ) http.Handler {
 	h := &Handler{
 		commands:            commands,
@@ -113,6 +119,7 @@ func NewHandler(
 		callbackURL:         CallbackURL(),
 		samlRootURL:         SAMLRootURL(),
 		loginSAMLRootURL:    LoginSAMLRootURL(),
+		caches:              &Caches{federatedLogouts: federatedLogoutCache},
 	}
 
 	router := mux.NewRouter()
@@ -121,9 +128,14 @@ func NewHandler(
 	router.HandleFunc(metadataPath, h.handleMetadata)
 	router.HandleFunc(certificatePath, h.handleCertificate)
 	router.HandleFunc(acsPath, h.handleACS)
+	router.HandleFunc(sloPath, h.handleSLO)
 	return router
 }
 
+type Caches struct {
+	federatedLogouts cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout]
+}
+
 func parseSAMLRequest(r *http.Request) *externalSAMLIDPCallbackData {
 	vars := mux.Vars(r)
 	return &externalSAMLIDPCallbackData{
@@ -351,6 +363,38 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
 	redirectToSuccessURL(w, r, intent, token, userID)
 }
 
+func (h *Handler) handleSLO(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	data := parseSAMLRequest(r)
+
+	logoutState, ok := h.caches.federatedLogouts.Get(ctx, federatedlogout.IndexRequestID, federatedlogout.Key(authz.GetInstance(ctx).InstanceID(), data.RelayState))
+	if !ok || logoutState.State != federatedlogout.StateRedirected {
+		err := zerrors.ThrowNotFound(nil, "SAML-3uor2", "Errors.Intent.NotFound")
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// For the moment we just make sure the callback matches the IDP it was started on / intended for.
+
+	provider, err := h.getProvider(ctx, data.IDPID)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if _, ok = provider.(*saml2.Provider); !ok {
+		err := zerrors.ThrowInvalidArgument(nil, "SAML-ui9wyux0hp", "Errors.Intent.IDPInvalid")
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// We could also parse and validate the response here, but for example Azure does not sign it and thus would already fail.
+	// Also we can't really act on it if it fails.
+
+	err = h.caches.federatedLogouts.Delete(ctx, federatedlogout.IndexRequestID, federatedlogout.Key(logoutState.InstanceID, logoutState.SessionID))
+	logging.WithFields("instanceID", logoutState.InstanceID, "sessionID", logoutState.SessionID).OnError(err).Error("could not delete federated logout")
+	http.Redirect(w, r, logoutState.PostLogoutRedirectURI, http.StatusFound)
+}
+
 func (h *Handler) tryMigrateExternalUser(ctx context.Context, idpID string, idpUser idp.User, idpSession idp.Session) (userID string, err error) {
 	migration, ok := idpSession.(idp.SessionSupportsMigration)
 	if !ok {
diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go
index f750b2a3eaa..f9ea0926271 100644
--- a/internal/api/oidc/auth_request.go
+++ b/internal/api/oidc/auth_request.go
@@ -21,6 +21,7 @@ import (
 	"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
 	"github.com/zitadel/zitadel/internal/command"
 	"github.com/zitadel/zitadel/internal/domain"
+	"github.com/zitadel/zitadel/internal/domain/federatedlogout"
 	"github.com/zitadel/zitadel/internal/query"
 	"github.com/zitadel/zitadel/internal/telemetry/tracing"
 	"github.com/zitadel/zitadel/internal/user/model"
@@ -236,6 +237,11 @@ func (o *OPStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken
 }
 
 func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID string) (err error) {
+	_, err = o.terminateSession(ctx, userID)
+	return err
+}
+
+func (o *OPStorage) terminateSession(ctx context.Context, userID string) (sessions []command.HumanSignOutSession, err error) {
 	ctx, span := tracing.NewSpan(ctx)
 	defer func() {
 		err = oidcError(err)
@@ -244,22 +250,22 @@ func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID strin
 	userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
 	if !ok {
 		logging.Error("no user agent id")
-		return zerrors.ThrowPreconditionFailed(nil, "OIDC-fso7F", "no user agent id")
+		return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-fso7F", "no user agent id")
 	}
-	sessions, err := o.repo.UserSessionsByAgentID(ctx, userAgentID)
+	sessions, err = o.repo.UserSessionsByAgentID(ctx, userAgentID)
 	if err != nil {
 		logging.WithError(err).Error("error retrieving user sessions")
-		return err
+		return nil, err
 	}
 	if len(sessions) == 0 {
-		return nil
+		return nil, nil
 	}
 	data := authz.CtxData{
 		UserID: userID,
 	}
 	err = o.command.HumansSignOut(authz.SetCtxData(ctx, data), userAgentID, sessions)
 	logging.OnError(err).Error("error signing out")
-	return err
+	return sessions, err
 }
 
 func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionRequest *op.EndSessionRequest) (redirectURI string, err error) {
@@ -294,7 +300,16 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	// So if any condition is not met, we handle the request as a V1 request and do a (v1) TerminateSession,
 	// which terminates all sessions of the user agent, identified by cookie.
 	if endSessionRequest.IDTokenHintClaims == nil || endSessionRequest.IDTokenHintClaims.SessionID == "" {
-		return endSessionRequest.RedirectURI, o.TerminateSession(ctx, endSessionRequest.UserID, endSessionRequest.ClientID)
+		sessions, err := o.terminateSession(ctx, endSessionRequest.UserID)
+		if err != nil {
+			return "", err
+		}
+		if len(sessions) == 1 {
+			if path := o.federatedLogout(ctx, sessions[0].ID, endSessionRequest.RedirectURI); path != "" {
+				return path, nil
+			}
+		}
+		return endSessionRequest.RedirectURI, nil
 	}
 
 	// V1:
@@ -305,6 +320,9 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 		if err != nil {
 			return "", err
 		}
+		if path := o.federatedLogout(ctx, endSessionRequest.IDTokenHintClaims.SessionID, endSessionRequest.RedirectURI); path != "" {
+			return path, nil
+		}
 		return endSessionRequest.RedirectURI, nil
 	}
 
@@ -317,6 +335,39 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
 }
 
+// federatedLogout checks whether the session has an idp session linked and the IDP template is configured for federated logout.
+// If so, it creates a federated logout request and stores it in the cache and returns the logout path.
+func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postLogoutRedirectURI string) string {
+	session, err := o.repo.UserSessionByID(ctx, sessionID)
+	if err != nil {
+		logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "sessionID", sessionID).
+			WithError(err).Error("error retrieving user session")
+		return ""
+	}
+	if session.SelectedIDPConfigID.String == "" {
+		return ""
+	}
+	identityProvider, err := o.query.IDPTemplateByID(ctx, false, session.SelectedIDPConfigID.String, false, nil)
+	if err != nil {
+		logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "idpID", session.SelectedIDPConfigID.String, "sessionID", sessionID).
+			WithError(err).Error("error retrieving idp template")
+		return ""
+	}
+	if identityProvider.SAMLIDPTemplate == nil || !identityProvider.FederatedLogoutEnabled {
+		return ""
+	}
+	o.federateLogoutCache.Set(ctx, &federatedlogout.FederatedLogout{
+		InstanceID:            authz.GetInstance(ctx).InstanceID(),
+		FingerPrintID:         authz.GetCtxData(ctx).AgentID,
+		SessionID:             sessionID,
+		IDPID:                 session.SelectedIDPConfigID.String,
+		UserID:                session.UserID,
+		PostLogoutRedirectURI: postLogoutRedirectURI,
+		State:                 federatedlogout.StateCreated,
+	})
+	return login.ExternalLogoutPath(sessionID)
+}
+
 func buildLoginV2LogoutURL(baseURI *url.URL, redirectURI string) string {
 	baseURI.JoinPath(LogoutPath)
 	q := baseURI.Query()
diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go
index 37a9ba2bce1..d7171b957bd 100644
--- a/internal/api/oidc/op.go
+++ b/internal/api/oidc/op.go
@@ -15,9 +15,11 @@ import (
 	"github.com/zitadel/zitadel/internal/api/http/middleware"
 	"github.com/zitadel/zitadel/internal/api/ui/login"
 	"github.com/zitadel/zitadel/internal/auth/repository"
+	"github.com/zitadel/zitadel/internal/cache"
 	"github.com/zitadel/zitadel/internal/command"
 	"github.com/zitadel/zitadel/internal/crypto"
 	"github.com/zitadel/zitadel/internal/database"
+	"github.com/zitadel/zitadel/internal/domain/federatedlogout"
 	"github.com/zitadel/zitadel/internal/eventstore"
 	"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
 	"github.com/zitadel/zitadel/internal/query"
@@ -76,6 +78,7 @@ type OPStorage struct {
 	locker                            crdb.Locker
 	assetAPIPrefix                    func(ctx context.Context) string
 	contextToIssuer                   func(context.Context) string
+	federateLogoutCache               cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout]
 }
 
 // Provider is used to overload certain [op.Provider] methods
@@ -115,12 +118,13 @@ func NewServer(
 	accessHandler *middleware.AccessInterceptor,
 	fallbackLogger *slog.Logger,
 	hashConfig crypto.HashConfig,
+	federatedLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout],
 ) (*Server, error) {
 	opConfig, err := createOPConfig(config, defaultLogoutRedirectURI, cryptoKey)
 	if err != nil {
 		return nil, zerrors.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w")
 	}
-	storage := newStorage(config, command, query, repo, encryptionAlg, es, projections, ContextToIssuer)
+	storage := newStorage(config, command, query, repo, encryptionAlg, es, projections, ContextToIssuer, federatedLogoutCache)
 	keyCache := newPublicKeyCache(ctx, config.PublicKeyCacheMaxAge, queryKeyFunc(query))
 	accessTokenKeySet := newOidcKeySet(keyCache, withKeyExpiryCheck(true))
 	idTokenHintKeySet := newOidcKeySet(keyCache)
@@ -225,7 +229,17 @@ func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []
 	return opConfig, nil
 }
 
-func newStorage(config Config, command *command.Commands, query *query.Queries, repo repository.Repository, encAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, db *database.DB, contextToIssuer func(context.Context) string) *OPStorage {
+func newStorage(
+	config Config,
+	command *command.Commands,
+	query *query.Queries,
+	repo repository.Repository,
+	encAlg crypto.EncryptionAlgorithm,
+	es *eventstore.Eventstore,
+	db *database.DB,
+	contextToIssuer func(context.Context) string,
+	federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout],
+) *OPStorage {
 	return &OPStorage{
 		repo:                              repo,
 		command:                           command,
@@ -242,6 +256,7 @@ func newStorage(config Config, command *command.Commands, query *query.Queries,
 		locker:                            crdb.NewLocker(db.DB, locksTable, signingKey),
 		assetAPIPrefix:                    assets.AssetAPI(),
 		contextToIssuer:                   contextToIssuer,
+		federateLogoutCache:               federateLogoutCache,
 	}
 }
 
diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go
index d198978f1a0..5cedf185788 100644
--- a/internal/api/ui/login/external_provider_handler.go
+++ b/internal/api/ui/login/external_provider_handler.go
@@ -3,11 +3,13 @@ package login
 import (
 	"context"
 	"errors"
+	"html/template"
 	"net/http"
 	"net/url"
 	"slices"
 	"strings"
 
+	crewjam_saml "github.com/crewjam/saml"
 	"github.com/crewjam/saml/samlsp"
 	"github.com/zitadel/logging"
 	"github.com/zitadel/oidc/v3/pkg/client/rp"
@@ -20,6 +22,7 @@ import (
 	http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
 	"github.com/zitadel/zitadel/internal/crypto"
 	"github.com/zitadel/zitadel/internal/domain"
+	"github.com/zitadel/zitadel/internal/domain/federatedlogout"
 	"github.com/zitadel/zitadel/internal/eventstore/v1/models"
 	"github.com/zitadel/zitadel/internal/idp"
 	"github.com/zitadel/zitadel/internal/idp/providers/apple"
@@ -1418,6 +1421,124 @@ func (l *Login) getUserLinks(ctx context.Context, userID, idpID string) (*query.
 	)
 }
 
+type federatedLogoutData struct {
+	SessionID string `schema:"sessionID"`
+}
+
+const (
+	federatedLogoutDataSessionID = "sessionID"
+)
+
+func ExternalLogoutPath(sessionID string) string {
+	v := url.Values{}
+	v.Set(federatedLogoutDataSessionID, sessionID)
+	return HandlerPrefix + EndpointExternalLogout + "?" + v.Encode()
+}
+
+// handleExternalLogout is called when a user signed out of ZITADEL with a federated logout
+func (l *Login) handleExternalLogout(w http.ResponseWriter, r *http.Request) {
+	data := new(federatedLogoutData)
+	err := l.parser.Parse(r, data)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	logoutRequest, ok := l.caches.federatedLogouts.Get(r.Context(), federatedlogout.IndexRequestID, federatedlogout.Key(authz.GetInstance(r.Context()).InstanceID(), data.SessionID))
+	if !ok || logoutRequest.State != federatedlogout.StateCreated || logoutRequest.FingerPrintID != authz.GetCtxData(r.Context()).AgentID {
+		l.renderError(w, r, nil, zerrors.ThrowNotFound(nil, "LOGIN-ADK21", "Errors.ExternalIDP.LogoutRequestNotFound"))
+		return
+	}
+
+	provider, err := l.externalLogoutProvider(r, logoutRequest.IDPID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	nameID, err := l.externalUserID(r.Context(), logoutRequest.UserID, logoutRequest.IDPID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	err = samlLogoutRequest(w, r, provider, nameID, logoutRequest.SessionID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+	logoutRequest.State = federatedlogout.StateRedirected
+	l.caches.federatedLogouts.Set(r.Context(), logoutRequest)
+}
+
+func (l *Login) externalLogoutProvider(r *http.Request, providerID string) (*saml.Provider, error) {
+	identityProvider, err := l.getIDPByID(r, providerID)
+	if err != nil {
+		return nil, err
+	}
+	if identityProvider.Type != domain.IDPTypeSAML {
+		return nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-ADK21", "Errors.ExternalIDP.IDPTypeNotImplemented")
+	}
+	return l.samlProvider(r.Context(), identityProvider)
+}
+
+func samlLogoutRequest(w http.ResponseWriter, r *http.Request, provider *saml.Provider, nameID, sessionID string) error {
+	mw, err := provider.GetSP()
+	if err != nil {
+		return err
+	}
+	// We ignore the configured binding and only check the available SLO endpoints from the metadata.
+	// For example, Azure documents that only redirect binding is possible and also only provides a redirect SLO in the metadata.
+	slo := mw.ServiceProvider.GetSLOBindingLocation(crewjam_saml.HTTPRedirectBinding)
+	if slo != "" {
+		return samlRedirectLogoutRequest(w, r, mw.ServiceProvider, slo, nameID, sessionID)
+	}
+	slo = mw.ServiceProvider.GetSLOBindingLocation(crewjam_saml.HTTPPostBinding)
+	return samlPostLogoutRequest(w, mw.ServiceProvider, slo, nameID, sessionID)
+}
+
+func samlRedirectLogoutRequest(w http.ResponseWriter, r *http.Request, sp crewjam_saml.ServiceProvider, slo, nameID, sessionID string) error {
+	lr, err := sp.MakeLogoutRequest(slo, nameID)
+	if err != nil {
+		return err
+	}
+	http.Redirect(w, r, lr.Redirect(sessionID).String(), http.StatusFound)
+	return nil
+}
+
+var (
+	samlSLOPostTemplate = template.Must(template.New("samlSLOPost").Parse(`<!DOCTYPE html><html><body>{{.Form}}</body></html>`))
+)
+
+type samlSLOPostData struct {
+	Form template.HTML
+}
+
+func samlPostLogoutRequest(w http.ResponseWriter, sp crewjam_saml.ServiceProvider, slo, nameID, sessionID string) error {
+	lr, err := sp.MakeLogoutRequest(slo, nameID)
+	if err != nil {
+		return err
+	}
+
+	return samlSLOPostTemplate.Execute(w, &samlSLOPostData{Form: template.HTML(lr.Post(sessionID))})
+}
+
+func (l *Login) externalUserID(ctx context.Context, userID, idpID string) (string, error) {
+	userIDQuery, err := query.NewIDPUserLinksUserIDSearchQuery(userID)
+	if err != nil {
+		return "", err
+	}
+	idpIDQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID)
+	if err != nil {
+		return "", err
+	}
+	links, err := l.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: []query.SearchQuery{userIDQuery, idpIDQuery}}, nil)
+	if err != nil || len(links.Links) != 1 {
+		return "", zerrors.ThrowPreconditionFailed(err, "LOGIN-ADK21", "Errors.User.ExternalIDP.NotFound")
+	}
+	return links.Links[0].ProvidedUserID, nil
+}
+
 // IdPError wraps an error from an external IDP to be able to distinguish it from other errors and to display it
 // more prominent (popup style) .
 // It's used if an error occurs during the login process with an external IDP and local authentication is allowed,
diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go
index 444c5aaa851..f1ce9bfa2ab 100644
--- a/internal/api/ui/login/login.go
+++ b/internal/api/ui/login/login.go
@@ -21,6 +21,7 @@ import (
 	"github.com/zitadel/zitadel/internal/command"
 	"github.com/zitadel/zitadel/internal/crypto"
 	"github.com/zitadel/zitadel/internal/domain"
+	"github.com/zitadel/zitadel/internal/domain/federatedlogout"
 	"github.com/zitadel/zitadel/internal/form"
 	"github.com/zitadel/zitadel/internal/query"
 	"github.com/zitadel/zitadel/internal/static"
@@ -60,25 +61,20 @@ const (
 	DefaultLoggedOutPath = HandlerPrefix + EndpointLogoutDone
 )
 
-func CreateLogin(config Config,
+func CreateLogin(
+	config Config,
 	command *command.Commands,
 	query *query.Queries,
 	authRepo *eventsourcing.EsRepository,
 	staticStorage static.Storage,
 	consolePath string,
-	oidcAuthCallbackURL func(context.Context, string) string,
-	samlAuthCallbackURL func(context.Context, string) string,
+	oidcAuthCallbackURL, samlAuthCallbackURL func(context.Context, string) string,
 	externalSecure bool,
-	userAgentCookie,
-	issuerInterceptor,
-	oidcInstanceHandler,
-	samlInstanceHandler,
-	assetCache,
-	accessHandler mux.MiddlewareFunc,
-	userCodeAlg crypto.EncryptionAlgorithm,
-	idpConfigAlg crypto.EncryptionAlgorithm,
+	userAgentCookie, issuerInterceptor, oidcInstanceHandler, samlInstanceHandler, assetCache, accessHandler mux.MiddlewareFunc,
+	userCodeAlg, idpConfigAlg crypto.EncryptionAlgorithm,
 	csrfCookieKey []byte,
 	cacheConnectors connector.Connectors,
+	federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout],
 ) (*Login, error) {
 	login := &Login{
 		oidcAuthCallbackURL: oidcAuthCallbackURL,
@@ -101,7 +97,7 @@ func CreateLogin(config Config,
 	login.parser = form.NewParser()
 
 	var err error
-	login.caches, err = startCaches(context.Background(), cacheConnectors)
+	login.caches, err = startCaches(context.Background(), cacheConnectors, federateLogoutCache)
 	if err != nil {
 		return nil, err
 	}
@@ -112,7 +108,11 @@ func csp() *middleware.CSP {
 	csp := middleware.DefaultSCP
 	csp.ObjectSrc = middleware.CSPSourceOptsSelf()
 	csp.StyleSrc = csp.StyleSrc.AddNonce()
-	csp.ScriptSrc = csp.ScriptSrc.AddNonce().AddHash("sha256", "AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE=")
+	csp.ScriptSrc = csp.ScriptSrc.AddNonce().
+		// SAML POST ACS
+		AddHash("sha256", "AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE=").
+		// SAML POST SLO
+		AddHash("sha256", "4Su6mBWzEIFnH4pAGMOuaeBrstwJN4Z3pq/s1Kn4/KQ=")
 	return &csp
 }
 
@@ -215,14 +215,16 @@ func (l *Login) baseURL(ctx context.Context) string {
 
 type Caches struct {
 	idpFormCallbacks cache.Cache[idpFormCallbackIndex, string, *idpFormCallback]
+	federatedLogouts cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout]
 }
 
-func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) {
+func startCaches(background context.Context, connectors connector.Connectors, federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout]) (_ *Caches, err error) {
 	caches := new(Caches)
 	caches.idpFormCallbacks, err = connector.StartCache[idpFormCallbackIndex, string, *idpFormCallback](background, []idpFormCallbackIndex{idpFormCallbackIndexRequestID}, cache.PurposeIdPFormCallback, connectors.Config.IdPFormCallbacks, connectors)
 	if err != nil {
 		return nil, err
 	}
+	caches.federatedLogouts = federateLogoutCache
 	return caches, nil
 }
 
diff --git a/internal/api/ui/login/router.go b/internal/api/ui/login/router.go
index 6e346c9da01..459a0aab5de 100644
--- a/internal/api/ui/login/router.go
+++ b/internal/api/ui/login/router.go
@@ -14,6 +14,7 @@ const (
 	EndpointExternalLogin                 = "/login/externalidp"
 	EndpointExternalLoginCallback         = "/login/externalidp/callback"
 	EndpointExternalLoginCallbackFormPost = "/login/externalidp/callback/form"
+	EndpointExternalLogout                = "/logout/externalidp"
 	EndpointSAMLACS                       = "/login/externalidp/saml/acs"
 	EndpointJWTAuthorize                  = "/login/jwt/authorize"
 	EndpointJWTCallback                   = "/login/jwt/callback"
@@ -77,6 +78,7 @@ func CreateRouter(login *Login, interceptors ...mux.MiddlewareFunc) *mux.Router
 	router.HandleFunc(EndpointExternalLogin, login.handleExternalLogin).Methods(http.MethodGet)
 	router.HandleFunc(EndpointExternalLoginCallback, login.handleExternalLoginCallback).Methods(http.MethodGet)
 	router.HandleFunc(EndpointExternalLoginCallbackFormPost, login.handleExternalLoginCallbackForm).Methods(http.MethodPost)
+	router.HandleFunc(EndpointExternalLogout, login.handleExternalLogout).Methods(http.MethodGet)
 	router.HandleFunc(EndpointSAMLACS, login.handleExternalLoginCallback).Methods(http.MethodGet)
 	router.HandleFunc(EndpointSAMLACS, login.handleExternalLoginCallbackForm).Methods(http.MethodPost)
 	router.HandleFunc(EndpointJWTAuthorize, login.handleJWTRequest).Methods(http.MethodGet)
diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go
index 61895c263d1..6c5375a6b9c 100644
--- a/internal/auth/repository/eventsourcing/eventstore/user.go
+++ b/internal/auth/repository/eventsourcing/eventstore/user.go
@@ -13,6 +13,7 @@ import (
 	"github.com/zitadel/zitadel/internal/query"
 	"github.com/zitadel/zitadel/internal/repository/user"
 	usr_view "github.com/zitadel/zitadel/internal/user/repository/view"
+	"github.com/zitadel/zitadel/internal/user/repository/view/model"
 	"github.com/zitadel/zitadel/internal/zerrors"
 )
 
@@ -49,6 +50,10 @@ func (repo *UserRepo) UserAgentIDBySessionID(ctx context.Context, sessionID stri
 	return repo.View.UserAgentIDBySessionID(ctx, sessionID, authz.GetInstance(ctx).InstanceID())
 }
 
+func (repo *UserRepo) UserSessionByID(ctx context.Context, sessionID string) (*model.UserSessionView, error) {
+	return repo.View.UserSessionByID(ctx, sessionID, authz.GetInstance(ctx).InstanceID())
+}
+
 func (repo *UserRepo) ActiveUserSessionsBySessionID(ctx context.Context, sessionID string) (userAgentID string, signoutSessions []command.HumanSignOutSession, err error) {
 	userAgentID, sessions, err := repo.View.ActiveUserSessionsBySessionID(ctx, sessionID, authz.GetInstance(ctx).InstanceID())
 	if err != nil {
diff --git a/internal/auth/repository/eventsourcing/view/user_session.go b/internal/auth/repository/eventsourcing/view/user_session.go
index a4618e11fb3..777bc3213fe 100644
--- a/internal/auth/repository/eventsourcing/view/user_session.go
+++ b/internal/auth/repository/eventsourcing/view/user_session.go
@@ -16,6 +16,10 @@ func (v *View) UserSessionByIDs(ctx context.Context, agentID, userID, instanceID
 	return view.UserSessionByIDs(ctx, v.client, agentID, userID, instanceID)
 }
 
+func (v *View) UserSessionByID(ctx context.Context, userSessionID, instanceID string) (*model.UserSessionView, error) {
+	return view.UserSessionByID(ctx, v.client, userSessionID, instanceID)
+}
+
 func (v *View) UserSessionsByAgentID(ctx context.Context, agentID, instanceID string) ([]*model.UserSessionView, error) {
 	return view.UserSessionsByAgentID(ctx, v.client, agentID, instanceID)
 }
diff --git a/internal/auth/repository/user.go b/internal/auth/repository/user.go
index f09581b32ea..b51ca27f24f 100644
--- a/internal/auth/repository/user.go
+++ b/internal/auth/repository/user.go
@@ -4,10 +4,12 @@ import (
 	"context"
 
 	"github.com/zitadel/zitadel/internal/command"
+	"github.com/zitadel/zitadel/internal/user/repository/view/model"
 )
 
 type UserRepository interface {
 	UserSessionsByAgentID(ctx context.Context, agentID string) (sessions []command.HumanSignOutSession, err error)
 	UserAgentIDBySessionID(ctx context.Context, sessionID string) (string, error)
+	UserSessionByID(ctx context.Context, sessionID string) (*model.UserSessionView, error)
 	ActiveUserSessionsBySessionID(ctx context.Context, sessionID string) (userAgentID string, sessions []command.HumanSignOutSession, err error)
 }
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index dc05208caa5..233308a3cbf 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -18,6 +18,7 @@ const (
 	PurposeMilestones
 	PurposeOrganization
 	PurposeIdPFormCallback
+	PurposeFederatedLogout
 )
 
 // Cache stores objects with a value of type `V`.
diff --git a/internal/cache/connector/connector.go b/internal/cache/connector/connector.go
index 1a0534759ab..532e795a817 100644
--- a/internal/cache/connector/connector.go
+++ b/internal/cache/connector/connector.go
@@ -23,6 +23,7 @@ type CachesConfig struct {
 	Milestones       *cache.Config
 	Organization     *cache.Config
 	IdPFormCallbacks *cache.Config
+	FederatedLogouts *cache.Config
 }
 
 type Connectors struct {
diff --git a/internal/cache/purpose_enumer.go b/internal/cache/purpose_enumer.go
index a93a978efbc..f721435593c 100644
--- a/internal/cache/purpose_enumer.go
+++ b/internal/cache/purpose_enumer.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _PurposeName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback"
+const _PurposeName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callbackfederated_logout"
 
-var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47, 65}
+var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47, 65, 81}
 
-const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback"
+const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callbackfederated_logout"
 
 func (i Purpose) String() string {
 	if i < 0 || i >= Purpose(len(_PurposeIndex)-1) {
@@ -29,9 +29,10 @@ func _PurposeNoOp() {
 	_ = x[PurposeMilestones-(2)]
 	_ = x[PurposeOrganization-(3)]
 	_ = x[PurposeIdPFormCallback-(4)]
+	_ = x[PurposeFederatedLogout-(5)]
 }
 
-var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization, PurposeIdPFormCallback}
+var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization, PurposeIdPFormCallback, PurposeFederatedLogout}
 
 var _PurposeNameToValueMap = map[string]Purpose{
 	_PurposeName[0:11]:       PurposeUnspecified,
@@ -44,6 +45,8 @@ var _PurposeNameToValueMap = map[string]Purpose{
 	_PurposeLowerName[35:47]: PurposeOrganization,
 	_PurposeName[47:65]:      PurposeIdPFormCallback,
 	_PurposeLowerName[47:65]: PurposeIdPFormCallback,
+	_PurposeName[65:81]:      PurposeFederatedLogout,
+	_PurposeLowerName[65:81]: PurposeFederatedLogout,
 }
 
 var _PurposeNames = []string{
@@ -52,6 +55,7 @@ var _PurposeNames = []string{
 	_PurposeName[25:35],
 	_PurposeName[35:47],
 	_PurposeName[47:65],
+	_PurposeName[65:81],
 }
 
 // PurposeString retrieves an enum value from the enum constants string name.
diff --git a/internal/domain/federatedlogout/logout.go b/internal/domain/federatedlogout/logout.go
new file mode 100644
index 00000000000..ca208a129a1
--- /dev/null
+++ b/internal/domain/federatedlogout/logout.go
@@ -0,0 +1,37 @@
+package federatedlogout
+
+type Index int
+
+const (
+	IndexUnspecified Index = iota
+	IndexRequestID
+)
+
+type FederatedLogout struct {
+	InstanceID            string
+	FingerPrintID         string
+	SessionID             string
+	IDPID                 string
+	UserID                string
+	PostLogoutRedirectURI string
+	State                 State
+}
+
+// Keys implements cache.Entry
+func (c *FederatedLogout) Keys(i Index) []string {
+	if i == IndexRequestID {
+		return []string{Key(c.InstanceID, c.SessionID)}
+	}
+	return nil
+}
+
+func Key(instanceID, sessionID string) string {
+	return instanceID + "-" + sessionID
+}
+
+type State int
+
+const (
+	StateCreated State = iota
+	StateRedirected
+)
diff --git a/internal/user/repository/view/user_session.sql b/internal/user/repository/view/user_session.sql
new file mode 100644
index 00000000000..3e2a97206b7
--- /dev/null
+++ b/internal/user/repository/view/user_session.sql
@@ -0,0 +1,29 @@
+SELECT s.creation_date,
+       s.change_date,
+       s.resource_owner,
+       s.state,
+       s.user_agent_id,
+       s.user_id,
+       u.username,
+       l.login_name,
+       h.display_name,
+       h.avatar_key,
+       s.selected_idp_config_id,
+       s.password_verification,
+       s.passwordless_verification,
+       s.external_login_verification,
+       s.second_factor_verification,
+       s.second_factor_verification_type,
+       s.multi_factor_verification,
+       s.multi_factor_verification_type,
+       s.sequence,
+       s.instance_id,
+       s.id
+FROM auth.user_sessions s
+         LEFT JOIN projections.users14 u ON s.user_id = u.id AND s.instance_id = u.instance_id
+         LEFT JOIN projections.users14_humans h ON s.user_id = h.user_id AND s.instance_id = h.instance_id
+         LEFT JOIN projections.login_names3 l ON s.user_id = l.user_id AND s.instance_id = l.instance_id AND l.is_primary = true
+WHERE (s.id = $1)
+  AND (s.instance_id = $2)
+LIMIT 1
+;
\ No newline at end of file
diff --git a/internal/user/repository/view/user_session_view.go b/internal/user/repository/view/user_session_view.go
index b3d155f1ec8..dec22a181d3 100644
--- a/internal/user/repository/view/user_session_view.go
+++ b/internal/user/repository/view/user_session_view.go
@@ -12,6 +12,9 @@ import (
 )
 
 //go:embed user_session_by_id.sql
+var userSessionByIDsQuery string
+
+//go:embed user_session.sql
 var userSessionByIDQuery string
 
 //go:embed user_sessions_by_user_agent.sql
@@ -30,7 +33,7 @@ func UserSessionByIDs(ctx context.Context, db *database.DB, agentID, userID, ins
 			userSession, err = scanUserSession(row)
 			return err
 		},
-		userSessionByIDQuery,
+		userSessionByIDsQuery,
 		agentID,
 		userID,
 		instanceID,
@@ -38,6 +41,20 @@ func UserSessionByIDs(ctx context.Context, db *database.DB, agentID, userID, ins
 	return userSession, err
 }
 
+func UserSessionByID(ctx context.Context, db *database.DB, userSessionID, instanceID string) (userSession *model.UserSessionView, err error) {
+	err = db.QueryRowContext(
+		ctx,
+		func(row *sql.Row) error {
+			userSession, err = scanUserSession(row)
+			return err
+		},
+		userSessionByIDQuery,
+		userSessionID,
+		instanceID,
+	)
+	return userSession, err
+}
+
 func UserSessionsByAgentID(ctx context.Context, db *database.DB, agentID, instanceID string) (userSessions []*model.UserSessionView, err error) {
 	err = db.QueryContext(
 		ctx,


Reviews:
user: login: stebenz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T14:34:30Z
user: login: livio-a
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T06:58:06Z
user: login: livio-a
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T06:58:09Z
user: login: livio-a
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T06:59:39Z
user: login: livio-a
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T07:02:05Z
user: login: stebenz
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T17:08:42Z

Review comments:
diff_hunk: @@ -317,6 +335,39 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
 }
 
+// federatedLogout checks whether the session has an idp session linked and the IDP template is configured for federated logout.
+// If so, it creates a federated logout request and stores it in the cache and returns the logout path.
+func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postLogoutRedirectURI string) string {
+	session, err := o.repo.UserSessionByID(ctx, sessionID)
+	if err != nil {
+		logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "sessionID", sessionID).
+			WithError(err).Error("error retrieving user session")
+		return ""
+	}
+	if session.SelectedIDPConfigID.String == "" {
+		return ""
+	}
+	identityProvider, err := o.query.IDPTemplateByID(ctx, false, session.SelectedIDPConfigID.String, false, nil)
+	if err != nil {
+		logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "idpID", session.SelectedIDPConfigID.String, "sessionID", sessionID).
+			WithError(err).Error("error retrieving idp template")
+		return ""
+	}
+	if identityProvider.SAMLIDPTemplate == nil || !identityProvider.FederatedLogoutEnabled {
path: internal/api/oidc/auth_request.go
user: login: stebenz
body: Is this correct? Shouldn't it be the other way around that you ignore all idp types which have no federated logout?
created_at: 2025-05-19T14:14:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1418,6 +1421,124 @@ func (l *Login) getUserLinks(ctx context.Context, userID, idpID string) (*query.
 	)
 }
 
+type federatedLogoutData struct {
+	SessionID string `schema:"sessionID"`
+}
+
+const (
+	federatedLogoutDataSessionID = "sessionID"
+)
+
+func ExternalLogoutPath(sessionID string) string {
+	v := url.Values{}
+	v.Set(federatedLogoutDataSessionID, sessionID)
+	return HandlerPrefix + EndpointExternalLogout + "?" + v.Encode()
+}
+
+// handleExternalLogout is called when a user signed out of ZITADEL with a federated logout
+func (l *Login) handleExternalLogout(w http.ResponseWriter, r *http.Request) {
+	data := new(federatedLogoutData)
+	err := l.parser.Parse(r, data)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	logoutRequest, ok := l.caches.federatedLogouts.Get(r.Context(), federatedlogout.IndexRequestID, federatedlogout.Key(authz.GetInstance(r.Context()).InstanceID(), data.SessionID))
+	if !ok || logoutRequest.State != federatedlogout.StateCreated || logoutRequest.FingerPrintID != authz.GetCtxData(r.Context()).AgentID {
+		l.renderError(w, r, nil, zerrors.ThrowNotFound(nil, "LOGIN-ADK21", "Errors.ExternalIDP.LogoutRequestNotFound"))
+		return
+	}
+
+	provider, err := l.externalLogoutProvider(r, logoutRequest.IDPID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	nameID, err := l.externalUserID(r.Context(), logoutRequest.UserID, logoutRequest.IDPID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	err = samlLogoutRequest(w, r, provider, nameID, logoutRequest.SessionID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+	logoutRequest.State = federatedlogout.StateRedirected
+	l.caches.federatedLogouts.Set(r.Context(), logoutRequest)
+}
+
+func (l *Login) externalLogoutProvider(r *http.Request, providerID string) (*saml.Provider, error) {
+	identityProvider, err := l.getIDPByID(r, providerID)
+	if err != nil {
+		return nil, err
+	}
+	if identityProvider.Type != domain.IDPTypeSAML {
+		return nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-ADK21", "Errors.ExternalIDP.IDPTypeNotImplemented")
+	}
+	return l.samlProvider(r.Context(), identityProvider)
+}
+
+func samlLogoutRequest(w http.ResponseWriter, r *http.Request, provider *saml.Provider, nameID, sessionID string) error {
+	mw, err := provider.GetSP()
+	if err != nil {
+		return err
+	}
+	// We ignore the configured binding and only check the available SLO endpoints from the metadata.
+	// For example, Azure documents that only redirect binding is possible and also only provides a redirect SLO in the metadata.
+	slo := mw.ServiceProvider.GetSLOBindingLocation(crewjam_saml.HTTPRedirectBinding)
+	if slo != "" {
+		return samlRedirectLogoutRequest(w, r, mw.ServiceProvider, slo, nameID, sessionID)
+	}
+	slo = mw.ServiceProvider.GetSLOBindingLocation(crewjam_saml.HTTPPostBinding)
+	return samlPostLogoutRequest(w, mw.ServiceProvider, slo, nameID, sessionID)
+}
+
+func samlRedirectLogoutRequest(w http.ResponseWriter, r *http.Request, sp crewjam_saml.ServiceProvider, slo, nameID, sessionID string) error {
+	lr, err := sp.MakeLogoutRequest(slo, nameID)
+	if err != nil {
+		return err
+	}
+	http.Redirect(w, r, lr.Redirect(sessionID).String(), http.StatusFound)
+	return nil
+}
+
+var (
+	samlSLOPostTemplate = template.Must(template.New("samlSLOPost").Parse(`<!DOCTYPE html><html><body>{{.Form}}</body></html>`))
path: internal/api/ui/login/external_provider_handler.go
user: login: stebenz
body: As we already have a basic form post template for the other bindings, should we not try to reuse them?
created_at: 2025-05-19T14:23:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -317,6 +335,39 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
 }
 
+// federatedLogout checks whether the session has an idp session linked and the IDP template is configured for federated logout.
+// If so, it creates a federated logout request and stores it in the cache and returns the logout path.
+func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postLogoutRedirectURI string) string {
+	session, err := o.repo.UserSessionByID(ctx, sessionID)
path: internal/api/oidc/auth_request.go
user: login: stebenz
body: Should the federated logout also be possible on inactive session? 
If not then we can use the existing ActiveUserSessionsBySessionID.
created_at: 2025-05-19T14:27:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -294,7 +300,16 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	// So if any condition is not met, we handle the request as a V1 request and do a (v1) TerminateSession,
 	// which terminates all sessions of the user agent, identified by cookie.
 	if endSessionRequest.IDTokenHintClaims == nil || endSessionRequest.IDTokenHintClaims.SessionID == "" {
-		return endSessionRequest.RedirectURI, o.TerminateSession(ctx, endSessionRequest.UserID, endSessionRequest.ClientID)
+		sessions, err := o.terminateSession(ctx, endSessionRequest.UserID)
+		if err != nil {
+			return "", err
+		}
+		if len(sessions) == 1 {
path: internal/api/oidc/auth_request.go
user: login: stebenz
body: Does this mean if you have multiple sessions from the same user federated logout is not working?
created_at: 2025-05-19T14:33:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -294,7 +300,16 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	// So if any condition is not met, we handle the request as a V1 request and do a (v1) TerminateSession,
 	// which terminates all sessions of the user agent, identified by cookie.
 	if endSessionRequest.IDTokenHintClaims == nil || endSessionRequest.IDTokenHintClaims.SessionID == "" {
-		return endSessionRequest.RedirectURI, o.TerminateSession(ctx, endSessionRequest.UserID, endSessionRequest.ClientID)
+		sessions, err := o.terminateSession(ctx, endSessionRequest.UserID)
+		if err != nil {
+			return "", err
+		}
+		if len(sessions) == 1 {
path: internal/api/oidc/auth_request.go
user: login: livio-a
body: yes it's currently restricted to a single user session, since we couldn't handle multiple redirects properly, resp. there's way to many possible errors
created_at: 2025-05-20T06:58:06Z
author_association: MEMBER
diff_hunk: @@ -317,6 +335,39 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
 }
 
+// federatedLogout checks whether the session has an idp session linked and the IDP template is configured for federated logout.
+// If so, it creates a federated logout request and stores it in the cache and returns the logout path.
+func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postLogoutRedirectURI string) string {
+	session, err := o.repo.UserSessionByID(ctx, sessionID)
+	if err != nil {
+		logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "sessionID", sessionID).
+			WithError(err).Error("error retrieving user session")
+		return ""
+	}
+	if session.SelectedIDPConfigID.String == "" {
+		return ""
+	}
+	identityProvider, err := o.query.IDPTemplateByID(ctx, false, session.SelectedIDPConfigID.String, false, nil)
+	if err != nil {
+		logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "idpID", session.SelectedIDPConfigID.String, "sessionID", sessionID).
+			WithError(err).Error("error retrieving idp template")
+		return ""
+	}
+	if identityProvider.SAMLIDPTemplate == nil || !identityProvider.FederatedLogoutEnabled {
path: internal/api/oidc/auth_request.go
user: login: livio-a
body: But that's exactly what i do. If the idp type is something else than SAML if will automatically return without the federated logout path
created_at: 2025-05-20T06:58:09Z
author_association: MEMBER
diff_hunk: @@ -1418,6 +1421,124 @@ func (l *Login) getUserLinks(ctx context.Context, userID, idpID string) (*query.
 	)
 }
 
+type federatedLogoutData struct {
+	SessionID string `schema:"sessionID"`
+}
+
+const (
+	federatedLogoutDataSessionID = "sessionID"
+)
+
+func ExternalLogoutPath(sessionID string) string {
+	v := url.Values{}
+	v.Set(federatedLogoutDataSessionID, sessionID)
+	return HandlerPrefix + EndpointExternalLogout + "?" + v.Encode()
+}
+
+// handleExternalLogout is called when a user signed out of ZITADEL with a federated logout
+func (l *Login) handleExternalLogout(w http.ResponseWriter, r *http.Request) {
+	data := new(federatedLogoutData)
+	err := l.parser.Parse(r, data)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	logoutRequest, ok := l.caches.federatedLogouts.Get(r.Context(), federatedlogout.IndexRequestID, federatedlogout.Key(authz.GetInstance(r.Context()).InstanceID(), data.SessionID))
+	if !ok || logoutRequest.State != federatedlogout.StateCreated || logoutRequest.FingerPrintID != authz.GetCtxData(r.Context()).AgentID {
+		l.renderError(w, r, nil, zerrors.ThrowNotFound(nil, "LOGIN-ADK21", "Errors.ExternalIDP.LogoutRequestNotFound"))
+		return
+	}
+
+	provider, err := l.externalLogoutProvider(r, logoutRequest.IDPID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	nameID, err := l.externalUserID(r.Context(), logoutRequest.UserID, logoutRequest.IDPID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+
+	err = samlLogoutRequest(w, r, provider, nameID, logoutRequest.SessionID)
+	if err != nil {
+		l.renderError(w, r, nil, err)
+		return
+	}
+	logoutRequest.State = federatedlogout.StateRedirected
+	l.caches.federatedLogouts.Set(r.Context(), logoutRequest)
+}
+
+func (l *Login) externalLogoutProvider(r *http.Request, providerID string) (*saml.Provider, error) {
+	identityProvider, err := l.getIDPByID(r, providerID)
+	if err != nil {
+		return nil, err
+	}
+	if identityProvider.Type != domain.IDPTypeSAML {
+		return nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-ADK21", "Errors.ExternalIDP.IDPTypeNotImplemented")
+	}
+	return l.samlProvider(r.Context(), identityProvider)
+}
+
+func samlLogoutRequest(w http.ResponseWriter, r *http.Request, provider *saml.Provider, nameID, sessionID string) error {
+	mw, err := provider.GetSP()
+	if err != nil {
+		return err
+	}
+	// We ignore the configured binding and only check the available SLO endpoints from the metadata.
+	// For example, Azure documents that only redirect binding is possible and also only provides a redirect SLO in the metadata.
+	slo := mw.ServiceProvider.GetSLOBindingLocation(crewjam_saml.HTTPRedirectBinding)
+	if slo != "" {
+		return samlRedirectLogoutRequest(w, r, mw.ServiceProvider, slo, nameID, sessionID)
+	}
+	slo = mw.ServiceProvider.GetSLOBindingLocation(crewjam_saml.HTTPPostBinding)
+	return samlPostLogoutRequest(w, mw.ServiceProvider, slo, nameID, sessionID)
+}
+
+func samlRedirectLogoutRequest(w http.ResponseWriter, r *http.Request, sp crewjam_saml.ServiceProvider, slo, nameID, sessionID string) error {
+	lr, err := sp.MakeLogoutRequest(slo, nameID)
+	if err != nil {
+		return err
+	}
+	http.Redirect(w, r, lr.Redirect(sessionID).String(), http.StatusFound)
+	return nil
+}
+
+var (
+	samlSLOPostTemplate = template.Must(template.New("samlSLOPost").Parse(`<!DOCTYPE html><html><body>{{.Form}}</body></html>`))
path: internal/api/ui/login/external_provider_handler.go
user: login: livio-a
body: If you can point me there, sure. From what I saw the post login binding already includes the whole HTML page and not just the form.
created_at: 2025-05-20T06:59:39Z
author_association: MEMBER
diff_hunk: @@ -317,6 +335,39 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
 	return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
 }
 
+// federatedLogout checks whether the session has an idp session linked and the IDP template is configured for federated logout.
+// If so, it creates a federated logout request and stores it in the cache and returns the logout path.
+func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postLogoutRedirectURI string) string {
+	session, err := o.repo.UserSessionByID(ctx, sessionID)
path: internal/api/oidc/auth_request.go
user: login: livio-a
body: Yes it would only make sense for active sessions i guess. I can also extend the information returned in that function if needed.
created_at: 2025-05-20T07:02:05Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-05-06T11:50:10Z
body: [vc]: #oLT2YWX/4h6CYVTEbwJfkYmzS2i3yLyH8EHMpLs/UVk=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJkb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3ppdGFkZWwvZG9jcy82NGtqdDVrbmpMWjNjWTVkU0RGTUFBZFhWWTNpIiwicHJldmlld1VybCI6ImRvY3MtZ2l0LWZlZGVyYXRlZC1sb2dvdXQtaW1wbC16aXRhZGVsLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiZG9jcy1naXQtZmVkZXJhdGVkLWxvZ291dC1pbXBsLXppdGFkZWwudmVyY2VsLmFwcCJ9LCJyb290RGlyZWN0b3J5IjoiZG9jcyJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **docs** | ✅ Ready ([Inspect](https://vercel.com/zitadel/docs/64kjt5knjLZ3cY5dSDFMAAdXVY3i)) | [Visit Preview](https://docs-git-federated-logout-impl-zitadel.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/docs-git-federated-logout-impl-zitadel.vercel.app?via=pr-comment-feedback-link) | May 6, 2025 2:37pm |


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-06T11:50:19Z
body: ### Thanks for your contribution @livio-a! 🎉

Please make sure you tick the following checkboxes before marking this Pull Request (PR) as ready for review:

  - [ ] I am happy with the code
  - [ ] Documentations and examples are up-to-date
  - [ ] Logical behavior changes are tested automatically
  - [ ] No debug or dead code
  - [ ] My code has no repetitions
  - [ ] The PR title adheres to the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/)
  - [ ] The example texts in the PR description are replaced.
  - [ ] If there are any open TODOs or follow-ups, they are described in issues and link to this PR
  - [ ] If there are deviations from a user stories acceptance criteria or design, they are agreed upon with the PO and documented.
  
author_association: NONE
user: login: codecov[bot]
created_at: 2025-05-06T12:43:48Z
body: ## [Codecov](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) Report
Attention: Patch coverage is `0%` with `186 lines` in your changes missing coverage. Please review.
> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`federated-logout@51769f8`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#section-missing-base-commit) about missing BASE report.

| [Files with missing lines](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) | Patch % | Lines |
|---|---|---|
| [internal/api/ui/login/external\_provider\_handler.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fapi%2Fui%2Flogin%2Fexternal_provider_handler.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXBpL3VpL2xvZ2luL2V4dGVybmFsX3Byb3ZpZGVyX2hhbmRsZXIuZ28=) | 0.00% | [80 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/api/oidc/auth\_request.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fapi%2Foidc%2Fauth_request.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXBpL29pZGMvYXV0aF9yZXF1ZXN0Lmdv) | 0.00% | [49 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/api/idp/idp.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fapi%2Fidp%2Fidp.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXBpL2lkcC9pZHAuZ28=) | 0.00% | [25 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/api/ui/login/login.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fapi%2Fui%2Flogin%2Flogin.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXBpL3VpL2xvZ2luL2xvZ2luLmdv) | 0.00% | [8 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/user/repository/view/user\_session\_view.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fuser%2Frepository%2Fview%2Fuser_session_view.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvdXNlci9yZXBvc2l0b3J5L3ZpZXcvdXNlcl9zZXNzaW9uX3ZpZXcuZ28=) | 0.00% | [8 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/domain/federatedlogout/logout.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fdomain%2Ffederatedlogout%2Flogout.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvZG9tYWluL2ZlZGVyYXRlZGxvZ291dC9sb2dvdXQuZ28=) | 0.00% | [7 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/api/oidc/op.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fapi%2Foidc%2Fop.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXBpL29pZGMvb3AuZ28=) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [...l/auth/repository/eventsourcing/eventstore/user.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fauth%2Frepository%2Feventsourcing%2Feventstore%2Fuser.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXV0aC9yZXBvc2l0b3J5L2V2ZW50c291cmNpbmcvZXZlbnRzdG9yZS91c2VyLmdv) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [...auth/repository/eventsourcing/view/user\_session.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fauth%2Frepository%2Feventsourcing%2Fview%2Fuser_session.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXV0aC9yZXBvc2l0b3J5L2V2ZW50c291cmNpbmcvdmlldy91c2VyX3Nlc3Npb24uZ28=) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| [internal/api/ui/login/router.go](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&filepath=internal%2Fapi%2Fui%2Flogin%2Frouter.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-aW50ZXJuYWwvYXBpL3VpL2xvZ2luL3JvdXRlci5nbw==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) |
| ... and [1 more](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) | |

<details><summary>Additional details and impacted files</summary>


```diff
@@                 Coverage Diff                 @@
##             federated-logout    #9854   +/-   ##
===================================================
  Coverage                    ?   46.20%           
===================================================
  Files                       ?     1552           
  Lines                       ?   149706           
  Branches                    ?        0           
===================================================
  Hits                        ?    69165           
  Misses                      ?    77225           
  Partials                    ?     3316           
```

| [Flag](https://app.codecov.io/gh/zitadel/zitadel/pull/9854/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) | Coverage Δ | |
|---|---|---|
| [core-unit-tests](https://app.codecov.io/gh/zitadel/zitadel/pull/9854/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) | `46.20% <0.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zitadel/zitadel/pull/9854?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE