diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 442a5ab324..789e01f627 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/setup/56.go b/cmd/setup/56.go new file mode 100644 index 0000000000..72ccb5e6ff --- /dev/null +++ b/cmd/setup/56.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 56.sql + addSAMLFederatedLogout string +) + +type IDPTemplate6SAMLFederatedLogout struct { + dbClient *database.DB +} + +func (mig *IDPTemplate6SAMLFederatedLogout) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addSAMLFederatedLogout) + return err +} + +func (mig *IDPTemplate6SAMLFederatedLogout) String() string { + return "56_idp_templates6_add_saml_federated_logout" +} diff --git a/cmd/setup/56.sql b/cmd/setup/56.sql new file mode 100644 index 0000000000..f5544eddf5 --- /dev/null +++ b/cmd/setup/56.sql @@ -0,0 +1 @@ +ALTER TABLE IF EXISTS projections.idp_templates6_saml ADD COLUMN IF NOT EXISTS federated_logout_enabled BOOLEAN DEFAULT FALSE; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 5e5c842b14..bd2abde9ea 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -152,6 +152,7 @@ type Steps struct { s53InitPermittedOrgsFunction *InitPermittedOrgsFunction53 s54InstancePositionIndex *InstancePositionIndex s55ExecutionHandlerStart *ExecutionHandlerStart + s56IDPTemplate6SAMLFederatedLogout *IDPTemplate6SAMLFederatedLogout } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 58bc89d2e4..c84976f282 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -214,6 +214,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s53InitPermittedOrgsFunction = &InitPermittedOrgsFunction53{dbClient: dbClient} steps.s54InstancePositionIndex = &InstancePositionIndex{dbClient: dbClient} steps.s55ExecutionHandlerStart = &ExecutionHandlerStart{dbClient: dbClient} + steps.s56IDPTemplate6SAMLFederatedLogout = &IDPTemplate6SAMLFederatedLogout{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -258,6 +259,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s53InitPermittedOrgsFunction, steps.s54InstancePositionIndex, steps.s55ExecutionHandlerStart, + steps.s56IDPTemplate6SAMLFederatedLogout, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/start/start.go b/cmd/start/start.go index 52d9c6fba8..8a16a5e3be 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/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html b/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html index e326d3be26..c7bb48ea71 100644 --- a/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html +++ b/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html @@ -98,13 +98,12 @@

{{ 'IDP.ISIDTOKENMAPPING_DESC' | translate }}

{{ 'IDP.ISIDTOKENMAPPING' | translate }} - - - -
-

{{ 'IDP.USEPKCE_DESC' | translate }}

- {{ 'IDP.USEPKCE' | translate }} -
+ +
+

{{ 'IDP.USEPKCE_DESC' | translate }}

+ {{ 'IDP.USEPKCE' | translate }} +
+
diff --git a/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html b/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html index d738b3fec9..245a963fa8 100644 --- a/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html +++ b/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html @@ -82,7 +82,7 @@
-

{{ 'IDP.SAML.TRANSIENTMAPPINGATTRIBUTENAME_DESC' | translate }}

+

{{ 'IDP.SAML.TRANSIENTMAPPINGATTRIBUTENAME_DESC' | translate }}

@@ -90,6 +90,15 @@
+ + +
+

{{ 'IDP.FEDERATEDLOGOUTENABLED_DESC' | translate }}

+ {{ + 'IDP.FEDERATEDLOGOUTENABLED' | translate + }} +
+
{{.Form}}`)) +) + +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 4b028a347f..5fa97ddd56 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 6e346c9da0..459a0aab5d 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 61895c263d..6c5375a6b9 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 a4618e11fb..777bc3213f 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 f09581b32e..b51ca27f24 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 dc05208caa..233308a3cb 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 1a0534759a..532e795a81 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 a93a978efb..f721435593 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/command/idp.go b/internal/command/idp.go index 821a577900..06d8f473c0 100644 --- a/internal/command/idp.go +++ b/internal/command/idp.go @@ -122,6 +122,7 @@ type SAMLProvider struct { WithSignedRequest bool NameIDFormat *domain.SAMLNameIDFormat TransientMappingAttributeName string + FederatedLogoutEnabled bool IDPOptions idp.Options } diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 1be3971e87..6cf835f521 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -743,6 +743,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, rep_idp.Options{}, )), ), @@ -763,6 +764,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, rep_idp.Options{}, )), ), diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index 5257d38bf4..188d45e6ec 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -1760,6 +1760,7 @@ type SAMLIDPWriteModel struct { WithSignedRequest bool NameIDFormat *domain.SAMLNameIDFormat TransientMappingAttributeName string + FederatedLogoutEnabled bool idp.Options State domain.IDPState @@ -1788,6 +1789,7 @@ func (wm *SAMLIDPWriteModel) reduceAddedEvent(e *idp.SAMLIDPAddedEvent) { wm.WithSignedRequest = e.WithSignedRequest wm.NameIDFormat = e.NameIDFormat wm.TransientMappingAttributeName = e.TransientMappingAttributeName + wm.FederatedLogoutEnabled = e.FederatedLogoutEnabled wm.Options = e.Options wm.State = domain.IDPStateActive } @@ -1817,6 +1819,9 @@ func (wm *SAMLIDPWriteModel) reduceChangedEvent(e *idp.SAMLIDPChangedEvent) { if e.TransientMappingAttributeName != nil { wm.TransientMappingAttributeName = *e.TransientMappingAttributeName } + if e.FederatedLogoutEnabled != nil { + wm.FederatedLogoutEnabled = *e.FederatedLogoutEnabled + } wm.Options.ReduceChanges(e.OptionChanges) } @@ -1830,6 +1835,7 @@ func (wm *SAMLIDPWriteModel) NewChanges( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) ([]idp.SAMLIDPChanges, error) { changes := make([]idp.SAMLIDPChanges, 0) @@ -1861,6 +1867,9 @@ func (wm *SAMLIDPWriteModel) NewChanges( if wm.TransientMappingAttributeName != transientMappingAttributeName { changes = append(changes, idp.ChangeSAMLTransientMappingAttributeName(transientMappingAttributeName)) } + if wm.FederatedLogoutEnabled != federatedLogoutEnabled { + changes = append(changes, idp.ChangeSAMLFederatedLogoutEnabled(federatedLogoutEnabled)) + } opts := wm.Options.Changes(options) if !opts.IsZero() { changes = append(changes, idp.ChangeSAMLOptions(opts)) @@ -1899,6 +1908,7 @@ func (wm *SAMLIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encryp if wm.TransientMappingAttributeName != "" { opts = append(opts, saml2.WithTransientMappingAttributeName(wm.TransientMappingAttributeName)) } + // TODO: ? if wm.FederatedLogoutEnabled opts = append(opts, saml2.WithCustomRequestTracker( requesttracker.New( addRequest, diff --git a/internal/command/instance_idp.go b/internal/command/instance_idp.go index 348f55cd9c..90efb3edd4 100644 --- a/internal/command/instance_idp.go +++ b/internal/command/instance_idp.go @@ -1795,6 +1795,7 @@ func (c *Commands) prepareAddInstanceSAMLProvider(a *instance.Aggregate, writeMo provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ), }, nil @@ -1848,6 +1849,7 @@ func (c *Commands) prepareUpdateInstanceSAMLProvider(a *instance.Aggregate, writ provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ) if err != nil || event == nil { @@ -1893,6 +1895,7 @@ func (c *Commands) prepareRegenerateInstanceSAMLProviderCertificate(a *instance. writeModel.WithSignedRequest, writeModel.NameIDFormat, writeModel.TransientMappingAttributeName, + writeModel.FederatedLogoutEnabled, writeModel.Options, ) if err != nil || event == nil { diff --git a/internal/command/instance_idp_model.go b/internal/command/instance_idp_model.go index d94c19d318..03d9cd9c36 100644 --- a/internal/command/instance_idp_model.go +++ b/internal/command/instance_idp_model.go @@ -923,6 +923,7 @@ func (wm *InstanceSAMLIDPWriteModel) NewChangedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) (*instance.SAMLIDPChangedEvent, error) { changes, err := wm.SAMLIDPWriteModel.NewChanges( @@ -935,6 +936,7 @@ func (wm *InstanceSAMLIDPWriteModel) NewChangedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ) if err != nil || len(changes) == 0 { diff --git a/internal/command/instance_idp_test.go b/internal/command/instance_idp_test.go index 7002598af5..4805d075dd 100644 --- a/internal/command/instance_idp_test.go +++ b/internal/command/instance_idp_test.go @@ -5437,6 +5437,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, ), ), @@ -5478,6 +5479,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { true, gu.Ptr(domain.SAMLNameIDFormatTransient), "customAttribute", + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5500,6 +5502,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5665,6 +5668,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, )), ), @@ -5703,6 +5707,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), @@ -5718,6 +5723,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { idp.ChangeSAMLWithSignedRequest(true), idp.ChangeSAMLNameIDFormat(gu.Ptr(domain.SAMLNameIDFormatTransient)), idp.ChangeSAMLTransientMappingAttributeName("customAttribute"), + idp.ChangeSAMLFederatedLogoutEnabled(true), idp.ChangeSAMLOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -5742,6 +5748,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5845,6 +5852,7 @@ func TestCommandSide_RegenerateInstanceSAMLProviderCertificate(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), diff --git a/internal/command/org_idp.go b/internal/command/org_idp.go index b72fc1fd77..6cae78acc7 100644 --- a/internal/command/org_idp.go +++ b/internal/command/org_idp.go @@ -1768,6 +1768,7 @@ func (c *Commands) prepareAddOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSA provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ), }, nil @@ -1821,6 +1822,7 @@ func (c *Commands) prepareUpdateOrgSAMLProvider(a *org.Aggregate, writeModel *Or provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ) if err != nil || event == nil { @@ -1866,6 +1868,7 @@ func (c *Commands) prepareRegenerateOrgSAMLProviderCertificate(a *org.Aggregate, writeModel.WithSignedRequest, writeModel.NameIDFormat, writeModel.TransientMappingAttributeName, + writeModel.FederatedLogoutEnabled, writeModel.Options, ) if err != nil || event == nil { diff --git a/internal/command/org_idp_model.go b/internal/command/org_idp_model.go index 3baea11495..fdafb7f087 100644 --- a/internal/command/org_idp_model.go +++ b/internal/command/org_idp_model.go @@ -935,6 +935,7 @@ func (wm *OrgSAMLIDPWriteModel) NewChangedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) (*org.SAMLIDPChangedEvent, error) { changes, err := wm.SAMLIDPWriteModel.NewChanges( @@ -947,6 +948,7 @@ func (wm *OrgSAMLIDPWriteModel) NewChangedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ) if err != nil || len(changes) == 0 { diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index 9959ced97d..54f508da30 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -5519,6 +5519,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, ), ), @@ -5560,6 +5561,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { true, gu.Ptr(domain.SAMLNameIDFormatTransient), "customAttribute", + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5583,6 +5585,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5756,6 +5759,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, )), ), @@ -5795,6 +5799,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), @@ -5810,6 +5815,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { idp.ChangeSAMLWithSignedRequest(true), idp.ChangeSAMLNameIDFormat(gu.Ptr(domain.SAMLNameIDFormatTransient)), idp.ChangeSAMLTransientMappingAttributeName("customAttribute"), + idp.ChangeSAMLFederatedLogoutEnabled(true), idp.ChangeSAMLOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -5835,6 +5841,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5943,6 +5950,7 @@ func TestCommandSide_RegenerateOrgSAMLProviderCertificate(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), diff --git a/internal/domain/federatedlogout/logout.go b/internal/domain/federatedlogout/logout.go new file mode 100644 index 0000000000..ca208a129a --- /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/query/idp_template.go b/internal/query/idp_template.go index f51e9a11a7..c1c47e73bc 100644 --- a/internal/query/idp_template.go +++ b/internal/query/idp_template.go @@ -165,6 +165,7 @@ type SAMLIDPTemplate struct { WithSignedRequest bool NameIDFormat sql.Null[domain.SAMLNameIDFormat] TransientMappingAttributeName string + FederatedLogoutEnabled bool } var ( @@ -724,6 +725,10 @@ var ( name: projection.SAMLTransientMappingAttributeName, table: samlIdpTemplateTable, } + SAMLFederatedLogoutEnabledCol = Column{ + name: projection.SAMLFederatedLogoutEnabled, + table: samlIdpTemplateTable, + } ) // IDPTemplateByID searches for the requested id with permission check if necessary @@ -948,6 +953,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla SAMLWithSignedRequestCol.identifier(), SAMLNameIDFormatCol.identifier(), SAMLTransientMappingAttributeNameCol.identifier(), + SAMLFederatedLogoutEnabledCol.identifier(), // ldap LDAPIDCol.identifier(), LDAPServersCol.identifier(), @@ -1067,6 +1073,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla samlWithSignedRequest := sql.NullBool{} samlNameIDFormat := sql.Null[domain.SAMLNameIDFormat]{} samlTransientMappingAttributeName := sql.NullString{} + samlFederatedLogoutEnabled := sql.NullBool{} ldapID := sql.NullString{} ldapServers := database.TextArray[string]{} @@ -1184,6 +1191,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla &samlWithSignedRequest, &samlNameIDFormat, &samlTransientMappingAttributeName, + &samlFederatedLogoutEnabled, // ldap &ldapID, &ldapServers, @@ -1323,6 +1331,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla WithSignedRequest: samlWithSignedRequest.Bool, NameIDFormat: samlNameIDFormat, TransientMappingAttributeName: samlTransientMappingAttributeName.String, + FederatedLogoutEnabled: samlFederatedLogoutEnabled.Bool, } } if ldapID.Valid { @@ -1456,6 +1465,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate SAMLWithSignedRequestCol.identifier(), SAMLNameIDFormatCol.identifier(), SAMLTransientMappingAttributeNameCol.identifier(), + SAMLFederatedLogoutEnabledCol.identifier(), // ldap LDAPIDCol.identifier(), LDAPServersCol.identifier(), @@ -1580,6 +1590,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate samlWithSignedRequest := sql.NullBool{} samlNameIDFormat := sql.Null[domain.SAMLNameIDFormat]{} samlTransientMappingAttributeName := sql.NullString{} + samlFederatedLogoutEnabled := sql.NullBool{} ldapID := sql.NullString{} ldapServers := database.TextArray[string]{} @@ -1697,6 +1708,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate &samlWithSignedRequest, &samlNameIDFormat, &samlTransientMappingAttributeName, + &samlFederatedLogoutEnabled, // ldap &ldapID, &ldapServers, @@ -1835,6 +1847,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate WithSignedRequest: samlWithSignedRequest.Bool, NameIDFormat: samlNameIDFormat, TransientMappingAttributeName: samlTransientMappingAttributeName.String, + FederatedLogoutEnabled: samlFederatedLogoutEnabled.Bool, } } if ldapID.Valid { diff --git a/internal/query/idp_template_test.go b/internal/query/idp_template_test.go index 702e5d0ced..aed243c61e 100644 --- a/internal/query/idp_template_test.go +++ b/internal/query/idp_template_test.go @@ -99,6 +99,7 @@ var ( ` projections.idp_templates6_saml.with_signed_request,` + ` projections.idp_templates6_saml.name_id_format,` + ` projections.idp_templates6_saml.transient_mapping_attribute_name,` + + ` projections.idp_templates6_saml.federated_logout_enabled,` + // ldap ` projections.idp_templates6_ldap2.idp_id,` + ` projections.idp_templates6_ldap2.servers,` + @@ -228,6 +229,7 @@ var ( "with_signed_request", "name_id_format", "transient_mapping_attribute_name", + "federated_logout_enabled", // ldap config "idp_id", "servers", @@ -344,6 +346,7 @@ var ( ` projections.idp_templates6_saml.with_signed_request,` + ` projections.idp_templates6_saml.name_id_format,` + ` projections.idp_templates6_saml.transient_mapping_attribute_name,` + + ` projections.idp_templates6_saml.federated_logout_enabled,` + // ldap ` projections.idp_templates6_ldap2.idp_id,` + ` projections.idp_templates6_ldap2.servers,` + @@ -474,6 +477,7 @@ var ( "with_signed_request", "name_id_format", "transient_mapping_attribute_name", + "federated_logout_enabled", // ldap config "idp_id", "servers", @@ -630,6 +634,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -784,6 +789,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -936,6 +942,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1086,6 +1093,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1235,6 +1243,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1384,6 +1393,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1534,6 +1544,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1683,6 +1694,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { false, domain.SAMLNameIDFormatTransient, "customAttribute", + true, // ldap config nil, nil, @@ -1742,6 +1754,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { WithSignedRequest: false, NameIDFormat: sql.Null[domain.SAMLNameIDFormat]{V: domain.SAMLNameIDFormatTransient, Valid: true}, TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, }, }, }, @@ -1836,6 +1849,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config "idp-id", database.TextArray[string]{"server"}, @@ -2006,6 +2020,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -2157,6 +2172,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -2336,6 +2352,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config "idp-id", database.TextArray[string]{"server"}, @@ -2515,6 +2532,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -2667,6 +2685,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config "idp-id-ldap", database.TextArray[string]{"server"}, @@ -2784,6 +2803,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { false, domain.SAMLNameIDFormatTransient, "customAttribute", + true, // ldap config nil, nil, @@ -2901,6 +2921,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3018,6 +3039,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3135,6 +3157,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3252,6 +3275,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3360,6 +3384,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { WithSignedRequest: false, NameIDFormat: sql.Null[domain.SAMLNameIDFormat]{V: domain.SAMLNameIDFormatTransient, Valid: true}, TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, }, }, { diff --git a/internal/query/projection/idp_template.go b/internal/query/projection/idp_template.go index 55c74b851c..11eeb8c613 100644 --- a/internal/query/projection/idp_template.go +++ b/internal/query/projection/idp_template.go @@ -173,6 +173,7 @@ const ( SAMLWithSignedRequestCol = "with_signed_request" SAMLNameIDFormatCol = "name_id_format" SAMLTransientMappingAttributeName = "transient_mapping_attribute_name" + SAMLFederatedLogoutEnabled = "federated_logout_enabled" ) type idpTemplateProjection struct{} @@ -377,6 +378,7 @@ func (*idpTemplateProjection) Init() *old_handler.Check { handler.NewColumn(SAMLWithSignedRequestCol, handler.ColumnTypeBool, handler.Nullable()), handler.NewColumn(SAMLNameIDFormatCol, handler.ColumnTypeEnum, handler.Nullable()), handler.NewColumn(SAMLTransientMappingAttributeName, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(SAMLFederatedLogoutEnabled, handler.ColumnTypeBool, handler.Default(false)), }, handler.NewPrimaryKey(SAMLInstanceIDCol, SAMLIDCol), IDPTemplateSAMLSuffix, @@ -1990,6 +1992,7 @@ func (p *idpTemplateProjection) reduceSAMLIDPAdded(event eventstore.Event) (*han handler.NewCol(SAMLBindingCol, idpEvent.Binding), handler.NewCol(SAMLWithSignedRequestCol, idpEvent.WithSignedRequest), handler.NewCol(SAMLTransientMappingAttributeName, idpEvent.TransientMappingAttributeName), + handler.NewCol(SAMLFederatedLogoutEnabled, idpEvent.FederatedLogoutEnabled), } if idpEvent.NameIDFormat != nil { columns = append(columns, handler.NewCol(SAMLNameIDFormatCol, *idpEvent.NameIDFormat)) @@ -2525,5 +2528,8 @@ func reduceSAMLIDPChangedColumns(idpEvent idp.SAMLIDPChangedEvent) []handler.Col if idpEvent.TransientMappingAttributeName != nil { SAMLCols = append(SAMLCols, handler.NewCol(SAMLTransientMappingAttributeName, *idpEvent.TransientMappingAttributeName)) } + if idpEvent.FederatedLogoutEnabled != nil { + SAMLCols = append(SAMLCols, handler.NewCol(SAMLFederatedLogoutEnabled, *idpEvent.FederatedLogoutEnabled)) + } return SAMLCols } diff --git a/internal/query/projection/idp_template_test.go b/internal/query/projection/idp_template_test.go index cebf3f8791..6ba6dabd47 100644 --- a/internal/query/projection/idp_template_test.go +++ b/internal/query/projection/idp_template_test.go @@ -2793,7 +2793,8 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "isLinkingAllowed": true, "isAutoCreation": true, "isAutoUpdate": true, - "autoLinkingOption": 1 + "autoLinkingOption": 1, + "federatedLogoutEnabled": true }`), ), instance.SAMLIDPAddedEventMapper), }, @@ -2824,7 +2825,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, federated_logout_enabled, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -2834,6 +2835,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "binding", true, "customAttribute", + true, domain.SAMLNameIDFormatTransient, }, }, @@ -2865,7 +2867,8 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "isLinkingAllowed": true, "isAutoCreation": true, "isAutoUpdate": true, - "autoLinkingOption": 1 + "autoLinkingOption": 1, + "federatedLogoutEnabled": true }`), ), org.SAMLIDPAddedEventMapper), }, @@ -2896,7 +2899,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, federated_logout_enabled, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -2906,6 +2909,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "binding", true, "customAttribute", + true, domain.SAMLNameIDFormatTransient, }, }, @@ -2976,7 +2980,8 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "isLinkingAllowed": true, "isAutoCreation": true, "isAutoUpdate": true, - "autoLinkingOption": 1 + "autoLinkingOption": 1, + "federatedLogoutEnabled": true }`), ), instance.SAMLIDPChangedEventMapper), }, @@ -3002,13 +3007,14 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.idp_templates6_saml SET (metadata, key, certificate, binding, with_signed_request) = ($1, $2, $3, $4, $5) WHERE (idp_id = $6) AND (instance_id = $7)", + expectedStmt: "UPDATE projections.idp_templates6_saml SET (metadata, key, certificate, binding, with_signed_request, federated_logout_enabled) = ($1, $2, $3, $4, $5, $6) WHERE (idp_id = $7) AND (instance_id = $8)", expectedArgs: []interface{}{ []byte("metadata"), anyArg{}, anyArg{}, "binding", true, + true, "idp-id", "instance-id", }, diff --git a/internal/repository/idp/saml.go b/internal/repository/idp/saml.go index da6a52b1be..209d8371b3 100644 --- a/internal/repository/idp/saml.go +++ b/internal/repository/idp/saml.go @@ -19,6 +19,7 @@ type SAMLIDPAddedEvent struct { WithSignedRequest bool `json:"withSignedRequest,omitempty"` NameIDFormat *domain.SAMLNameIDFormat `json:"nameIDFormat,omitempty"` TransientMappingAttributeName string `json:"transientMappingAttributeName,omitempty"` + FederatedLogoutEnabled bool `json:"federatedLogoutEnabled,omitempty"` Options } @@ -33,6 +34,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options Options, ) *SAMLIDPAddedEvent { return &SAMLIDPAddedEvent{ @@ -46,6 +48,7 @@ func NewSAMLIDPAddedEvent( WithSignedRequest: withSignedRequest, NameIDFormat: nameIDFormat, TransientMappingAttributeName: transientMappingAttributeName, + FederatedLogoutEnabled: federatedLogoutEnabled, Options: options, } } @@ -83,6 +86,7 @@ type SAMLIDPChangedEvent struct { WithSignedRequest *bool `json:"withSignedRequest,omitempty"` NameIDFormat *domain.SAMLNameIDFormat `json:"nameIDFormat,omitempty"` TransientMappingAttributeName *string `json:"transientMappingAttributeName,omitempty"` + FederatedLogoutEnabled *bool `json:"federatedLogoutEnabled,omitempty"` OptionChanges } @@ -154,6 +158,12 @@ func ChangeSAMLTransientMappingAttributeName(name string) func(*SAMLIDPChangedEv } } +func ChangeSAMLFederatedLogoutEnabled(federatedLogoutEnabled bool) func(*SAMLIDPChangedEvent) { + return func(e *SAMLIDPChangedEvent) { + e.FederatedLogoutEnabled = &federatedLogoutEnabled + } +} + func ChangeSAMLOptions(options OptionChanges) func(*SAMLIDPChangedEvent) { return func(e *SAMLIDPChangedEvent) { e.OptionChanges = options diff --git a/internal/repository/instance/idp.go b/internal/repository/instance/idp.go index 6ab60c0dd5..f0f324cb7d 100644 --- a/internal/repository/instance/idp.go +++ b/internal/repository/instance/idp.go @@ -1024,6 +1024,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) *SAMLIDPAddedEvent { return &SAMLIDPAddedEvent{ @@ -1042,6 +1043,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ), } diff --git a/internal/repository/org/idp.go b/internal/repository/org/idp.go index 0070f71a95..5f061370f1 100644 --- a/internal/repository/org/idp.go +++ b/internal/repository/org/idp.go @@ -1025,6 +1025,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) *SAMLIDPAddedEvent { @@ -1044,6 +1045,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ), } diff --git a/internal/user/repository/view/user_session.sql b/internal/user/repository/view/user_session.sql new file mode 100644 index 0000000000..3e2a97206b --- /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 b3d155f1ec..dec22a181d 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, diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index d9f8bee2c7..c173da9181 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -7027,6 +7027,9 @@ message AddSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 8; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 9; } message AddSAMLProviderResponse { @@ -7061,6 +7064,9 @@ message UpdateSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 9; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 10; } message UpdateSAMLProviderResponse { diff --git a/proto/zitadel/idp.proto b/proto/zitadel/idp.proto index 82e32aa873..de497d8c93 100644 --- a/proto/zitadel/idp.proto +++ b/proto/zitadel/idp.proto @@ -479,6 +479,9 @@ message SAMLConfig { // Optional name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 5; + // Boolean weather federated logout is enabled. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 6; } message AzureADConfig { diff --git a/proto/zitadel/idp/v2/idp.proto b/proto/zitadel/idp/v2/idp.proto index 0c95b742f1..663581a659 100644 --- a/proto/zitadel/idp/v2/idp.proto +++ b/proto/zitadel/idp/v2/idp.proto @@ -303,6 +303,9 @@ message SAMLConfig { // in case the nameid-format returned is // `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 5; + // Boolean weather federated logout is enabled. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 6; } message AzureADConfig { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index e69e331f87..96d4adf2d4 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -13320,6 +13320,9 @@ message AddSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 8; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 9; } message AddSAMLProviderResponse { @@ -13354,6 +13357,9 @@ message UpdateSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 9; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 10; } message UpdateSAMLProviderResponse {