From 2cf3ef4de4ef993367daec6ff3974bdbdf70d2f3 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 23 May 2025 13:52:25 +0200 Subject: [PATCH] feat: federated logout for SAML IdPs (#9931) # 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 - Admins can enabled a federated logout option on SAML IdPs through the Admin and Management APIs. - 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 - This PR merges the https://github.com/zitadel/zitadel/pull/9841 and https://github.com/zitadel/zitadel/pull/9854 to main, additionally updating the docs on Entra ID SAML. - closes #9228 - backport to 3.x --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> Co-authored-by: Zach Hirschtritt --- cmd/defaults.yaml | 10 ++ cmd/setup/56.go | 27 ++++ cmd/setup/56.sql | 1 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + cmd/start/start.go | 30 ++++- .../provider-oidc.component.html | 13 +- .../provider-saml-sp.component.html | 11 +- .../provider-saml-sp.component.scss | 10 +- .../provider-saml-sp.component.ts | 7 + console/src/assets/i18n/bg.json | 3 +- console/src/assets/i18n/cs.json | 2 + console/src/assets/i18n/de.json | 2 + console/src/assets/i18n/en.json | 2 + console/src/assets/i18n/es.json | 2 + console/src/assets/i18n/fr.json | 2 + console/src/assets/i18n/hu.json | 2 + console/src/assets/i18n/id.json | 2 + console/src/assets/i18n/it.json | 2 + console/src/assets/i18n/ja.json | 2 + console/src/assets/i18n/ko.json | 2 + console/src/assets/i18n/mk.json | 2 + console/src/assets/i18n/nl.json | 2 + console/src/assets/i18n/pl.json | 2 + console/src/assets/i18n/pt.json | 2 + console/src/assets/i18n/ro.json | 2 + console/src/assets/i18n/ru.json | 2 + console/src/assets/i18n/sv.json | 2 + console/src/assets/i18n/zh.json | 2 + .../identity-providers/azure-ad-saml.mdx | 5 +- internal/api/grpc/admin/idp_converter.go | 2 + internal/api/grpc/idp/converter.go | 1 + internal/api/grpc/idp/v2/query.go | 1 + internal/api/grpc/management/idp_converter.go | 2 + internal/api/idp/idp.go | 44 +++++++ internal/api/oidc/auth_request.go | 63 ++++++++- internal/api/oidc/op.go | 19 ++- .../api/ui/login/external_provider_handler.go | 121 ++++++++++++++++++ internal/api/ui/login/login.go | 30 +++-- internal/api/ui/login/router.go | 2 + .../eventsourcing/eventstore/user.go | 5 + .../eventsourcing/view/user_session.go | 4 + internal/auth/repository/user.go | 2 + internal/cache/cache.go | 1 + internal/cache/connector/connector.go | 1 + internal/cache/purpose_enumer.go | 12 +- internal/command/idp.go | 1 + internal/command/idp_intent_test.go | 2 + internal/command/idp_model.go | 10 ++ internal/command/instance_idp.go | 3 + internal/command/instance_idp_model.go | 2 + internal/command/instance_idp_test.go | 8 ++ internal/command/org_idp.go | 3 + internal/command/org_idp_model.go | 2 + internal/command/org_idp_test.go | 8 ++ internal/domain/federatedlogout/logout.go | 37 ++++++ internal/query/idp_template.go | 13 ++ internal/query/idp_template_test.go | 25 ++++ internal/query/projection/idp_template.go | 6 + .../query/projection/idp_template_test.go | 18 ++- internal/repository/idp/saml.go | 10 ++ internal/repository/instance/idp.go | 2 + internal/repository/org/idp.go | 2 + .../user/repository/view/user_session.sql | 29 +++++ .../user/repository/view/user_session_view.go | 19 ++- proto/zitadel/admin.proto | 6 + proto/zitadel/idp.proto | 3 + proto/zitadel/idp/v2/idp.proto | 3 + proto/zitadel/management.proto | 6 + 69 files changed, 633 insertions(+), 51 deletions(-) create mode 100644 cmd/setup/56.go create mode 100644 cmd/setup/56.sql create mode 100644 internal/domain/federatedlogout/logout.go create mode 100644 internal/user/repository/view/user_session.sql diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 397e9af376..7bb44b743f 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 1d83197062..af76b29e99 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -74,12 +74,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" @@ -511,7 +513,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 { @@ -532,7 +539,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) } @@ -581,6 +606,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 444c5aaa85..f1ce9bfa2a 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 9033fd8668..1e7f3b7407 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -7035,6 +7035,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 { @@ -7069,6 +7072,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 c90c44667c..3018ebe600 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -13408,6 +13408,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 { @@ -13442,6 +13445,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 {