From fe8e81555b84042ad384eeab5741eee93e0fea70 Mon Sep 17 00:00:00 2001 From: Maduranga Siriwardena Date: Tue, 14 Jul 2026 10:18:16 +0530 Subject: [PATCH] Implement session sign-out on the flow-graph model Add session sign-out authored and configured per flow, on the same flow-graph model login/SSO use, rather than a hardcoded path in the OAuth2 layer. Two entry points converge on one termination machinery: - RP-initiated logout via GET/POST /oauth2/logout (end_session_endpoint) - Native, API-driven sign-out via the existing /flow/execute machinery Both resolve the target session, revoke it and its stored context, clear the per-flow SSO cookie, and (browser path) confirm with the user and land them on a validated post_logout_redirect_uri. Key design: sign-out separates two resolutions. The flow to run is the client's SignOutFlowID (first-class SIGNOUT flow type, resolved like recovery); the session to end belongs to the login flow, so AuthFlowID is threaded onto the engine context as SessionFlowID and used by applyInboundSSO to read the correct login-flow cookie. SessionSignOutExecutor calls session.Terminate (State=ENDED, bump version, delete contexts and participants; idempotent) and signals a cookie clear applied via the existing ssoTransport.Clear seam. The gate sign-out page resumes the flow over /flow/execute and runs on the published @thunderid/react SDK. OIDC discovery advertises end_session_endpoint. Console gains the SIGNOUT flow type, a sign-out flow selector, templates, post-logout redirect URI settings, and the sign-out executor in the flow editor. OIDC spec terms (/oauth2/logout, post_logout_redirect_uri, end_session_endpoint, logout_hint) are preserved; user-facing text and internal identifiers use "Sign Out". Refs #3916 --- backend/.mockery.private.yml | 15 + backend/cmd/server/servicemanager.go | 3 +- backend/dbscripts/configdb/postgres.sql | 2 + backend/dbscripts/configdb/sqlite.sql | 2 + backend/internal/actorprovider/service.go | 1 + backend/internal/actorprovider/utils.go | 9 +- backend/internal/actorprovider/utils_test.go | 15 + backend/internal/agent/service.go | 2 + .../application/declarative_resource.go | 4 + backend/internal/application/handler.go | 13 + .../internal/application/model/application.go | 4 + backend/internal/application/service.go | 19 + backend/internal/flow/common/constants.go | 5 + backend/internal/flow/executor/constants.go | 1 + backend/internal/flow/executor/register.go | 4 + .../flow/executor/session_signout_executor.go | 84 ++++ .../executor/session_signout_executor_test.go | 111 +++++ backend/internal/flow/flowexec/engine.go | 18 +- .../internal/flow/flowexec/error_constants.go | 14 + backend/internal/flow/flowexec/handler.go | 5 + backend/internal/flow/flowexec/model.go | 10 + backend/internal/flow/flowexec/service.go | 37 +- .../internal/flow/flowexec/service_test.go | 14 +- .../flow/session/Service_mock_test.go | 74 +++ backend/internal/flow/session/interface.go | 2 + backend/internal/flow/session/service.go | 47 ++ backend/internal/flow/session/service_test.go | 61 +++ .../flow/session/sessionStore_mock_test.go | 57 +++ backend/internal/flow/session/store.go | 11 + .../internal/flow/session/store_constants.go | 6 + .../internal/inboundclient/error_constants.go | 2 + backend/internal/inboundclient/model/oauth.go | 1 + backend/internal/inboundclient/service.go | 30 ++ backend/internal/inboundclient/store.go | 40 +- .../internal/inboundclient/store_constants.go | 19 +- backend/internal/inboundclient/store_test.go | 8 +- backend/internal/oauth/init.go | 3 + .../oauth/oauth2/constants/constants.go | 2 + backend/internal/oauth/oauth2/dcr/model.go | 2 + backend/internal/oauth/oauth2/dcr/service.go | 2 + .../oauth/oauth2/discovery/service.go | 5 + .../logout/RuntimeStoreProvider_mock_test.go | 463 ++++++++++++++++++ .../internal/oauth/oauth2/logout/handler.go | 146 ++++++ .../oauth/oauth2/logout/handler_test.go | 268 ++++++++++ backend/internal/oauth/oauth2/logout/init.go | 70 +++ .../logoutRequestStoreInterface_mock_test.go | 233 +++++++++ .../internal/oauth/oauth2/logout/service.go | 248 ++++++++++ .../oauth/oauth2/logout/service_test.go | 364 ++++++++++++++ backend/internal/oauth/oauth2/logout/store.go | 136 +++++ .../oauth/oauth2/logout/store_test.go | 134 +++++ backend/internal/system/config/config.go | 3 + backend/internal/system/i18n/core/defaults.go | 2 + backend/internal/system/importer/service.go | 7 + backend/pkg/thunderidengine/config/config.go | 1 + backend/pkg/thunderidengine/engine.go | 2 +- .../thunderidengine/providers/constants.go | 4 + .../pkg/thunderidengine/providers/model.go | 8 + .../thunderidengine/providers/oauth_client.go | 24 + .../mocks/flow/sessionmock/Service_mock.go | 74 +++ frontend/apps/console/src/App.tsx | 20 + .../flows-settings/EditFlowsSettings.tsx | 7 + .../flows-settings/SignOutFlowSection.tsx | 147 ++++++ .../__tests__/EditFlowsSettings.test.tsx | 10 + .../__tests__/SignOutFlowSection.test.tsx | 195 ++++++++ .../general-settings/AccessSection.tsx | 219 +++++++-- .../__tests__/AccessSection.test.tsx | 75 ++- .../applications/models/application.ts | 12 + .../src/features/applications/models/oauth.ts | 7 + .../components/create-flow/SelectFlowType.tsx | 14 +- .../__tests__/SelectFlowType.test.tsx | 16 + .../components/resources/steps/call/Call.tsx | 2 + .../steps/call/__tests__/Call.test.tsx | 11 + .../src/features/flows/data/templates.json | 217 ++++++++ .../src/features/flows/models/flows.ts | 5 + .../extended-properties/CallProperties.tsx | 6 +- .../__tests__/CallProperties.test.tsx | 5 +- .../features/login-flow/data/executors.json | 20 + frontend/apps/gate/src/App.tsx | 2 + .../gate/src/components/SignOut/SignOut.tsx | 32 ++ .../src/components/SignOut/SignOutBox.tsx | 172 +++++++ .../SignOut/__tests__/SignOut.test.tsx | 78 +++ .../SignOut/__tests__/SignOutBox.test.tsx | 221 +++++++++ .../src/constants/__tests__/routes.test.ts | 5 + frontend/apps/gate/src/constants/routes.ts | 5 + frontend/apps/gate/src/pages/SignOutPage.tsx | 24 + .../src/pages/__tests__/SignOutPage.test.tsx | 38 ++ frontend/packages/i18n/src/locales/en-US.ts | 11 + .../oauth/discovery/discovery_test.go | 5 +- 88 files changed, 4430 insertions(+), 102 deletions(-) create mode 100644 backend/internal/flow/executor/session_signout_executor.go create mode 100644 backend/internal/flow/executor/session_signout_executor_test.go create mode 100644 backend/internal/oauth/oauth2/logout/RuntimeStoreProvider_mock_test.go create mode 100644 backend/internal/oauth/oauth2/logout/handler.go create mode 100644 backend/internal/oauth/oauth2/logout/handler_test.go create mode 100644 backend/internal/oauth/oauth2/logout/init.go create mode 100644 backend/internal/oauth/oauth2/logout/logoutRequestStoreInterface_mock_test.go create mode 100644 backend/internal/oauth/oauth2/logout/service.go create mode 100644 backend/internal/oauth/oauth2/logout/service_test.go create mode 100644 backend/internal/oauth/oauth2/logout/store.go create mode 100644 backend/internal/oauth/oauth2/logout/store_test.go create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/flows-settings/SignOutFlowSection.tsx create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/SignOutFlowSection.test.tsx create mode 100644 frontend/apps/gate/src/components/SignOut/SignOut.tsx create mode 100644 frontend/apps/gate/src/components/SignOut/SignOutBox.tsx create mode 100644 frontend/apps/gate/src/components/SignOut/__tests__/SignOut.test.tsx create mode 100644 frontend/apps/gate/src/components/SignOut/__tests__/SignOutBox.test.tsx create mode 100644 frontend/apps/gate/src/pages/SignOutPage.tsx create mode 100644 frontend/apps/gate/src/pages/__tests__/SignOutPage.test.tsx diff --git a/backend/.mockery.private.yml b/backend/.mockery.private.yml index 1fe23e7631..6bba9ff633 100644 --- a/backend/.mockery.private.yml +++ b/backend/.mockery.private.yml @@ -53,6 +53,15 @@ packages: pkgname: par filename: "{{.InterfaceName}}_mock_test.go" + github.com/thunder-id/thunderid/internal/oauth/oauth2/logout: + interfaces: + logoutRequestStoreInterface: + config: + dir: internal/oauth/oauth2/logout + structname: '{{.InterfaceName}}Mock' + pkgname: logout + filename: "{{.InterfaceName}}_mock_test.go" + github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop: config: all: true @@ -404,6 +413,12 @@ packages: structname: ExecutorInterfaceMock pkgname: core filename: "ExecutorInterface_mock_test.go" + RuntimeStoreProvider: + config: + dir: internal/oauth/oauth2/logout + structname: '{{.InterfaceName}}Mock' + pkgname: logout + filename: "{{.InterfaceName}}_mock_test.go" github.com/thunder-id/thunderid/internal/flow/graphbuilder: interfaces: diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index 55165024b9..02d076ce58 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -482,7 +482,8 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // Initialize OAuth services. err = oauth.Initialize(mux, actorProvider, authnProvider, jwtService, jweService, flowExecService, observabilitySvc, runtimeCryptoSvc, ouService, attributeCacheService, authZService, - resourceService, serverConfigService, i18nService, idpService, dpopVerifier, oauthCfg) + resourceService, serverConfigService, i18nService, idpService, dpopVerifier, + runtimeStoreProvider, oauthCfg) if err != nil { logger.Fatal(ctx, "Failed to initialize OAuth services", log.Error(err)) } diff --git a/backend/dbscripts/configdb/postgres.sql b/backend/dbscripts/configdb/postgres.sql index 576c2907f2..d4dda24b6f 100644 --- a/backend/dbscripts/configdb/postgres.sql +++ b/backend/dbscripts/configdb/postgres.sql @@ -103,6 +103,8 @@ CREATE TABLE "INBOUND_CLIENT" ( IS_REGISTRATION_FLOW_ENABLED CHAR(1) DEFAULT '1', RECOVERY_FLOW_ID VARCHAR(100), IS_RECOVERY_FLOW_ENABLED CHAR(1) DEFAULT '0', + SIGNOUT_FLOW_ID VARCHAR(100), + IS_SIGNOUT_FLOW_ENABLED CHAR(1) DEFAULT '0', THEME_ID VARCHAR(36), LAYOUT_ID VARCHAR(36), PROPERTIES JSONB, diff --git a/backend/dbscripts/configdb/sqlite.sql b/backend/dbscripts/configdb/sqlite.sql index 7d7ae32a4d..a05d6091e8 100644 --- a/backend/dbscripts/configdb/sqlite.sql +++ b/backend/dbscripts/configdb/sqlite.sql @@ -103,6 +103,8 @@ CREATE TABLE "INBOUND_CLIENT" ( IS_REGISTRATION_FLOW_ENABLED CHAR(1) DEFAULT '1', RECOVERY_FLOW_ID VARCHAR(100), IS_RECOVERY_FLOW_ENABLED CHAR(1) DEFAULT '0', + SIGNOUT_FLOW_ID VARCHAR(100), + IS_SIGNOUT_FLOW_ENABLED CHAR(1) DEFAULT '0', THEME_ID VARCHAR(36), LAYOUT_ID VARCHAR(36), PROPERTIES TEXT, diff --git a/backend/internal/actorprovider/service.go b/backend/internal/actorprovider/service.go index 781a9bd220..8bd2dfd9dd 100644 --- a/backend/internal/actorprovider/service.go +++ b/backend/internal/actorprovider/service.go @@ -156,6 +156,7 @@ func toProviderOAuthClient(c *providers.OAuthClient) *providers.OAuthClient { OUID: c.OUID, ClientID: c.ClientID, RedirectURIs: c.RedirectURIs, + PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, TokenEndpointAuthMethod: c.TokenEndpointAuthMethod, PKCERequired: c.PKCERequired, PublicClient: c.PublicClient, diff --git a/backend/internal/actorprovider/utils.go b/backend/internal/actorprovider/utils.go index 3432a69d45..147d939e31 100644 --- a/backend/internal/actorprovider/utils.go +++ b/backend/internal/actorprovider/utils.go @@ -54,9 +54,12 @@ func assembleApplication( app := &providers.Application{ ID: client.ID, InboundAuthProfile: providers.InboundAuthProfile{ - Assertion: client.Assertion, - LoginConsent: client.LoginConsent, - AllowedUserTypes: client.AllowedUserTypes, + AuthFlowID: client.AuthFlowID, + SignOutFlowID: client.SignOutFlowID, + IsSignOutFlowEnabled: client.IsSignOutFlowEnabled, + Assertion: client.Assertion, + LoginConsent: client.LoginConsent, + AllowedUserTypes: client.AllowedUserTypes, }, } diff --git a/backend/internal/actorprovider/utils_test.go b/backend/internal/actorprovider/utils_test.go index d0f045b3c0..1babcbc3e0 100644 --- a/backend/internal/actorprovider/utils_test.go +++ b/backend/internal/actorprovider/utils_test.go @@ -138,6 +138,21 @@ func (s *UtilsTestSuite) TestAssembleApplication_NoClientID() { s.Empty(app.InboundAuthConfig) } +func (s *UtilsTestSuite) TestAssembleApplication_CarriesFlowIDs() { + client := &providers.InboundClient{ + ID: "app-1", + AuthFlowID: "auth-flow", + SignOutFlowID: "signout-flow", + IsSignOutFlowEnabled: true, + } + + app := assembleApplication(client, nil) + + s.Equal("auth-flow", app.AuthFlowID) + s.Equal("signout-flow", app.SignOutFlowID) + s.True(app.IsSignOutFlowEnabled) +} + func (s *UtilsTestSuite) TestBuildApplication_NotFound() { s.mockInbound.On("GetInboundClientByEntityID", mock.Anything, "missing"). Return((*inboundmodel.InboundClient)(nil), inboundclient.ErrInboundClientNotFound) diff --git a/backend/internal/agent/service.go b/backend/internal/agent/service.go index 42fde29ba0..fb508f6e8d 100644 --- a/backend/internal/agent/service.go +++ b/backend/internal/agent/service.go @@ -1361,6 +1361,7 @@ func buildOAuthProfile(configs []providers.InboundAuthConfigWithSecret) *provide } return &providers.OAuthProfile{ RedirectURIs: cfg.RedirectURIs, + PostLogoutRedirectURIs: cfg.PostLogoutRedirectURIs, GrantTypes: grantTypes, ResponseTypes: sysutils.ConvertToStringSlice(cfg.ResponseTypes), TokenEndpointAuthMethod: string(authMethod), @@ -1386,6 +1387,7 @@ func oauthProfileToComplete(clientID string, p *providers.OAuthProfile) *provide return &providers.OAuthConfigWithSecret{ ClientID: clientID, RedirectURIs: p.RedirectURIs, + PostLogoutRedirectURIs: p.PostLogoutRedirectURIs, GrantTypes: grants, ResponseTypes: respTypes, TokenEndpointAuthMethod: providers.TokenEndpointAuthMethod(p.TokenEndpointAuthMethod), diff --git a/backend/internal/application/declarative_resource.go b/backend/internal/application/declarative_resource.go index bc38e1736f..7ab4ccd23f 100644 --- a/backend/internal/application/declarative_resource.go +++ b/backend/internal/application/declarative_resource.go @@ -183,6 +183,9 @@ func parseToApplicationDTO(data []byte) (*model.ApplicationDTO, error) { RecoveryFlowID: appRequest.RecoveryFlowID, RecoveryFlowHandle: appRequest.RecoveryFlowHandle, IsRecoveryFlowEnabled: appRequest.IsRecoveryFlowEnabled, + SignOutFlowID: appRequest.SignOutFlowID, + SignOutFlowHandle: appRequest.SignOutFlowHandle, + IsSignOutFlowEnabled: appRequest.IsSignOutFlowEnabled, ThemeID: appRequest.ThemeID, LayoutID: appRequest.LayoutID, Assertion: appRequest.Assertion, @@ -211,6 +214,7 @@ func parseToApplicationDTO(data []byte) (*model.ApplicationDTO, error) { ClientID: config.OAuthConfig.ClientID, ClientSecret: config.OAuthConfig.ClientSecret, RedirectURIs: config.OAuthConfig.RedirectURIs, + PostLogoutRedirectURIs: config.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: config.OAuthConfig.GrantTypes, ResponseTypes: config.OAuthConfig.ResponseTypes, TokenEndpointAuthMethod: config.OAuthConfig.TokenEndpointAuthMethod, diff --git a/backend/internal/application/handler.go b/backend/internal/application/handler.go index 92bd06f7e1..f4c08b610d 100644 --- a/backend/internal/application/handler.go +++ b/backend/internal/application/handler.go @@ -76,6 +76,8 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter IsRegistrationFlowEnabled: appRequest.IsRegistrationFlowEnabled, RecoveryFlowID: appRequest.RecoveryFlowID, IsRecoveryFlowEnabled: appRequest.IsRecoveryFlowEnabled, + SignOutFlowID: appRequest.SignOutFlowID, + IsSignOutFlowEnabled: appRequest.IsSignOutFlowEnabled, ThemeID: appRequest.ThemeID, LayoutID: appRequest.LayoutID, Assertion: appRequest.Assertion, @@ -112,6 +114,8 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter IsRegistrationFlowEnabled: createdAppDTO.IsRegistrationFlowEnabled, RecoveryFlowID: createdAppDTO.RecoveryFlowID, IsRecoveryFlowEnabled: createdAppDTO.IsRecoveryFlowEnabled, + SignOutFlowID: createdAppDTO.SignOutFlowID, + IsSignOutFlowEnabled: createdAppDTO.IsSignOutFlowEnabled, ThemeID: createdAppDTO.ThemeID, LayoutID: createdAppDTO.LayoutID, Assertion: createdAppDTO.Assertion, @@ -191,6 +195,8 @@ func (ah *applicationHandler) HandleApplicationGetRequest(w http.ResponseWriter, IsRegistrationFlowEnabled: appDTO.IsRegistrationFlowEnabled, RecoveryFlowID: appDTO.RecoveryFlowID, IsRecoveryFlowEnabled: appDTO.IsRecoveryFlowEnabled, + SignOutFlowID: appDTO.SignOutFlowID, + IsSignOutFlowEnabled: appDTO.IsSignOutFlowEnabled, ThemeID: appDTO.ThemeID, LayoutID: appDTO.LayoutID, Assertion: appDTO.Assertion, @@ -261,6 +267,7 @@ func (ah *applicationHandler) HandleApplicationGetRequest(w http.ResponseWriter, oAuthAppConfig := inboundmodel.OAuthConfig{ ClientID: config.OAuthConfig.ClientID, RedirectURIs: redirectURIs, + PostLogoutRedirectURIs: config.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: grantTypes, ResponseTypes: responseTypes, TokenEndpointAuthMethod: config.OAuthConfig.TokenEndpointAuthMethod, @@ -331,6 +338,8 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, IsRegistrationFlowEnabled: appRequest.IsRegistrationFlowEnabled, RecoveryFlowID: appRequest.RecoveryFlowID, IsRecoveryFlowEnabled: appRequest.IsRecoveryFlowEnabled, + SignOutFlowID: appRequest.SignOutFlowID, + IsSignOutFlowEnabled: appRequest.IsSignOutFlowEnabled, ThemeID: appRequest.ThemeID, LayoutID: appRequest.LayoutID, Assertion: appRequest.Assertion, @@ -367,6 +376,8 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, IsRegistrationFlowEnabled: updatedAppDTO.IsRegistrationFlowEnabled, RecoveryFlowID: updatedAppDTO.RecoveryFlowID, IsRecoveryFlowEnabled: updatedAppDTO.IsRecoveryFlowEnabled, + SignOutFlowID: updatedAppDTO.SignOutFlowID, + IsSignOutFlowEnabled: updatedAppDTO.IsSignOutFlowEnabled, ThemeID: updatedAppDTO.ThemeID, LayoutID: updatedAppDTO.LayoutID, Assertion: updatedAppDTO.Assertion, @@ -462,6 +473,7 @@ func (ah *applicationHandler) processInboundAuthConfig( ClientID: config.OAuthConfig.ClientID, ClientSecret: config.OAuthConfig.ClientSecret, RedirectURIs: redirectURIs, + PostLogoutRedirectURIs: config.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: grantTypes, ResponseTypes: responseTypes, TokenEndpointAuthMethod: config.OAuthConfig.TokenEndpointAuthMethod, @@ -540,6 +552,7 @@ func (ah *applicationHandler) processInboundAuthConfigFromRequest( ClientID: config.OAuthConfig.ClientID, ClientSecret: config.OAuthConfig.ClientSecret, RedirectURIs: config.OAuthConfig.RedirectURIs, + PostLogoutRedirectURIs: config.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: config.OAuthConfig.GrantTypes, ResponseTypes: config.OAuthConfig.ResponseTypes, TokenEndpointAuthMethod: config.OAuthConfig.TokenEndpointAuthMethod, diff --git a/backend/internal/application/model/application.go b/backend/internal/application/model/application.go index 697e5fe3ea..e19b328584 100644 --- a/backend/internal/application/model/application.go +++ b/backend/internal/application/model/application.go @@ -57,6 +57,8 @@ type BasicApplicationDTO struct { IsRegistrationFlowEnabled bool RecoveryFlowID string IsRecoveryFlowEnabled bool + SignOutFlowID string + IsSignOutFlowEnabled bool ThemeID string LayoutID string Template string @@ -175,6 +177,8 @@ type BasicApplicationResponse struct { IsRegistrationFlowEnabled bool `json:"isRegistrationFlowEnabled" jsonschema:"Registration enabled status."` RecoveryFlowID string `json:"recoveryFlowId,omitempty" jsonschema:"Recovery Flow ID."` IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled" jsonschema:"Recovery enabled status."` + SignOutFlowID string `json:"signOutFlowId,omitempty" jsonschema:"Sign-out flow ID."` + IsSignOutFlowEnabled bool `json:"isSignOutFlowEnabled" jsonschema:"Sign-out enabled status."` ThemeID string `json:"themeId,omitempty" jsonschema:"Theme ID."` LayoutID string `json:"layoutId,omitempty" jsonschema:"Layout ID."` Template string `json:"template,omitempty" jsonschema:"Application Template."` diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index fadf238db5..5389a4c5fe 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -189,6 +189,7 @@ func (as *applicationService) CreateApplication(ctx context.Context, app *model. appForReturn.AuthFlowID = inboundClient.AuthFlowID appForReturn.RegistrationFlowID = inboundClient.RegistrationFlowID appForReturn.RecoveryFlowID = inboundClient.RecoveryFlowID + appForReturn.SignOutFlowID = inboundClient.SignOutFlowID var oauthToken *providers.OAuthTokenConfig var userInfo *providers.UserInfoConfig var scopeClaims map[string][]string @@ -271,6 +272,7 @@ func (as *applicationService) ValidateApplication(ctx context.Context, app *mode processedDTO.AuthFlowID = inboundClient.AuthFlowID processedDTO.RegistrationFlowID = inboundClient.RegistrationFlowID processedDTO.RecoveryFlowID = inboundClient.RecoveryFlowID + processedDTO.SignOutFlowID = inboundClient.SignOutFlowID return processedDTO, inboundAuthConfig, nil } @@ -435,6 +437,7 @@ func (as *applicationService) UpdateApplication(ctx context.Context, appID strin appForReturn.AuthFlowID = inboundClient.AuthFlowID appForReturn.RegistrationFlowID = inboundClient.RegistrationFlowID appForReturn.RecoveryFlowID = inboundClient.RecoveryFlowID + appForReturn.SignOutFlowID = inboundClient.SignOutFlowID var oauthToken *providers.OAuthTokenConfig var userInfo *providers.UserInfoConfig var scopeClaims map[string][]string @@ -770,6 +773,8 @@ func toInboundClient(dto *model.ApplicationProcessedDTO) inboundmodel.InboundCli IsRegistrationFlowEnabled: dto.IsRegistrationFlowEnabled, RecoveryFlowID: dto.RecoveryFlowID, IsRecoveryFlowEnabled: dto.IsRecoveryFlowEnabled, + SignOutFlowID: dto.SignOutFlowID, + IsSignOutFlowEnabled: dto.IsSignOutFlowEnabled, ThemeID: dto.ThemeID, LayoutID: dto.LayoutID, Assertion: dto.Assertion, @@ -821,6 +826,8 @@ func toProcessedDTO( IsRegistrationFlowEnabled: dao.IsRegistrationFlowEnabled, RecoveryFlowID: dao.RecoveryFlowID, IsRecoveryFlowEnabled: dao.IsRecoveryFlowEnabled, + SignOutFlowID: dao.SignOutFlowID, + IsSignOutFlowEnabled: dao.IsSignOutFlowEnabled, ThemeID: dao.ThemeID, LayoutID: dao.LayoutID, Assertion: dao.Assertion, @@ -927,6 +934,7 @@ func buildOAuthProfileFromProcessed(inboundAuth inboundmodel.InboundAuthConfigPr oa := inboundAuth.OAuthConfig return &providers.OAuthProfile{ RedirectURIs: oa.RedirectURIs, + PostLogoutRedirectURIs: oa.PostLogoutRedirectURIs, GrantTypes: sysutils.ConvertToStringSlice(oa.GrantTypes), ResponseTypes: sysutils.ConvertToStringSlice(oa.ResponseTypes), TokenEndpointAuthMethod: string(oa.TokenEndpointAuthMethod), @@ -1723,6 +1731,8 @@ func buildApplicationResponse(dto *model.ApplicationProcessedDTO) *providers.App IsRegistrationFlowEnabled: dto.IsRegistrationFlowEnabled, RecoveryFlowID: dto.RecoveryFlowID, IsRecoveryFlowEnabled: dto.IsRecoveryFlowEnabled, + SignOutFlowID: dto.SignOutFlowID, + IsSignOutFlowEnabled: dto.IsSignOutFlowEnabled, ThemeID: dto.ThemeID, LayoutID: dto.LayoutID, Assertion: dto.Assertion, @@ -1747,6 +1757,7 @@ func buildApplicationResponse(dto *model.ApplicationProcessedDTO) *providers.App OAuthConfig: &providers.OAuthConfigWithSecret{ ClientID: oauthAppConfig.ClientID, RedirectURIs: oauthAppConfig.RedirectURIs, + PostLogoutRedirectURIs: oauthAppConfig.PostLogoutRedirectURIs, GrantTypes: oauthAppConfig.GrantTypes, ResponseTypes: oauthAppConfig.ResponseTypes, TokenEndpointAuthMethod: oauthAppConfig.TokenEndpointAuthMethod, @@ -1779,6 +1790,8 @@ func buildBasicApplicationResponse( IsRegistrationFlowEnabled: cfg.IsRegistrationFlowEnabled, RecoveryFlowID: cfg.RecoveryFlowID, IsRecoveryFlowEnabled: cfg.IsRecoveryFlowEnabled, + SignOutFlowID: cfg.SignOutFlowID, + IsSignOutFlowEnabled: cfg.IsSignOutFlowEnabled, ThemeID: cfg.ThemeID, LayoutID: cfg.LayoutID, IsReadOnly: cfg.IsReadOnly, @@ -1827,6 +1840,8 @@ func buildBaseApplicationProcessedDTO(appID string, app *model.ApplicationDTO, IsRegistrationFlowEnabled: app.IsRegistrationFlowEnabled, RecoveryFlowID: app.RecoveryFlowID, IsRecoveryFlowEnabled: app.IsRecoveryFlowEnabled, + SignOutFlowID: app.SignOutFlowID, + IsSignOutFlowEnabled: app.IsSignOutFlowEnabled, ThemeID: app.ThemeID, LayoutID: app.LayoutID, Assertion: assertion, @@ -1873,6 +1888,7 @@ func buildOAuthInboundAuthConfigProcessedDTO( ID: appID, ClientID: inboundAuthConfig.OAuthConfig.ClientID, RedirectURIs: inboundAuthConfig.OAuthConfig.RedirectURIs, + PostLogoutRedirectURIs: inboundAuthConfig.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: inboundAuthConfig.OAuthConfig.GrantTypes, ResponseTypes: inboundAuthConfig.OAuthConfig.ResponseTypes, TokenEndpointAuthMethod: inboundAuthConfig.OAuthConfig.TokenEndpointAuthMethod, @@ -1908,6 +1924,8 @@ func buildReturnApplicationDTO( IsRegistrationFlowEnabled: app.IsRegistrationFlowEnabled, RecoveryFlowID: app.RecoveryFlowID, IsRecoveryFlowEnabled: app.IsRecoveryFlowEnabled, + SignOutFlowID: app.SignOutFlowID, + IsSignOutFlowEnabled: app.IsSignOutFlowEnabled, ThemeID: app.ThemeID, LayoutID: app.LayoutID, Assertion: assertion, @@ -1934,6 +1952,7 @@ func buildReturnApplicationDTO( ClientID: inboundAuthConfig.OAuthConfig.ClientID, ClientSecret: inboundAuthConfig.OAuthConfig.ClientSecret, RedirectURIs: inboundAuthConfig.OAuthConfig.RedirectURIs, + PostLogoutRedirectURIs: inboundAuthConfig.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: inboundAuthConfig.OAuthConfig.GrantTypes, ResponseTypes: inboundAuthConfig.OAuthConfig.ResponseTypes, TokenEndpointAuthMethod: inboundAuthConfig.OAuthConfig.TokenEndpointAuthMethod, diff --git a/backend/internal/flow/common/constants.go b/backend/internal/flow/common/constants.go index 9d97c55309..3be81a3044 100644 --- a/backend/internal/flow/common/constants.go +++ b/backend/internal/flow/common/constants.go @@ -226,6 +226,11 @@ const ( // to the transport layer for the per-flow cookie. Using the generic EngineData channel keeps SSO // concepts out of the reusable engine contract. RuntimeKeySSOSessionHandle = "ssoSessionHandle" + // RuntimeKeySSOSessionCleared is the ExecutorResponse EngineData signal the session sign-out node + // raises once it has terminated the session, telling the transport layer to clear the per-flow + // cookie. Like RuntimeKeySSOSessionHandle it rides the engine-only EngineData channel, keeping SSO + // concepts off the reusable engine contract. + RuntimeKeySSOSessionCleared = "ssoSessionCleared" ) // SSOCheckpointKey scopes a per-checkpoint SSO control key (RuntimeKeySSOSessionPresent, diff --git a/backend/internal/flow/executor/constants.go b/backend/internal/flow/executor/constants.go index 7fea2ddcab..0ff4dfadf6 100644 --- a/backend/internal/flow/executor/constants.go +++ b/backend/internal/flow/executor/constants.go @@ -48,6 +48,7 @@ const ( ExecutorNameFederatedAuthResolver = "FederatedAuthResolverExecutor" ExecutorNameSSOCheck = "SSOCheckExecutor" ExecutorNameSession = "SessionExecutor" + ExecutorNameSessionSignOut = "SessionSignOutExecutor" ExecutorNameOTPExecutor = "OTPExecutor" ) diff --git a/backend/internal/flow/executor/register.go b/backend/internal/flow/executor/register.go index 7c47750750..d5726d01f2 100644 --- a/backend/internal/flow/executor/register.go +++ b/backend/internal/flow/executor/register.go @@ -273,6 +273,10 @@ func newBuiltInExecutorRegistrars() map[string]builtInExecutorRegistrar { reg.RegisterExecutor(ExecutorNameSession, newSessionExecutor( deps.FlowFactory, deps.SessionService, deps.AuthnProvider)) }, + ExecutorNameSessionSignOut: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { + reg.RegisterExecutor(ExecutorNameSessionSignOut, newSessionSignOutExecutor( + deps.FlowFactory, deps.SessionService)) + }, ExecutorNameOTPExecutor: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { reg.RegisterExecutor(ExecutorNameOTPExecutor, newOTPExecutor( deps.FlowFactory, deps.OTPService, deps.AuthnProvider, deps.EntityProvider)) diff --git a/backend/internal/flow/executor/session_signout_executor.go b/backend/internal/flow/executor/session_signout_executor.go new file mode 100644 index 0000000000..10d0db9694 --- /dev/null +++ b/backend/internal/flow/executor/session_signout_executor.go @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// sessionSignOutExecutor is the task behind a session sign-out node. It ends the SSO session that the +// login flow established and signals the transport layer to clear that flow's per-flow cookie. The +// login flow whose session is targeted is resolved by the engine (SessionFlowID) and delivered +// through the SSO inputs, so this executor needs only the inbound handle and that flow id. It holds +// only the SSO session service, never the stores directly. +type sessionSignOutExecutor struct { + providers.Executor + sso session.Service + logger *log.Logger +} + +var _ providers.Executor = (*sessionSignOutExecutor)(nil) + +// newSessionSignOutExecutor creates a new session sign-out executor backed by the SSO session service. +func newSessionSignOutExecutor(flowFactory core.FlowFactoryInterface, sso session.Service) *sessionSignOutExecutor { + logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "SessionSignOutExecutor"), + log.String(log.LoggerKeyExecutorName, ExecutorNameSessionSignOut)) + + base := flowFactory.CreateExecutor(ExecutorNameSessionSignOut, providers.ExecutorTypeUtility, + []providers.Input{}, []providers.Input{}, &providers.ExecutorMeta{ + SupportedFlowTypes: []providers.FlowType{providers.FlowTypeSignOut}, + }) + + return &sessionSignOutExecutor{ + Executor: base, + sso: sso, + logger: logger, + } +} + +// Execute ends the SSO session referenced by the inbound handle for the login flow and raises the +// cookie-clear signal. Terminate is idempotent, so a missing or already-ended session is not an +// error; the cookie is cleared regardless so the browser drops any stale handle. It routes to the +// success outcome — sign-out completes even when there was nothing to end. +func (e *sessionSignOutExecutor) Execute(ctx *providers.NodeContext) (*providers.ExecutorResponse, error) { + logger := e.logger.With(log.String(log.LoggerKeyExecutionID, ctx.ExecutionID)) + + execResp := &providers.ExecutorResponse{ + Status: providers.ExecComplete, + RuntimeData: make(map[string]string), + EngineData: make(map[string]string), + } + + in := session.SSOInputsFrom(ctx.Context) + if _, err := e.sso.Terminate(ctx.Context, in.Handle, in.FlowID); err != nil { + return execResp, err + } + + // Signal the transport layer to clear the per-flow cookie. The engine resolves the flow id + // (the login flow) from the execution's SessionFlowID. The post-logout redirect is not the flow's + // concern — the OAuth layer resolves it on the sign-out completion callback. + execResp.EngineData[common.RuntimeKeySSOSessionCleared] = dataValueTrue + + logger.Debug(ctx.Context, "Terminated SSO session on sign-out", log.String("flowId", in.FlowID)) + return execResp, nil +} diff --git a/backend/internal/flow/executor/session_signout_executor_test.go b/backend/internal/flow/executor/session_signout_executor_test.go new file mode 100644 index 0000000000..57b77be077 --- /dev/null +++ b/backend/internal/flow/executor/session_signout_executor_test.go @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/cache" + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/flow/sessionmock" +) + +type SessionSignOutExecutorTestSuite struct { + suite.Suite +} + +func TestSessionSignOutExecutorTestSuite(t *testing.T) { + suite.Run(t, new(SessionSignOutExecutorTestSuite)) +} + +func (suite *SessionSignOutExecutorTestSuite) SetupTest() { + suite.Require().NoError(config.InitializeServerRuntime(suite.T().TempDir(), &config.Config{})) +} + +func (suite *SessionSignOutExecutorTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +func (suite *SessionSignOutExecutorTestSuite) newExecutor(sso session.Service) *sessionSignOutExecutor { + flowFactory, _ := core.Initialize(cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment")) + return newSessionSignOutExecutor(flowFactory, sso) +} + +// signOutNodeContext carries the login flow's inbound handle and flow id, as the engine delivers them +// through the SSO inputs for a sign-out flow. +func signOutNodeContext() *providers.NodeContext { + return &providers.NodeContext{ + Context: session.WithSSOInputs(context.Background(), session.SSOInputs{ + Handle: "handle-abc", + FlowID: "flow-1", + }), + ExecutionID: "exec-1", + } +} + +// TestTerminatesAndSignalsClear covers a live session: it is ended and the cookie-clear signal is +// raised on the engine-only channel. +func (suite *SessionSignOutExecutorTestSuite) TestTerminatesAndSignalsClear() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Terminate(mock.Anything, "handle-abc", "flow-1"). + Return(&session.Session{SessionID: "sess-1", State: session.StateEnded}, nil) + exec := suite.newExecutor(sso) + + resp, err := exec.Execute(signOutNodeContext()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Equal(dataValueTrue, resp.EngineData[common.RuntimeKeySSOSessionCleared]) +} + +// TestClearsWhenNoSession covers sign-out when no session backs the handle: Terminate is a no-op but +// the cookie is still cleared so the browser drops any stale handle. +func (suite *SessionSignOutExecutorTestSuite) TestClearsWhenNoSession() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Terminate(mock.Anything, "handle-abc", "flow-1").Return(nil, nil) + exec := suite.newExecutor(sso) + + resp, err := exec.Execute(signOutNodeContext()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Equal(dataValueTrue, resp.EngineData[common.RuntimeKeySSOSessionCleared]) +} + +// TestTerminateError covers a store failure during termination: the executor surfaces the error and +// does not raise the clear signal. +func (suite *SessionSignOutExecutorTestSuite) TestTerminateError() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Terminate(mock.Anything, "handle-abc", "flow-1").Return(nil, errors.New("store down")) + exec := suite.newExecutor(sso) + + resp, err := exec.Execute(signOutNodeContext()) + + suite.Require().Error(err) + suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionCleared]) +} diff --git a/backend/internal/flow/flowexec/engine.go b/backend/internal/flow/flowexec/engine.go index 53d819c45d..93de0cac60 100644 --- a/backend/internal/flow/flowexec/engine.go +++ b/backend/internal/flow/flowexec/engine.go @@ -805,6 +805,13 @@ func (fe *flowEngine) processNodeResponse(ctx *EngineContext, nodeResp *common.N flowStep.SSOFlowID = ssoFlowID(ctx) } + // Carry a session-termination signal onto the flow step so the transport layer clears the + // per-flow cookie. The session sign-out node raises it on the engine-only EngineData channel once + // it has ended the session. + if cleared := nodeResp.EngineData[common.RuntimeKeySSOSessionCleared]; cleared != "" { + flowStep.SSOClearFlowID = ssoFlowID(ctx) + } + switch nodeResp.Status { case common.NodeStatusComplete: if fe.isDisplayOnlyPromptNode(ctx.CurrentNode) { @@ -1627,7 +1634,16 @@ func processNodeResponseErrorForEventPublish(nodeResp *common.NodeResponse) map[ // ssoFlowID returns the current flow's ID (used as the SSO group key), or "" if no graph // is set on the context. func ssoFlowID(ctx *EngineContext) string { - if ctx == nil || ctx.Graph == nil { + if ctx == nil { + return "" + } + // A sign-out flow operates on a different flow's session than the one it runs; SessionFlowID + // carries that flow (the login flow) so the inbound cookie, SSO inputs, and cookie clear all + // resolve under it. Other flows fall back to the running flow's own id. + if ctx.SessionFlowID != "" { + return ctx.SessionFlowID + } + if ctx.Graph == nil { return "" } return ctx.Graph.GetID() diff --git a/backend/internal/flow/flowexec/error_constants.go b/backend/internal/flow/flowexec/error_constants.go index 92fd365e32..9b089ca4c5 100644 --- a/backend/internal/flow/flowexec/error_constants.go +++ b/backend/internal/flow/flowexec/error_constants.go @@ -239,3 +239,17 @@ var ErrorAttestationInvalid = tidcommon.ServiceError{ DefaultValue: "The provided attestation token is invalid", }, } + +// ErrorSignOutFlowDisabled defines the error response for sign-out flow disabled errors. +var ErrorSignOutFlowDisabled = tidcommon.ServiceError{ + Code: "FES-1016", + Type: tidcommon.ClientErrorType, + Error: tidcommon.I18nMessage{ + Key: "error.flowexecservice.signout_not_allowed", + DefaultValue: "Sign out not allowed", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.flowexecservice.signout_not_allowed_description", + DefaultValue: "Sign out flow is disabled for the application", + }, +} diff --git a/backend/internal/flow/flowexec/handler.go b/backend/internal/flow/flowexec/handler.go index 43148776fb..b5d23c798e 100644 --- a/backend/internal/flow/flowexec/handler.go +++ b/backend/internal/flow/flowexec/handler.go @@ -99,6 +99,11 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter, h.ssoHandleTTL) } + // Clear the per-flow SSO cookie when the flow terminated the session (sign-out). + if flowStep.SSOClearFlowID != "" { + h.ssoTransport.Clear(w, session.CookieName(flowStep.SSOClearFlowID)) + } + flowResp := FlowResponse{ ExecutionID: flowStep.ExecutionID, StepID: flowStep.StepID, diff --git a/backend/internal/flow/flowexec/model.go b/backend/internal/flow/flowexec/model.go index 51c646fbea..ee91de1f85 100644 --- a/backend/internal/flow/flowexec/model.go +++ b/backend/internal/flow/flowexec/model.go @@ -94,6 +94,12 @@ type EngineContext struct { // flow fetched when the context is loaded. Transient; used by the SSO-Check node to reject // sessions established at an incompatible flow version. SSOFlowVersion int + // SessionFlowID overrides the flow whose SSO session this execution operates on. It is set for + // sign-out flows, which run a different flow than the one that owns the session: the login (auth) + // flow id is carried here so the inbound cookie, SSO inputs, and cookie clear all resolve under + // that flow rather than the running sign-out flow. Empty for all other flows. Transient — re-derived + // from the application on each context load, never persisted. + SessionFlowID string } // mergeRuntimeData merges the given data into RuntimeData. @@ -228,6 +234,10 @@ type FlowStep struct { // the JSON response body. SSOHandleOut string SSOFlowID string + // SSOClearFlowID carries the flow id whose per-flow SSO cookie the transport layer must clear + // after this step terminated the session (sign-out). Empty when nothing was cleared. Not part of + // the JSON response body. + SSOClearFlowID string } // FlowData holds the data returned by a flow execution step diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go index 9b3fec323b..e43dd31e6d 100644 --- a/backend/internal/flow/flowexec/service.go +++ b/backend/internal/flow/flowexec/service.go @@ -187,7 +187,10 @@ func applyInboundSSO(engineCtx *EngineContext, ctx context.Context) { if !ok { return } - engineCtx.SSOHandleIn = inbound.HandleFor(engineCtx.Graph.GetID()) + // ssoFlowID resolves the flow whose session this execution operates on — the running flow for + // login/SSO, or the login flow (SessionFlowID) for a sign-out flow — so the correct per-flow cookie + // is selected. + engineCtx.SSOHandleIn = inbound.HandleFor(ssoFlowID(engineCtx)) } // initContext initializes a new flow context with the given details. @@ -213,8 +216,8 @@ func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr return engineCtx, nil } -// checkDirectFlowInitiationAllowed governs which applications may initiate an authentication flow -// directly over HTTP, based on how the application is classified: +// checkDirectFlowInitiationAllowed governs which applications may initiate an authentication or +// sign-out flow directly over HTTP, based on how the application is classified: // - RedirectOnly — the application signs users in through a redirect-based protocol component // (currently OAuth 2.0 authorization_code apps) and must have its flows initiated by that // component, not via a direct HTTP call. @@ -223,13 +226,15 @@ func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr // - Attestation — a mobile application that authenticates at flow initiation by presenting a valid // platform attestation (e.g. a Google Play Integrity token) proving its binary identity. // -// Other flow types (registration, recovery, user onboarding) are not restricted. The classification -// is derived from neutral actor data resolved through the actor layer; credential verification +// Sign-out is guarded like authentication so a native caller must prove its identity before ending a +// session; a redirect-based app is pushed to the RP-initiated /oauth2/logout endpoint instead. Other +// flow types (registration, recovery, user onboarding) are not restricted. The classification is +// derived from neutral actor data resolved through the actor layer; credential verification // (Flow Secret or attestation) is the only check that remains here. func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, appID string, flowType providers.FlowType, flowSecret, attestationToken string, logger *log.Logger) *tidcommon.ServiceError { - if flowType != providers.FlowTypeAuthentication { + if flowType != providers.FlowTypeAuthentication && flowType != providers.FlowTypeSignOut { return nil } if appID == "" { @@ -523,6 +528,13 @@ func (s *flowExecService) setApplicationToContext(engineCtx *EngineContext, return svcErr } engineCtx.Application = *app + + // A sign-out flow runs a different flow than the one that owns the SSO session. Carry the login + // (auth) flow id so the session is resolved, and its cookie cleared, under that flow rather than + // the running sign-out flow. Re-derived here on every context load, so it is never persisted. + if engineCtx.FlowType == providers.FlowTypeSignOut { + engineCtx.SessionFlowID = engineCtx.Application.AuthFlowID + } return nil } @@ -668,6 +680,17 @@ func (s *flowExecService) getFlowGraph(ctx context.Context, appID string, flowTy return client.RecoveryFlowID, nil } + if flowType == providers.FlowTypeSignOut { + if !client.IsSignOutFlowEnabled { + return "", &ErrorSignOutFlowDisabled + } else if client.SignOutFlowID == "" { + logger.Error(ctx, "Sign-out flow is not configured for the application", + log.String("appID", appID)) + return "", &tidcommon.InternalServerError + } + return client.SignOutFlowID, nil + } + // Default to authentication flow ID if client.AuthFlowID == "" { logger.Error(ctx, "Authentication flow is not configured for the entity", @@ -682,7 +705,7 @@ func (s *flowExecService) getFlowGraph(ctx context.Context, appID string, flowTy func validateFlowType(flowTypeStr string) (providers.FlowType, *tidcommon.ServiceError) { switch providers.FlowType(flowTypeStr) { case providers.FlowTypeAuthentication, providers.FlowTypeRegistration, providers.FlowTypeUserOnboarding, - providers.FlowTypeRecovery: + providers.FlowTypeRecovery, providers.FlowTypeSignOut: return providers.FlowType(flowTypeStr), nil default: return "", &ErrorInvalidFlowType diff --git a/backend/internal/flow/flowexec/service_test.go b/backend/internal/flow/flowexec/service_test.go index 218c130573..5b8b58d069 100644 --- a/backend/internal/flow/flowexec/service_test.go +++ b/backend/internal/flow/flowexec/service_test.go @@ -2376,15 +2376,21 @@ func (s *ServiceTestSuite) TestSetApplicationToContext_BuildApplicationError() { func (s *ServiceTestSuite) TestExecute_NewFlow_GuardRejections() { cases := []struct { name string + flowType providers.FlowType grantTypes []string flowSecret string wantCode string }{ - {"redirect-based app blocked", []string{"authorization_code"}, "", + {"redirect-based app blocked", providers.FlowTypeAuthentication, []string{"authorization_code"}, "", ErrorDirectFlowInitiationNotPermitted.Code}, - {"m2m app blocked", []string{"client_credentials"}, "", + {"m2m app blocked", providers.FlowTypeAuthentication, []string{"client_credentials"}, "", ErrorDirectFlowInitiationNotPermitted.Code}, - {"flow-native app without secret", + {"flow-native app without secret", providers.FlowTypeAuthentication, + []string{"client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"}, "", + ErrorFlowSecretRequired.Code}, + {"sign-out redirect-based app blocked", providers.FlowTypeSignOut, []string{"authorization_code"}, "", + ErrorDirectFlowInitiationNotPermitted.Code}, + {"sign-out flow-native app without secret", providers.FlowTypeSignOut, []string{"client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"}, "", ErrorFlowSecretRequired.Code}, } @@ -2411,7 +2417,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_GuardRejections() { } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", tc.flowSecret, "") + string(tc.flowType), false, "submit", map[string]string{}, "", tc.flowSecret, "") s.Nil(flowStep) s.NotNil(svcErr) diff --git a/backend/internal/flow/session/Service_mock_test.go b/backend/internal/flow/session/Service_mock_test.go index c233697202..b9d0cd8ff9 100644 --- a/backend/internal/flow/session/Service_mock_test.go +++ b/backend/internal/flow/session/Service_mock_test.go @@ -349,3 +349,77 @@ func (_c *ServiceMock_SaveCheckpoint_Call) RunAndReturn(run func(ctx context.Con _c.Call.Return(run) return _c } + +// Terminate provides a mock function for the type ServiceMock +func (_mock *ServiceMock) Terminate(ctx context.Context, handle string, flowID string) (*Session, error) { + ret := _mock.Called(ctx, handle, flowID) + + if len(ret) == 0 { + panic("no return value specified for Terminate") + } + + var r0 *Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*Session, error)); ok { + return returnFunc(ctx, handle, flowID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *Session); ok { + r0 = returnFunc(ctx, handle, flowID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, handle, flowID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_Terminate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Terminate' +type ServiceMock_Terminate_Call struct { + *mock.Call +} + +// Terminate is a helper method to define mock.On call +// - ctx context.Context +// - handle string +// - flowID string +func (_e *ServiceMock_Expecter) Terminate(ctx interface{}, handle interface{}, flowID interface{}) *ServiceMock_Terminate_Call { + return &ServiceMock_Terminate_Call{Call: _e.mock.On("Terminate", ctx, handle, flowID)} +} + +func (_c *ServiceMock_Terminate_Call) Run(run func(ctx context.Context, handle string, flowID string)) *ServiceMock_Terminate_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ServiceMock_Terminate_Call) Return(session *Session, err error) *ServiceMock_Terminate_Call { + _c.Call.Return(session, err) + return _c +} + +func (_c *ServiceMock_Terminate_Call) RunAndReturn(run func(ctx context.Context, handle string, flowID string) (*Session, error)) *ServiceMock_Terminate_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/session/interface.go b/backend/internal/flow/session/interface.go index 1484e611c4..575a222c93 100644 --- a/backend/internal/flow/session/interface.go +++ b/backend/internal/flow/session/interface.go @@ -44,6 +44,8 @@ type sessionStore interface { GetByCheckpoint(ctx context.Context, sessionID, checkpointID string) (*SessionContext, error) // Delete removes all of a session's checkpoint contexts. Delete(ctx context.Context, sessionID string) error + // DeleteSession removes the session row itself. + DeleteSession(ctx context.Context, sessionID string) error // ListCheckpointIDs returns the checkpoint ids a session has saved, without loading any context // payload — the existence check the SSO-Check node uses to decide checkpoint availability. ListCheckpointIDs(ctx context.Context, sessionID string) ([]string, error) diff --git a/backend/internal/flow/session/service.go b/backend/internal/flow/session/service.go index 27ec895c4f..d8da1f8d2a 100644 --- a/backend/internal/flow/session/service.go +++ b/backend/internal/flow/session/service.go @@ -59,6 +59,14 @@ type Service interface { // the session's last-active timestamp and idle deadline, and records the joining participant // (both best-effort). It errors when the session or its checkpoint context no longer exists. LoadCheckpoint(ctx context.Context, handle, checkpoint, appID string) (*Session, *SessionContext, error) + + // Terminate ends the session referenced by handle: it marks the session ENDED (so it can no + // longer back SSO) and removes its checkpoint contexts and participants, all in one transaction. + // When flowID is non-empty the handle must belong to that flow, guarding against ending a + // session grouped under a different flow. It is idempotent — a no-op returning (nil, nil) when + // no session matches the handle, and the unchanged session when it is already ended — and + // returns the ended session on success. + Terminate(ctx context.Context, handle, flowID string) (*Session, error) } // SaveCheckpointInput carries the data a Session join needs to persist. The caller resolves the @@ -210,6 +218,45 @@ func (s *service) LoadCheckpoint(ctx context.Context, handle, checkpoint, appID return sess, sc, nil } +// Terminate implements Service. +func (s *service) Terminate(ctx context.Context, handle, flowID string) (*Session, error) { + if handle == "" { + return nil, nil + } + sess, err := s.store.GetByHandle(ctx, handle) + if err != nil { + return nil, fmt.Errorf("failed to load session for termination: %w", err) + } + if sess == nil { + return nil, nil + } + // The handle must belong to the expected flow. A per-flow handle resolving to a session grouped + // under a different flow should never happen; surface it as an error rather than silently skipping. + if flowID != "" && sess.FlowID != flowID { + return nil, fmt.Errorf("session handle belongs to flow %q, expected %q", sess.FlowID, flowID) + } + // Hard-delete the session and its derived state (checkpoint contexts and participants) in one + // transaction. Sign-out ends SSO reuse outright and nothing references the session afterwards, so the + // row is removed rather than tombstoned. DeleteSession removes the session row (SSO_SESSION), Delete + // its checkpoint contexts (SSO_SESSION_CONTEXT), and DeleteBySessionID its participants + // (SSO_SESSION_PARTICIPANT). Repeated calls are idempotent: once the row is gone, GetByHandle + // returns nil above. + if txErr := s.transactioner.Transact(ctx, func(txCtx context.Context) error { + if delErr := s.store.DeleteSession(txCtx, sess.SessionID); delErr != nil { + return delErr + } + if delErr := s.store.Delete(txCtx, sess.SessionID); delErr != nil { + return delErr + } + return s.store.DeleteBySessionID(txCtx, sess.SessionID) + }); txErr != nil { + return nil, fmt.Errorf("failed to terminate session: %w", txErr) + } + + s.logger.Debug(ctx, "Terminated SSO session", log.String("flowId", sess.FlowID)) + return sess, nil +} + // targetSession returns the session this execution's checkpoints attach to, establishing one when // none exists yet. The bool reports whether this call minted the session. It returns (nil, false, // nil) when an existing session belongs to a different subject than the one just authenticated, so diff --git a/backend/internal/flow/session/service_test.go b/backend/internal/flow/session/service_test.go index 702153c56b..3d5deda229 100644 --- a/backend/internal/flow/session/service_test.go +++ b/backend/internal/flow/session/service_test.go @@ -349,3 +349,64 @@ func (suite *ServiceTestSuite) TestLoadCheckpoint_ParticipantErrorIsNonFatal() { suite.NotNil(sess) suite.NotNil(sc) } + +// --- Terminate --- + +func (suite *ServiceTestSuite) TestTerminate_DeletesSessionAndPurges() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(liveStoreSession(), nil) + runTx(m) + m.store.EXPECT().DeleteSession(mock.Anything, "sess-1").Return(nil) + m.store.EXPECT().Delete(mock.Anything, "sess-1").Return(nil) + m.store.EXPECT().DeleteBySessionID(mock.Anything, "sess-1").Return(nil) + + got, err := svc.Terminate(context.Background(), "handle-abc", "flow-1") + + suite.Require().NoError(err) + suite.Require().NotNil(got) + suite.Equal("sess-1", got.SessionID, "the terminated session is returned") +} + +func (suite *ServiceTestSuite) TestTerminate_NoHandle() { + svc, _ := suite.newService() + + got, err := svc.Terminate(context.Background(), "", "flow-1") + + suite.Require().NoError(err) + suite.Nil(got) +} + +func (suite *ServiceTestSuite) TestTerminate_MissingSessionIsNoOp() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(nil, nil) + + got, err := svc.Terminate(context.Background(), "handle-abc", "flow-1") + + suite.Require().NoError(err, "terminating an absent session must be an idempotent no-op") + suite.Nil(got) +} + +func (suite *ServiceTestSuite) TestTerminate_DifferentFlowErrors() { + svc, m := suite.newService() + s := liveStoreSession() + s.FlowID = "other-flow" + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(s, nil) + + got, err := svc.Terminate(context.Background(), "handle-abc", "flow-1") + + suite.Require().Error(err, "a handle grouped under a different flow must be an error") + suite.Contains(err.Error(), "belongs to flow") + suite.Nil(got) +} + +func (suite *ServiceTestSuite) TestTerminate_DeleteError() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(liveStoreSession(), nil) + runTx(m) + m.store.EXPECT().DeleteSession(mock.Anything, mock.Anything).Return(errors.New("store down")) + + _, err := svc.Terminate(context.Background(), "handle-abc", "flow-1") + + suite.Require().Error(err) + suite.Contains(err.Error(), "failed to terminate session") +} diff --git a/backend/internal/flow/session/sessionStore_mock_test.go b/backend/internal/flow/session/sessionStore_mock_test.go index 0816c980e6..9ab5cff40a 100644 --- a/backend/internal/flow/session/sessionStore_mock_test.go +++ b/backend/internal/flow/session/sessionStore_mock_test.go @@ -265,6 +265,63 @@ func (_c *sessionStoreMock_DeleteBySessionID_Call) RunAndReturn(run func(ctx con return _c } +// DeleteSession provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) DeleteSession(ctx context.Context, sessionID string) error { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for DeleteSession") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, sessionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_DeleteSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSession' +type sessionStoreMock_DeleteSession_Call struct { + *mock.Call +} + +// DeleteSession is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *sessionStoreMock_Expecter) DeleteSession(ctx interface{}, sessionID interface{}) *sessionStoreMock_DeleteSession_Call { + return &sessionStoreMock_DeleteSession_Call{Call: _e.mock.On("DeleteSession", ctx, sessionID)} +} + +func (_c *sessionStoreMock_DeleteSession_Call) Run(run func(ctx context.Context, sessionID string)) *sessionStoreMock_DeleteSession_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_DeleteSession_Call) Return(err error) *sessionStoreMock_DeleteSession_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_DeleteSession_Call) RunAndReturn(run func(ctx context.Context, sessionID string) error) *sessionStoreMock_DeleteSession_Call { + _c.Call.Return(run) + return _c +} + // GetByCheckpoint provides a mock function for the type sessionStoreMock func (_mock *sessionStoreMock) GetByCheckpoint(ctx context.Context, sessionID string, checkpointID string) (*SessionContext, error) { ret := _mock.Called(ctx, sessionID, checkpointID) diff --git a/backend/internal/flow/session/store.go b/backend/internal/flow/session/store.go index 419b03df07..5e9044561b 100644 --- a/backend/internal/flow/session/store.go +++ b/backend/internal/flow/session/store.go @@ -208,6 +208,17 @@ func (st *store) Delete(ctx context.Context, sessionID string) error { }) } +// DeleteSession removes the session row itself. +func (st *store) DeleteSession(ctx context.Context, sessionID string) error { + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + _, err := dbClient.ExecuteContext(ctx, queryDeleteSession, sessionID, st.deploymentID) + if err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + return nil + }) +} + // buildSessionContextFromRow parses a result row into an SessionContext. func (st *store) buildSessionContextFromRow(row map[string]interface{}) (*SessionContext, error) { sessionID, err := parseString(row["session_id"], "session_id") diff --git a/backend/internal/flow/session/store_constants.go b/backend/internal/flow/session/store_constants.go index 15af1b0ad1..bc11185bc8 100644 --- a/backend/internal/flow/session/store_constants.go +++ b/backend/internal/flow/session/store_constants.go @@ -122,4 +122,10 @@ var ( ID: "SSO-SESS-11", Query: `DELETE FROM "SSO_SESSION_PARTICIPANT" WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2`, } + + // queryDeleteSession removes the session row itself. + queryDeleteSession = model.DBQuery{ + ID: "SSO-SESS-12", + Query: `DELETE FROM "SSO_SESSION" WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2`, + } ) diff --git a/backend/internal/inboundclient/error_constants.go b/backend/internal/inboundclient/error_constants.go index 1f990b3a20..7641215148 100644 --- a/backend/internal/inboundclient/error_constants.go +++ b/backend/internal/inboundclient/error_constants.go @@ -51,6 +51,8 @@ var ( ErrFKInvalidRegistrationFlow = errors.New("invalid registration flow ID") // ErrFKInvalidRecoveryFlow is returned when the recovery flow ID does not exist. ErrFKInvalidRecoveryFlow = errors.New("invalid recovery flow ID") + // ErrFKInvalidSignOutFlow is returned when the sign-out flow ID does not exist. + ErrFKInvalidSignOutFlow = errors.New("invalid sign-out flow ID") // ErrFKFlowDefinitionRetrievalFailed is returned when a flow definition cannot be retrieved. ErrFKFlowDefinitionRetrievalFailed = errors.New("error retrieving flow definition") // ErrFKFlowServerError is returned when a server error occurs while resolving a flow. diff --git a/backend/internal/inboundclient/model/oauth.go b/backend/internal/inboundclient/model/oauth.go index bb2f260c95..08c0c95155 100644 --- a/backend/internal/inboundclient/model/oauth.go +++ b/backend/internal/inboundclient/model/oauth.go @@ -45,6 +45,7 @@ var ( type OAuthConfig struct { ClientID string `json:"clientId,omitempty" yaml:"clientId,omitempty"` RedirectURIs []string `json:"redirectUris,omitempty" yaml:"redirectUris,omitempty"` + PostLogoutRedirectURIs []string `json:"postLogoutRedirectUris,omitempty" yaml:"postLogoutRedirectUris,omitempty"` GrantTypes []providers.GrantType `json:"grantTypes,omitempty" yaml:"grantTypes,omitempty"` ResponseTypes []providers.ResponseType `json:"responseTypes,omitempty" yaml:"responseTypes,omitempty"` TokenEndpointAuthMethod providers.TokenEndpointAuthMethod `json:"tokenEndpointAuthMethod,omitempty" yaml:"tokenEndpointAuthMethod,omitempty"` diff --git a/backend/internal/inboundclient/service.go b/backend/internal/inboundclient/service.go index cd833bf329..94aa17a962 100644 --- a/backend/internal/inboundclient/service.go +++ b/backend/internal/inboundclient/service.go @@ -322,6 +322,13 @@ func (s *inboundClientService) ResolveInboundAuthProfileHandles( } profile.RecoveryFlowID = flow.ID } + if profile.SignOutFlowID == "" && profile.SignOutFlowHandle != "" { + flow, svcErr := s.flowMgt.GetFlowByHandle(ctx, profile.SignOutFlowHandle, providers.FlowTypeSignOut) + if svcErr != nil { + return ErrFKInvalidSignOutFlow + } + profile.SignOutFlowID = flow.ID + } return nil } @@ -530,6 +537,7 @@ func BuildOAuthClient( ClientID: clientID, EntityCategory: entityCategory, RedirectURIs: p.RedirectURIs, + PostLogoutRedirectURIs: p.PostLogoutRedirectURIs, TokenEndpointAuthMethod: providers.TokenEndpointAuthMethod(p.TokenEndpointAuthMethod), PKCERequired: p.PKCERequired, PublicClient: p.PublicClient, @@ -590,6 +598,10 @@ func (s *inboundClientService) resolveFlowDefaults(ctx context.Context, c *inbou // If a recovery flow is not defined, disable recovery flow for the application. c.IsRecoveryFlowEnabled = false } + if c.SignOutFlowID == "" { + // If a sign-out flow is not defined, disable sign-out for the application. + c.IsSignOutFlowEnabled = false + } return nil } @@ -1039,6 +1051,9 @@ func (s *inboundClientService) validateFKs(ctx context.Context, c *inboundmodel. if err := s.validateRecoveryFlowID(ctx, c.RecoveryFlowID); err != nil { return err } + if err := s.validateSignOutFlowID(ctx, c.SignOutFlowID); err != nil { + return err + } if err := s.validateThemeID(ctx, c.ThemeID); err != nil { return err } @@ -1096,6 +1111,21 @@ func (s *inboundClientService) validateRecoveryFlowID(ctx context.Context, flowI return nil } +// validateSignOutFlowID validates that the sign-out flow ID exists and is of the correct type. +func (s *inboundClientService) validateSignOutFlowID(ctx context.Context, flowID string) error { + if flowID == "" || s.flowMgt == nil { + return nil + } + valid, svcErr := s.flowMgt.IsValidFlow(ctx, flowID, providers.FlowTypeSignOut) + if svcErr != nil { + return ErrFKFlowServerError + } + if !valid { + return ErrFKInvalidSignOutFlow + } + return nil +} + // validateThemeID validates that the theme ID exists. func (s *inboundClientService) validateThemeID(ctx context.Context, themeID string) error { if themeID == "" || s.themeMgt == nil { diff --git a/backend/internal/inboundclient/store.go b/backend/internal/inboundclient/store.go index d4501f78cd..693c5f54e6 100644 --- a/backend/internal/inboundclient/store.go +++ b/backend/internal/inboundclient/store.go @@ -107,7 +107,8 @@ func marshalInboundClient(c inboundmodel.InboundClient) ( propertiesBytes interface{}, isRegistrationEnabledStr string, isRecoveryEnabledStr string, - recoveryFlowID, registrationFlowID, themeID, layoutID interface{}, + isSignOutEnabledStr string, + recoveryFlowID, signOutFlowID, registrationFlowID, themeID, layoutID interface{}, err error, ) { blob := inboundClientJSONBlob{ @@ -119,15 +120,19 @@ func marshalInboundClient(c inboundmodel.InboundClient) ( } propertiesBytes, err = marshalNullableJSON(blob) if err != nil { - return nil, "", "", nil, nil, nil, nil, fmt.Errorf("failed to marshal properties: %w", err) + return nil, "", "", "", nil, nil, nil, nil, nil, fmt.Errorf("failed to marshal properties: %w", err) } isRegistrationEnabledStr = utils.BoolToNumString(c.IsRegistrationFlowEnabled) isRecoveryEnabledStr = utils.BoolToNumString(c.IsRecoveryFlowEnabled) + isSignOutEnabledStr = utils.BoolToNumString(c.IsSignOutFlowEnabled) if c.RecoveryFlowID != "" { recoveryFlowID = c.RecoveryFlowID } + if c.SignOutFlowID != "" { + signOutFlowID = c.SignOutFlowID + } if c.RegistrationFlowID != "" { registrationFlowID = c.RegistrationFlowID } @@ -138,8 +143,8 @@ func marshalInboundClient(c inboundmodel.InboundClient) ( layoutID = c.LayoutID } - return propertiesBytes, isRegistrationEnabledStr, isRecoveryEnabledStr, recoveryFlowID, - registrationFlowID, themeID, layoutID, nil + return propertiesBytes, isRegistrationEnabledStr, isRecoveryEnabledStr, isSignOutEnabledStr, + recoveryFlowID, signOutFlowID, registrationFlowID, themeID, layoutID, nil } // CreateInboundClient creates a new inbound client entry. @@ -149,15 +154,16 @@ func (st *store) CreateInboundClient(ctx context.Context, client inboundmodel.In return fmt.Errorf("failed to get database client: %w", err) } - propsBytes, isRegEnabledStr, isRecoveryEnabledStr, recoveryFlowID, - registrationFlowID, themeID, layoutID, marshalErr := marshalInboundClient(client) + propsBytes, isRegEnabledStr, isRecoveryEnabledStr, isSignOutEnabledStr, recoveryFlowID, + signOutFlowID, registrationFlowID, themeID, layoutID, marshalErr := marshalInboundClient(client) if marshalErr != nil { return marshalErr } _, err = dbClient.ExecuteContext(ctx, queryCreateInboundClient, client.ID, client.AuthFlowID, registrationFlowID, isRegEnabledStr, - recoveryFlowID, isRecoveryEnabledStr, themeID, layoutID, propsBytes, st.deploymentID) + recoveryFlowID, isRecoveryEnabledStr, signOutFlowID, isSignOutEnabledStr, + themeID, layoutID, propsBytes, st.deploymentID) if err != nil { return fmt.Errorf("failed to insert inbound client: %w", err) } @@ -298,7 +304,7 @@ func referenceQueries(refType, refID, deploymentID string) ( []interface{}{refID, deploymentID}, true case resourcedependency.ResourceTypeFlow: return queryGetEntityIDsByFlowIDCount, queryGetEntityIDsByFlowID, - []interface{}{refID, refID, refID, deploymentID}, true + []interface{}{refID, refID, refID, refID, deploymentID}, true default: return dbmodel.DBQuery{}, dbmodel.DBQuery{}, nil, false } @@ -313,7 +319,8 @@ func clientReferences(c *inboundmodel.InboundClient, refType, refID string) bool case resourcedependency.ResourceTypeLayout: return c.LayoutID == refID case resourcedependency.ResourceTypeFlow: - return c.AuthFlowID == refID || c.RegistrationFlowID == refID || c.RecoveryFlowID == refID + return c.AuthFlowID == refID || c.RegistrationFlowID == refID || c.RecoveryFlowID == refID || + c.SignOutFlowID == refID default: return false } @@ -347,15 +354,16 @@ func (st *store) UpdateInboundClient(ctx context.Context, client inboundmodel.In return fmt.Errorf("failed to get database client: %w", err) } - propsBytes, isRegEnabledStr, isRecoveryEnabledStr, recoveryFlowID, - registrationFlowID, themeID, layoutID, marshalErr := marshalInboundClient(client) + propsBytes, isRegEnabledStr, isRecoveryEnabledStr, isSignOutEnabledStr, recoveryFlowID, + signOutFlowID, registrationFlowID, themeID, layoutID, marshalErr := marshalInboundClient(client) if marshalErr != nil { return marshalErr } rowsAffected, err := dbClient.ExecuteContext(ctx, queryUpdateInboundClientByEntityID, client.ID, client.AuthFlowID, registrationFlowID, isRegEnabledStr, - recoveryFlowID, isRecoveryEnabledStr, themeID, layoutID, propsBytes, st.deploymentID) + recoveryFlowID, isRecoveryEnabledStr, signOutFlowID, isSignOutEnabledStr, + themeID, layoutID, propsBytes, st.deploymentID) if err != nil { return fmt.Errorf("failed to update inbound client: %w", err) } @@ -463,6 +471,7 @@ func buildInboundClientFromRow(ctx context.Context, row map[string]interface{}) authFlowID := parseStringColumn(row, "auth_flow_id") regFlowID := parseStringColumn(row, "registration_flow_id") recoveryFlowID := parseStringColumn(row, "recovery_flow_id") + signOutFlowID := parseStringColumn(row, "signout_flow_id") themeID := parseStringColumn(row, "theme_id") layoutID := parseStringColumn(row, "layout_id") @@ -476,6 +485,11 @@ func buildInboundClientFromRow(ctx context.Context, row map[string]interface{}) isRecoveryFlowEnabled = utils.NumStringToBool(val) } + isSignOutFlowEnabled := false + if val := parseStringOrBytesColumn(row, "is_signout_flow_enabled"); val != "" { + isSignOutFlowEnabled = utils.NumStringToBool(val) + } + client := &inboundmodel.InboundClient{ ID: entityID, AuthFlowID: authFlowID, @@ -483,6 +497,8 @@ func buildInboundClientFromRow(ctx context.Context, row map[string]interface{}) IsRegistrationFlowEnabled: isRegistrationFlowEnabled, RecoveryFlowID: recoveryFlowID, IsRecoveryFlowEnabled: isRecoveryFlowEnabled, + SignOutFlowID: signOutFlowID, + IsSignOutFlowEnabled: isSignOutFlowEnabled, ThemeID: themeID, LayoutID: layoutID, } diff --git a/backend/internal/inboundclient/store_constants.go b/backend/internal/inboundclient/store_constants.go index 00256e3fa4..7fe7501a34 100644 --- a/backend/internal/inboundclient/store_constants.go +++ b/backend/internal/inboundclient/store_constants.go @@ -26,8 +26,9 @@ var ( ID: "ASQ-INBC_MGT-01", Query: `INSERT INTO "INBOUND_CLIENT" (ENTITY_ID, AUTH_FLOW_ID, REGISTRATION_FLOW_ID, ` + `IS_REGISTRATION_FLOW_ENABLED, RECOVERY_FLOW_ID, IS_RECOVERY_FLOW_ENABLED, ` + + `SIGNOUT_FLOW_ID, IS_SIGNOUT_FLOW_ENABLED, ` + `THEME_ID, LAYOUT_ID, PROPERTIES, DEPLOYMENT_ID) ` + - `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, } // queryCreateOAuthProfile creates a new OAuth inbound profile entry keyed by entity ID. queryCreateOAuthProfile = dbmodel.DBQuery{ @@ -39,6 +40,7 @@ var ( ID: "ASQ-INBC_MGT-03", Query: `SELECT app.ENTITY_ID, app.AUTH_FLOW_ID, app.REGISTRATION_FLOW_ID, ` + `app.IS_REGISTRATION_FLOW_ENABLED, app.RECOVERY_FLOW_ID, app.IS_RECOVERY_FLOW_ENABLED, ` + + `app.SIGNOUT_FLOW_ID, app.IS_SIGNOUT_FLOW_ENABLED, ` + `app.THEME_ID, app.LAYOUT_ID, app.PROPERTIES ` + `FROM "INBOUND_CLIENT" app WHERE app.ENTITY_ID = $1 AND app.DEPLOYMENT_ID = $2`, } @@ -53,6 +55,7 @@ var ( ID: "ASQ-INBC_MGT-06", Query: `SELECT app.ENTITY_ID, app.AUTH_FLOW_ID, app.REGISTRATION_FLOW_ID, ` + `app.IS_REGISTRATION_FLOW_ENABLED, app.RECOVERY_FLOW_ID, app.IS_RECOVERY_FLOW_ENABLED, ` + + `app.SIGNOUT_FLOW_ID, app.IS_SIGNOUT_FLOW_ENABLED, ` + `app.THEME_ID, app.LAYOUT_ID, app.PROPERTIES ` + `FROM "INBOUND_CLIENT" app WHERE app.DEPLOYMENT_ID = $1 LIMIT $2`, } @@ -61,8 +64,9 @@ var ( ID: "ASQ-INBC_MGT-07", Query: `UPDATE "INBOUND_CLIENT" SET AUTH_FLOW_ID=$2, REGISTRATION_FLOW_ID=$3, ` + `IS_REGISTRATION_FLOW_ENABLED=$4, RECOVERY_FLOW_ID=$5, IS_RECOVERY_FLOW_ENABLED=$6, ` + - `THEME_ID=$7, LAYOUT_ID=$8, PROPERTIES=$9 ` + - `WHERE ENTITY_ID = $1 AND DEPLOYMENT_ID = $10`, + `SIGNOUT_FLOW_ID=$7, IS_SIGNOUT_FLOW_ENABLED=$8, ` + + `THEME_ID=$9, LAYOUT_ID=$10, PROPERTIES=$11 ` + + `WHERE ENTITY_ID = $1 AND DEPLOYMENT_ID = $12`, } // queryUpdateOAuthProfileByEntityID updates an OAuth inbound profile by entity ID. queryUpdateOAuthProfileByEntityID = dbmodel.DBQuery{ @@ -117,18 +121,19 @@ var ( } // queryGetEntityIDsByFlowID retrieves paginated entity IDs for inbound clients referencing a specific - // flow through any of the authentication, registration, or recovery flow slots. + // flow through any of the authentication, registration, recovery, or sign-out flow slots. queryGetEntityIDsByFlowID = dbmodel.DBQuery{ ID: "ASQ-INBC_MGT-15", Query: `SELECT ENTITY_ID FROM "INBOUND_CLIENT" WHERE ` + - `(AUTH_FLOW_ID = $1 OR REGISTRATION_FLOW_ID = $2 OR RECOVERY_FLOW_ID = $3) AND DEPLOYMENT_ID = $4 ` + - `ORDER BY ENTITY_ID ASC LIMIT $5 OFFSET $6`, + `(AUTH_FLOW_ID = $1 OR REGISTRATION_FLOW_ID = $2 OR RECOVERY_FLOW_ID = $3 OR SIGNOUT_FLOW_ID = $4) ` + + `AND DEPLOYMENT_ID = $5 ORDER BY ENTITY_ID ASC LIMIT $6 OFFSET $7`, } // queryGetEntityIDsByFlowIDCount retrieves the total count of inbound clients referencing a specific flow. queryGetEntityIDsByFlowIDCount = dbmodel.DBQuery{ ID: "ASQ-INBC_MGT-16", Query: `SELECT COUNT(*) as total FROM "INBOUND_CLIENT" WHERE ` + - `(AUTH_FLOW_ID = $1 OR REGISTRATION_FLOW_ID = $2 OR RECOVERY_FLOW_ID = $3) AND DEPLOYMENT_ID = $4`, + `(AUTH_FLOW_ID = $1 OR REGISTRATION_FLOW_ID = $2 OR RECOVERY_FLOW_ID = $3 OR SIGNOUT_FLOW_ID = $4) ` + + `AND DEPLOYMENT_ID = $5`, } ) diff --git a/backend/internal/inboundclient/store_test.go b/backend/internal/inboundclient/store_test.go index 476eea8c2c..a6bd42007a 100644 --- a/backend/internal/inboundclient/store_test.go +++ b/backend/internal/inboundclient/store_test.go @@ -543,7 +543,7 @@ func (suite *InboundClientStoreTestSuite) TestCreateProfile() { suite.mockDBClient.On("ExecuteContext", mock.Anything, queryCreateInboundClient, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(int64(1), nil).Once() err := suite.store.CreateInboundClient(context.Background(), client) @@ -578,7 +578,7 @@ func (suite *InboundClientStoreTestSuite) TestUpdateProfile() { suite.mockDBClient.On("ExecuteContext", mock.Anything, queryUpdateInboundClientByEntityID, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(int64(1), nil).Once() err := suite.store.UpdateInboundClient(context.Background(), client) @@ -815,10 +815,10 @@ func (suite *InboundClientStoreTestSuite) TestGetEntityIDsByReference_Flow() { suite.Run("uses flow queries and repeats the id across slots", func() { suite.mockDBProvider.On("GetConfigDBClient").Return(suite.mockDBClient, nil).Once() suite.mockDBClient.On("QueryContext", mock.Anything, queryGetEntityIDsByFlowIDCount, - "flow-1", "flow-1", "flow-1", testServerID). + "flow-1", "flow-1", "flow-1", "flow-1", testServerID). Return([]map[string]interface{}{{"total": int64(2)}}, nil).Once() suite.mockDBClient.On("QueryContext", mock.Anything, queryGetEntityIDsByFlowID, - "flow-1", "flow-1", "flow-1", testServerID, 10, 0). + "flow-1", "flow-1", "flow-1", "flow-1", testServerID, 10, 0). Return([]map[string]interface{}{ {"entity_id": "app-1"}, {"entity_id": "app-2"}, diff --git a/backend/internal/oauth/init.go b/backend/internal/oauth/init.go index 0279dcd024..cef6f67d8b 100644 --- a/backend/internal/oauth/init.go +++ b/backend/internal/oauth/init.go @@ -34,6 +34,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/granthandlers" "github.com/thunder-id/thunderid/internal/oauth/oauth2/introspect" "github.com/thunder-id/thunderid/internal/oauth/oauth2/jwksresolver" + oauth2logout "github.com/thunder-id/thunderid/internal/oauth/oauth2/logout" "github.com/thunder-id/thunderid/internal/oauth/oauth2/par" "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/oauth/oauth2/token" @@ -66,6 +67,7 @@ func Initialize( i18nService providers.I18nProvider, idpService providers.IDPProvider, dpopVerifier dpop.VerifierInterface, + runtimeStore providers.RuntimeStoreProvider, cfg oauthconfig.Config, ) error { jwks.Initialize(mux, runtimeCrypto) @@ -101,5 +103,6 @@ func Initialize( tokenValidator, actorProvider, attributeCacheSvc, discoveryService, dpopVerifier, cfg) callback.Initialize(mux, oauth2AuthzService, cibaService, cfg) + oauth2logout.Initialize(mux, jwtService, actorProvider, flowExecService, runtimeStore, cfg) return nil } diff --git a/backend/internal/oauth/oauth2/constants/constants.go b/backend/internal/oauth/oauth2/constants/constants.go index 4dc99d3381..e045931e23 100644 --- a/backend/internal/oauth/oauth2/constants/constants.go +++ b/backend/internal/oauth/oauth2/constants/constants.go @@ -67,6 +67,7 @@ const ( RequestParamDPoPJkt string = "dpop_jkt" RequestParamLoginHint string = "login_hint" RequestParamIDTokenHint string = "id_token_hint" + RequestParamPostLogoutRedirect string = "post_logout_redirect_uri" RequestParamLoginHintToken string = "login_hint_token" // #nosec G101 RequestParamBindingMessage string = "binding_message" RequestParamRequestedExpiry string = "requested_expiry" @@ -246,6 +247,7 @@ const ( ClaimSub string = "sub" ClaimIss string = "iss" ClaimAud string = "aud" + ClaimAzp string = "azp" ClaimExp string = "exp" ClaimIat string = "iat" ClaimJTI string = "jti" diff --git a/backend/internal/oauth/oauth2/dcr/model.go b/backend/internal/oauth/oauth2/dcr/model.go index 2a49310578..31d0ee5a5a 100644 --- a/backend/internal/oauth/oauth2/dcr/model.go +++ b/backend/internal/oauth/oauth2/dcr/model.go @@ -36,6 +36,7 @@ const ( type DCRRegistrationRequest struct { OUID string `json:"ou_id,omitempty"` RedirectURIs []string `json:"redirect_uris"` + PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"` GrantTypes []providers.GrantType `json:"grant_types,omitempty"` ResponseTypes []providers.ResponseType `json:"response_types,omitempty"` ClientName string `json:"client_name,omitempty"` @@ -132,6 +133,7 @@ type DCRRegistrationResponse struct { ClientSecret string `json:"client_secret,omitempty"` ClientSecretExpiresAt int64 `json:"client_secret_expires_at"` RedirectURIs []string `json:"redirect_uris,omitempty"` + PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"` GrantTypes []providers.GrantType `json:"grant_types,omitempty"` ResponseTypes []providers.ResponseType `json:"response_types,omitempty"` ClientName string `json:"client_name,omitempty"` diff --git a/backend/internal/oauth/oauth2/dcr/service.go b/backend/internal/oauth/oauth2/dcr/service.go index e50d14c330..7201a96f4e 100644 --- a/backend/internal/oauth/oauth2/dcr/service.go +++ b/backend/internal/oauth/oauth2/dcr/service.go @@ -243,6 +243,7 @@ func (ds *dcrService) convertDCRToApplication(request *DCRRegistrationRequest) ( oauthAppConfig := &providers.OAuthConfigWithSecret{ ClientID: clientID, RedirectURIs: request.RedirectURIs, + PostLogoutRedirectURIs: request.PostLogoutRedirectURIs, GrantTypes: request.GrantTypes, ResponseTypes: request.ResponseTypes, TokenEndpointAuthMethod: request.TokenEndpointAuthMethod, @@ -374,6 +375,7 @@ func (ds *dcrService) convertApplicationToDCRResponse(appDTO *model.ApplicationD ClientSecret: oauthConfig.ClientSecret, ClientSecretExpiresAt: ClientSecretExpiresAtNever, RedirectURIs: oauthConfig.RedirectURIs, + PostLogoutRedirectURIs: oauthConfig.PostLogoutRedirectURIs, GrantTypes: oauthConfig.GrantTypes, ResponseTypes: oauthConfig.ResponseTypes, ClientName: clientName, diff --git a/backend/internal/oauth/oauth2/discovery/service.go b/backend/internal/oauth/oauth2/discovery/service.go index 5c5c234469..bc779beb6e 100644 --- a/backend/internal/oauth/oauth2/discovery/service.go +++ b/backend/internal/oauth/oauth2/discovery/service.go @@ -103,10 +103,15 @@ func (ds *discoveryService) GetOIDCMetadata(ctx context.Context) (*OIDCProviderM IDTokenEncryptionEncValuesSupported: inboundmodel.SupportedIDTokenEncryptionEncs, ClaimsSupported: ds.getSupportedClaims(), ClaimsParameterSupported: true, + EndSessionEndpoint: ds.getEndSessionEndpoint(), AcrValuesSupported: ds.getSupportedAcrValues(), }, nil } +func (ds *discoveryService) getEndSessionEndpoint() string { + return ds.cfg.BaseURL + constants.OAuth2LogoutEndpoint +} + func (ds *discoveryService) getIssuer() string { return ds.cfg.JWT.Issuer } diff --git a/backend/internal/oauth/oauth2/logout/RuntimeStoreProvider_mock_test.go b/backend/internal/oauth/oauth2/logout/RuntimeStoreProvider_mock_test.go new file mode 100644 index 0000000000..089739a2da --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/RuntimeStoreProvider_mock_test.go @@ -0,0 +1,463 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package logout + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// NewRuntimeStoreProviderMock creates a new instance of RuntimeStoreProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRuntimeStoreProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *RuntimeStoreProviderMock { + mock := &RuntimeStoreProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// RuntimeStoreProviderMock is an autogenerated mock type for the RuntimeStoreProvider type +type RuntimeStoreProviderMock struct { + mock.Mock +} + +type RuntimeStoreProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *RuntimeStoreProviderMock) EXPECT() *RuntimeStoreProviderMock_Expecter { + return &RuntimeStoreProviderMock_Expecter{mock: &_m.Mock} +} + +// Delete provides a mock function for the type RuntimeStoreProviderMock +func (_mock *RuntimeStoreProviderMock) Delete(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string) error { + ret := _mock.Called(ctx, namespace, key) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string) error); ok { + r0 = returnFunc(ctx, namespace, key) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// RuntimeStoreProviderMock_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type RuntimeStoreProviderMock_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - namespace providers.RuntimeStoreNamespace +// - key string +func (_e *RuntimeStoreProviderMock_Expecter) Delete(ctx interface{}, namespace interface{}, key interface{}) *RuntimeStoreProviderMock_Delete_Call { + return &RuntimeStoreProviderMock_Delete_Call{Call: _e.mock.On("Delete", ctx, namespace, key)} +} + +func (_c *RuntimeStoreProviderMock_Delete_Call) Run(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string)) *RuntimeStoreProviderMock_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.RuntimeStoreNamespace + if args[1] != nil { + arg1 = args[1].(providers.RuntimeStoreNamespace) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *RuntimeStoreProviderMock_Delete_Call) Return(err error) *RuntimeStoreProviderMock_Delete_Call { + _c.Call.Return(err) + return _c +} + +func (_c *RuntimeStoreProviderMock_Delete_Call) RunAndReturn(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string) error) *RuntimeStoreProviderMock_Delete_Call { + _c.Call.Return(run) + return _c +} + +// ExtendTTL provides a mock function for the type RuntimeStoreProviderMock +func (_mock *RuntimeStoreProviderMock) ExtendTTL(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, ttlSeconds int64) error { + ret := _mock.Called(ctx, namespace, key, ttlSeconds) + + if len(ret) == 0 { + panic("no return value specified for ExtendTTL") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string, int64) error); ok { + r0 = returnFunc(ctx, namespace, key, ttlSeconds) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// RuntimeStoreProviderMock_ExtendTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendTTL' +type RuntimeStoreProviderMock_ExtendTTL_Call struct { + *mock.Call +} + +// ExtendTTL is a helper method to define mock.On call +// - ctx context.Context +// - namespace providers.RuntimeStoreNamespace +// - key string +// - ttlSeconds int64 +func (_e *RuntimeStoreProviderMock_Expecter) ExtendTTL(ctx interface{}, namespace interface{}, key interface{}, ttlSeconds interface{}) *RuntimeStoreProviderMock_ExtendTTL_Call { + return &RuntimeStoreProviderMock_ExtendTTL_Call{Call: _e.mock.On("ExtendTTL", ctx, namespace, key, ttlSeconds)} +} + +func (_c *RuntimeStoreProviderMock_ExtendTTL_Call) Run(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, ttlSeconds int64)) *RuntimeStoreProviderMock_ExtendTTL_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.RuntimeStoreNamespace + if args[1] != nil { + arg1 = args[1].(providers.RuntimeStoreNamespace) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 int64 + if args[3] != nil { + arg3 = args[3].(int64) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *RuntimeStoreProviderMock_ExtendTTL_Call) Return(err error) *RuntimeStoreProviderMock_ExtendTTL_Call { + _c.Call.Return(err) + return _c +} + +func (_c *RuntimeStoreProviderMock_ExtendTTL_Call) RunAndReturn(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, ttlSeconds int64) error) *RuntimeStoreProviderMock_ExtendTTL_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function for the type RuntimeStoreProviderMock +func (_mock *RuntimeStoreProviderMock) Get(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string) ([]byte, error) { + ret := _mock.Called(ctx, namespace, key) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string) ([]byte, error)); ok { + return returnFunc(ctx, namespace, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string) []byte); ok { + r0 = returnFunc(ctx, namespace, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, providers.RuntimeStoreNamespace, string) error); ok { + r1 = returnFunc(ctx, namespace, key) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// RuntimeStoreProviderMock_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type RuntimeStoreProviderMock_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - namespace providers.RuntimeStoreNamespace +// - key string +func (_e *RuntimeStoreProviderMock_Expecter) Get(ctx interface{}, namespace interface{}, key interface{}) *RuntimeStoreProviderMock_Get_Call { + return &RuntimeStoreProviderMock_Get_Call{Call: _e.mock.On("Get", ctx, namespace, key)} +} + +func (_c *RuntimeStoreProviderMock_Get_Call) Run(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string)) *RuntimeStoreProviderMock_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.RuntimeStoreNamespace + if args[1] != nil { + arg1 = args[1].(providers.RuntimeStoreNamespace) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *RuntimeStoreProviderMock_Get_Call) Return(bytes []byte, err error) *RuntimeStoreProviderMock_Get_Call { + _c.Call.Return(bytes, err) + return _c +} + +func (_c *RuntimeStoreProviderMock_Get_Call) RunAndReturn(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string) ([]byte, error)) *RuntimeStoreProviderMock_Get_Call { + _c.Call.Return(run) + return _c +} + +// Put provides a mock function for the type RuntimeStoreProviderMock +func (_mock *RuntimeStoreProviderMock) Put(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, value []byte, ttlSeconds int64) error { + ret := _mock.Called(ctx, namespace, key, value, ttlSeconds) + + if len(ret) == 0 { + panic("no return value specified for Put") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string, []byte, int64) error); ok { + r0 = returnFunc(ctx, namespace, key, value, ttlSeconds) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// RuntimeStoreProviderMock_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' +type RuntimeStoreProviderMock_Put_Call struct { + *mock.Call +} + +// Put is a helper method to define mock.On call +// - ctx context.Context +// - namespace providers.RuntimeStoreNamespace +// - key string +// - value []byte +// - ttlSeconds int64 +func (_e *RuntimeStoreProviderMock_Expecter) Put(ctx interface{}, namespace interface{}, key interface{}, value interface{}, ttlSeconds interface{}) *RuntimeStoreProviderMock_Put_Call { + return &RuntimeStoreProviderMock_Put_Call{Call: _e.mock.On("Put", ctx, namespace, key, value, ttlSeconds)} +} + +func (_c *RuntimeStoreProviderMock_Put_Call) Run(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, value []byte, ttlSeconds int64)) *RuntimeStoreProviderMock_Put_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.RuntimeStoreNamespace + if args[1] != nil { + arg1 = args[1].(providers.RuntimeStoreNamespace) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 []byte + if args[3] != nil { + arg3 = args[3].([]byte) + } + var arg4 int64 + if args[4] != nil { + arg4 = args[4].(int64) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *RuntimeStoreProviderMock_Put_Call) Return(err error) *RuntimeStoreProviderMock_Put_Call { + _c.Call.Return(err) + return _c +} + +func (_c *RuntimeStoreProviderMock_Put_Call) RunAndReturn(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, value []byte, ttlSeconds int64) error) *RuntimeStoreProviderMock_Put_Call { + _c.Call.Return(run) + return _c +} + +// Take provides a mock function for the type RuntimeStoreProviderMock +func (_mock *RuntimeStoreProviderMock) Take(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string) ([]byte, error) { + ret := _mock.Called(ctx, namespace, key) + + if len(ret) == 0 { + panic("no return value specified for Take") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string) ([]byte, error)); ok { + return returnFunc(ctx, namespace, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string) []byte); ok { + r0 = returnFunc(ctx, namespace, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, providers.RuntimeStoreNamespace, string) error); ok { + r1 = returnFunc(ctx, namespace, key) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// RuntimeStoreProviderMock_Take_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Take' +type RuntimeStoreProviderMock_Take_Call struct { + *mock.Call +} + +// Take is a helper method to define mock.On call +// - ctx context.Context +// - namespace providers.RuntimeStoreNamespace +// - key string +func (_e *RuntimeStoreProviderMock_Expecter) Take(ctx interface{}, namespace interface{}, key interface{}) *RuntimeStoreProviderMock_Take_Call { + return &RuntimeStoreProviderMock_Take_Call{Call: _e.mock.On("Take", ctx, namespace, key)} +} + +func (_c *RuntimeStoreProviderMock_Take_Call) Run(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string)) *RuntimeStoreProviderMock_Take_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.RuntimeStoreNamespace + if args[1] != nil { + arg1 = args[1].(providers.RuntimeStoreNamespace) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *RuntimeStoreProviderMock_Take_Call) Return(bytes []byte, err error) *RuntimeStoreProviderMock_Take_Call { + _c.Call.Return(bytes, err) + return _c +} + +func (_c *RuntimeStoreProviderMock_Take_Call) RunAndReturn(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string) ([]byte, error)) *RuntimeStoreProviderMock_Take_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function for the type RuntimeStoreProviderMock +func (_mock *RuntimeStoreProviderMock) Update(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, value []byte) error { + ret := _mock.Called(ctx, namespace, key, value) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.RuntimeStoreNamespace, string, []byte) error); ok { + r0 = returnFunc(ctx, namespace, key, value) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// RuntimeStoreProviderMock_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type RuntimeStoreProviderMock_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - namespace providers.RuntimeStoreNamespace +// - key string +// - value []byte +func (_e *RuntimeStoreProviderMock_Expecter) Update(ctx interface{}, namespace interface{}, key interface{}, value interface{}) *RuntimeStoreProviderMock_Update_Call { + return &RuntimeStoreProviderMock_Update_Call{Call: _e.mock.On("Update", ctx, namespace, key, value)} +} + +func (_c *RuntimeStoreProviderMock_Update_Call) Run(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, value []byte)) *RuntimeStoreProviderMock_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.RuntimeStoreNamespace + if args[1] != nil { + arg1 = args[1].(providers.RuntimeStoreNamespace) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 []byte + if args[3] != nil { + arg3 = args[3].([]byte) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *RuntimeStoreProviderMock_Update_Call) Return(err error) *RuntimeStoreProviderMock_Update_Call { + _c.Call.Return(err) + return _c +} + +func (_c *RuntimeStoreProviderMock_Update_Call) RunAndReturn(run func(ctx context.Context, namespace providers.RuntimeStoreNamespace, key string, value []byte) error) *RuntimeStoreProviderMock_Update_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/oauth/oauth2/logout/handler.go b/backend/internal/oauth/oauth2/logout/handler.go new file mode 100644 index 0000000000..df012d37f9 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/handler.go @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package logout + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + oauth2utils "github.com/thunder-id/thunderid/internal/oauth/oauth2/utils" + serverconst "github.com/thunder-id/thunderid/internal/system/constants" + "github.com/thunder-id/thunderid/internal/system/log" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" +) + +// paramLogoutID is the gate query/callback parameter carrying the stored logout-request id. +const paramLogoutID = "logoutId" + +// logoutHandler serves the RP-initiated logout endpoint. It validates the request, persists the +// validated post-logout target server-side, initiates the application's sign-out flow, and redirects the +// browser to the gate sign-out page to run the flow (confirmation + session termination). The gate +// executes the flow via /flow/execute (which clears the per-flow cookie), then calls back to the +// completion endpoint, which issues the post-logout redirect. Keeping the post-logout target in the +// OAuth layer (not the flow) leaves the flow engine protocol-agnostic and gives OAuth a hook for +// protocol-level actions on sign-out. +type logoutHandler struct { + service LogoutServiceInterface + gateConfig oauthconfig.Config + logger *log.Logger +} + +func newLogoutHandler(service LogoutServiceInterface, gateConfig oauthconfig.Config) *logoutHandler { + return &logoutHandler{ + service: service, + gateConfig: gateConfig, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "LogoutHandler")), + } +} + +// HandleLogout handles GET and POST requests to the end_session_endpoint. +func (h *logoutHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + req := LogoutRequest{ + IDTokenHint: r.FormValue(constants.RequestParamIDTokenHint), + ClientID: r.FormValue(constants.RequestParamClientID), + PostLogoutRedirectURI: r.FormValue(constants.RequestParamPostLogoutRedirect), + State: r.FormValue(constants.RequestParamState), + } + + // Validate before initiating anything: the post-logout redirect URI is validated here (against the + // client's registered list) and never trusted from the browser again. + resolution, err := h.service.Resolve(r.Context(), req) + if err != nil { + h.logger.Debug(r.Context(), "Rejected logout request", log.Error(err)) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + initiation, svcErr := h.service.InitiateSignOutFlow(r.Context(), resolution) + if svcErr != nil { + status := http.StatusInternalServerError + if svcErr.Type == tidcommon.ClientErrorType { + // Client errors are the caller's fault; log at debug, not error. + status = http.StatusBadRequest + h.logger.Debug(r.Context(), "Rejected sign-out flow initiation", + log.String("appID", resolution.AppID), log.String("error", svcErr.Error.DefaultValue)) + } else { + h.logger.Error(r.Context(), "Failed to initiate sign-out flow", + log.String("appID", resolution.AppID), log.String("error", svcErr.Error.DefaultValue)) + } + http.Error(w, "logout failed", status) + return + } + + redirectURL, buildErr := getSignOutPageRedirectURI(h.gateConfig, map[string]string{ + constants.AppID: resolution.AppID, + constants.ExecutionID: initiation.ExecutionID, + paramLogoutID: initiation.LogoutID, + }) + if buildErr != nil { + h.logger.Error(r.Context(), "Failed to build gate sign-out redirect", log.Error(buildErr)) + http.Error(w, "logout failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// HandleLogoutCallback completes an RP-initiated sign-out. The gate posts the logout id here once the +// sign-out flow finishes; the server consumes the stored request (running any protocol-level actions) +// and returns the post-logout redirect URI for the browser to land on. +func (h *logoutHandler) HandleLogoutCallback(w http.ResponseWriter, r *http.Request) { + var body struct { + LogoutID string `json:"logoutId"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.LogoutID == "" { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + redirectURI, err := h.service.CompleteSignOut(r.Context(), body.LogoutID) + if err != nil { + h.logger.Error(r.Context(), "Failed to complete sign-out", log.Error(err)) + http.Error(w, "logout failed", http.StatusInternalServerError) + return + } + + w.Header().Set(serverconst.ContentTypeHeaderName, serverconst.ContentTypeJSON) + if encErr := json.NewEncoder(w).Encode(map[string]string{"redirect_uri": redirectURI}); encErr != nil { + h.logger.Error(r.Context(), "Failed to encode sign-out callback response", log.Error(encErr)) + } +} + +// getSignOutPageRedirectURI builds the gate sign-out page URL with the given query params. +func getSignOutPageRedirectURI(cfg oauthconfig.Config, queryParams map[string]string) (string, error) { + signOutPageURL := (&url.URL{ + Scheme: cfg.GateClient.Scheme, + Host: fmt.Sprintf("%s:%d", cfg.GateClient.Hostname, cfg.GateClient.Port), + Path: cfg.GateClient.SignOutPath, + }).String() + + return oauth2utils.GetURIWithQueryParams(signOutPageURL, queryParams) +} diff --git a/backend/internal/oauth/oauth2/logout/handler_test.go b/backend/internal/oauth/oauth2/logout/handler_test.go new file mode 100644 index 0000000000..419bf809f6 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/handler_test.go @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package logout + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/flow/flowexec" + oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" + "github.com/thunder-id/thunderid/internal/system/config" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" + "github.com/thunder-id/thunderid/tests/mocks/actorprovidermock" + "github.com/thunder-id/thunderid/tests/mocks/flow/flowexecmock" + "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" +) + +type LogoutHandlerTestSuite struct { + suite.Suite +} + +func TestLogoutHandlerTestSuite(t *testing.T) { + suite.Run(t, new(LogoutHandlerTestSuite)) +} + +func (suite *LogoutHandlerTestSuite) SetupTest() { + suite.Require().NoError(config.InitializeServerRuntime(suite.T().TempDir(), &config.Config{})) +} + +func (suite *LogoutHandlerTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +func gateConfig() oauthconfig.Config { + return oauthconfig.Config{ + GateClient: engineconfig.GateClientConfig{ + Scheme: "https", + Hostname: "gate.example", + Port: 9443, + SignOutPath: "/signout", + }, + } +} + +func (suite *LogoutHandlerTestSuite) TestHandleLogout_InitiatesFlowAndRedirectsToGate() { + actor := actorprovidermock.NewActorProviderMock(suite.T()) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout("https://rp.example/after"), nil) + + flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) + var capturedRuntime map[string]string + flowSvc.EXPECT().InitiateFlow(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, ic *flowexec.FlowInitContext) (string, *tidcommon.ServiceError) { + suite.Equal("app-1", ic.ApplicationID) + suite.Equal("SIGNOUT", ic.FlowType) + capturedRuntime = ic.RuntimeData + return "exec-1", nil + }) + + // A post_logout_redirect_uri is only honored with an id_token_hint, so drive the flow with one. + jwtSvc := jwtmock.NewJWTServiceInterfaceMock(suite.T()) + token := makeIDToken(testIssuer, "client-x") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().AddRequest(mock.Anything, mock.Anything).Return("logout-1", nil) + handler := newLogoutHandler( + newLogoutService(jwtSvc, actor, flowSvc, store, testIssuer), gateConfig()) + + req := httptest.NewRequest(http.MethodGet, + "/oauth2/logout?id_token_hint="+token+"&post_logout_redirect_uri=https://rp.example/after&state=xyz", nil) + rec := httptest.NewRecorder() + + handler.HandleLogout(rec, req) + + suite.Equal(http.StatusFound, rec.Code) + location := rec.Header().Get("Location") + suite.Contains(location, "https://gate.example:9443/signout") + suite.Contains(location, "applicationId=app-1") + suite.Contains(location, "executionId=exec-1") + suite.Contains(location, "logoutId=", "the gate needs the logout id to complete on callback") + + // The post-logout target is kept in the OAuth layer, not threaded through the flow. + suite.Empty(capturedRuntime, "the sign-out flow must not carry post-logout runtime data") +} + +func (suite *LogoutHandlerTestSuite) TestHandleLogout_InvalidRequestRejected() { + // No client_id and no id_token_hint: the request cannot be resolved and must be rejected before + // any flow is initiated or redirect happens. + actor := actorprovidermock.NewActorProviderMock(suite.T()) + flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) + // The request is rejected during resolution, so the store is never touched. + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), actor, flowSvc, + newLogoutRequestStoreInterfaceMock(suite.T()), testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(http.MethodGet, "/oauth2/logout", nil) + rec := httptest.NewRecorder() + + handler.HandleLogout(rec, req) + + suite.Equal(http.StatusBadRequest, rec.Code) +} + +// A sign-out flow initiation failure maps to the HTTP status implied by the service error type +// (client -> 400, server -> 500) and does not redirect. Also exercises both GET and POST. +func (suite *LogoutHandlerTestSuite) TestHandleLogout_FlowInitiationError_MapsStatus() { + cases := []struct { + name string + method string + errType tidcommon.ServiceErrorType + wantStatus int + }{ + {"client error -> 400", http.MethodGet, tidcommon.ClientErrorType, http.StatusBadRequest}, + {"server error -> 500", http.MethodPost, tidcommon.ServerErrorType, http.StatusInternalServerError}, + } + + for _, tc := range cases { + suite.Run(tc.name, func() { + actor := actorprovidermock.NewActorProviderMock(suite.T()) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout(), nil) + flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) + flowSvc.EXPECT().InitiateFlow(mock.Anything, mock.Anything).Return("", + &tidcommon.ServiceError{Type: tc.errType, Error: tidcommon.I18nMessage{DefaultValue: "flow boom"}}) + // The request is persisted before the flow is initiated; the flow init is what fails here. + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().AddRequest(mock.Anything, mock.Anything).Return("logout-1", nil) + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), actor, flowSvc, + store, testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(tc.method, "/oauth2/logout?client_id=client-x", nil) + rec := httptest.NewRecorder() + + handler.HandleLogout(rec, req) + + suite.Equal(tc.wantStatus, rec.Code) + suite.Empty(rec.Header().Get("Location"), "must not redirect when flow initiation fails") + }) + } +} + +// A POST to the end_session_endpoint initiates the flow and redirects to the gate, exactly like GET. +func (suite *LogoutHandlerTestSuite) TestHandleLogout_POSTInitiatesAndRedirects() { + actor := actorprovidermock.NewActorProviderMock(suite.T()) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x").Return(clientWithPostLogout(), nil) + flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) + flowSvc.EXPECT().InitiateFlow(mock.Anything, mock.Anything).Return("exec-2", nil) + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().AddRequest(mock.Anything, mock.Anything).Return("logout-1", nil) + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), actor, flowSvc, + store, testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(http.MethodPost, "/oauth2/logout?client_id=client-x", nil) + rec := httptest.NewRecorder() + + handler.HandleLogout(rec, req) + + suite.Equal(http.StatusFound, rec.Code) + location := rec.Header().Get("Location") + suite.Contains(location, "https://gate.example:9443/signout") + suite.Contains(location, "executionId=exec-2") +} + +// The completion callback consumes the stored logout request and returns the post-logout redirect URI. +func (suite *LogoutHandlerTestSuite) TestHandleLogoutCallback_ReturnsRedirect() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "logout-1").Return(true, logoutRequestContext{ + AppID: "app-1", PostLogoutRedirectURI: "https://rp.example/after", State: "xyz", + }, nil) + store.EXPECT().ClearRequest(mock.Anything, "logout-1").Return(nil) + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), + actorprovidermock.NewActorProviderMock(suite.T()), + flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()), store, testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(http.MethodPost, "/oauth2/logout/callback", + strings.NewReader(`{"logoutId":"logout-1"}`)) + rec := httptest.NewRecorder() + + handler.HandleLogoutCallback(rec, req) + + suite.Equal(http.StatusOK, rec.Code) + var body struct { + RedirectURI string `json:"redirect_uri"` + } + suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&body)) + suite.Contains(body.RedirectURI, "https://rp.example/after") + suite.Contains(body.RedirectURI, "state=xyz") +} + +func (suite *LogoutHandlerTestSuite) TestHandleLogout_MalformedFormRejected() { + // An unparseable query string (invalid percent-encoding) fails ParseForm before anything else. + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), + actorprovidermock.NewActorProviderMock(suite.T()), + flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()), + newLogoutRequestStoreInterfaceMock(suite.T()), testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(http.MethodGet, "/oauth2/logout?%zz", nil) + rec := httptest.NewRecorder() + + handler.HandleLogout(rec, req) + + suite.Equal(http.StatusBadRequest, rec.Code) +} + +// A completion-callback store failure maps to 500 and returns no redirect. +func (suite *LogoutHandlerTestSuite) TestHandleLogoutCallback_CompletionError() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "logout-1"). + Return(false, logoutRequestContext{}, fmt.Errorf("store down")) + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), + actorprovidermock.NewActorProviderMock(suite.T()), + flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()), store, testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(http.MethodPost, "/oauth2/logout/callback", + strings.NewReader(`{"logoutId":"logout-1"}`)) + rec := httptest.NewRecorder() + + handler.HandleLogoutCallback(rec, req) + + suite.Equal(http.StatusInternalServerError, rec.Code) +} + +func (suite *LogoutHandlerTestSuite) TestHandleLogoutCallback_InvalidRequest() { + // The body carries no logout id, so it is rejected before the store is consulted. + svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), + actorprovidermock.NewActorProviderMock(suite.T()), + flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()), + newLogoutRequestStoreInterfaceMock(suite.T()), testIssuer) + handler := newLogoutHandler(svc, gateConfig()) + + req := httptest.NewRequest(http.MethodPost, "/oauth2/logout/callback", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + + handler.HandleLogoutCallback(rec, req) + + suite.Equal(http.StatusBadRequest, rec.Code) +} diff --git a/backend/internal/oauth/oauth2/logout/init.go b/backend/internal/oauth/oauth2/logout/init.go new file mode 100644 index 0000000000..3fbf69b826 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/init.go @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package logout + +import ( + "net/http" + + "github.com/thunder-id/thunderid/internal/flow/flowexec" + oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + "github.com/thunder-id/thunderid/internal/system/jose/jwt" + "github.com/thunder-id/thunderid/internal/system/middleware" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// Initialize wires the RP-initiated logout feature and registers the end_session_endpoint. +func Initialize( + mux *http.ServeMux, + jwtService jwt.JWTServiceInterface, + actorProvider providers.ActorProvider, + flowExecService flowexec.FlowExecServiceInterface, + runtimeStore providers.RuntimeStoreProvider, + cfg oauthconfig.Config, +) { + store := newLogoutRequestStore(runtimeStore) + service := newLogoutService(jwtService, actorProvider, flowExecService, store, cfg.JWT.Issuer) + handler := newLogoutHandler(service, cfg) + registerRoutes(mux, handler) +} + +// registerRoutes registers the GET/POST/OPTIONS routes for the logout endpoint and its completion +// callback (POST /oauth2/logout/callback), which the gate calls once the sign-out flow finishes. +func registerRoutes(mux *http.ServeMux, handler *logoutHandler) { + opts := middleware.CORSOptions{ + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: middleware.DefaultAllowedHeaders, + AllowCredentials: true, + MaxAge: 600, + } + + callbackEndpoint := constants.OAuth2LogoutEndpoint + "/callback" + + mux.HandleFunc(middleware.WithCORS("GET "+constants.OAuth2LogoutEndpoint, handler.HandleLogout, opts)) + mux.HandleFunc(middleware.WithCORS("POST "+constants.OAuth2LogoutEndpoint, handler.HandleLogout, opts)) + mux.HandleFunc(middleware.WithCORS("POST "+callbackEndpoint, handler.HandleLogoutCallback, opts)) + mux.HandleFunc(middleware.WithCORS("OPTIONS "+constants.OAuth2LogoutEndpoint, + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, opts)) + mux.HandleFunc(middleware.WithCORS("OPTIONS "+callbackEndpoint, + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, opts)) +} diff --git a/backend/internal/oauth/oauth2/logout/logoutRequestStoreInterface_mock_test.go b/backend/internal/oauth/oauth2/logout/logoutRequestStoreInterface_mock_test.go new file mode 100644 index 0000000000..962f692478 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/logoutRequestStoreInterface_mock_test.go @@ -0,0 +1,233 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package logout + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// newLogoutRequestStoreInterfaceMock creates a new instance of logoutRequestStoreInterfaceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newLogoutRequestStoreInterfaceMock(t interface { + mock.TestingT + Cleanup(func()) +}) *logoutRequestStoreInterfaceMock { + mock := &logoutRequestStoreInterfaceMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// logoutRequestStoreInterfaceMock is an autogenerated mock type for the logoutRequestStoreInterface type +type logoutRequestStoreInterfaceMock struct { + mock.Mock +} + +type logoutRequestStoreInterfaceMock_Expecter struct { + mock *mock.Mock +} + +func (_m *logoutRequestStoreInterfaceMock) EXPECT() *logoutRequestStoreInterfaceMock_Expecter { + return &logoutRequestStoreInterfaceMock_Expecter{mock: &_m.Mock} +} + +// AddRequest provides a mock function for the type logoutRequestStoreInterfaceMock +func (_mock *logoutRequestStoreInterfaceMock) AddRequest(ctx context.Context, value logoutRequestContext) (string, error) { + ret := _mock.Called(ctx, value) + + if len(ret) == 0 { + panic("no return value specified for AddRequest") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, logoutRequestContext) (string, error)); ok { + return returnFunc(ctx, value) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, logoutRequestContext) string); ok { + r0 = returnFunc(ctx, value) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, logoutRequestContext) error); ok { + r1 = returnFunc(ctx, value) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// logoutRequestStoreInterfaceMock_AddRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRequest' +type logoutRequestStoreInterfaceMock_AddRequest_Call struct { + *mock.Call +} + +// AddRequest is a helper method to define mock.On call +// - ctx context.Context +// - value logoutRequestContext +func (_e *logoutRequestStoreInterfaceMock_Expecter) AddRequest(ctx interface{}, value interface{}) *logoutRequestStoreInterfaceMock_AddRequest_Call { + return &logoutRequestStoreInterfaceMock_AddRequest_Call{Call: _e.mock.On("AddRequest", ctx, value)} +} + +func (_c *logoutRequestStoreInterfaceMock_AddRequest_Call) Run(run func(ctx context.Context, value logoutRequestContext)) *logoutRequestStoreInterfaceMock_AddRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 logoutRequestContext + if args[1] != nil { + arg1 = args[1].(logoutRequestContext) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *logoutRequestStoreInterfaceMock_AddRequest_Call) Return(s string, err error) *logoutRequestStoreInterfaceMock_AddRequest_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *logoutRequestStoreInterfaceMock_AddRequest_Call) RunAndReturn(run func(ctx context.Context, value logoutRequestContext) (string, error)) *logoutRequestStoreInterfaceMock_AddRequest_Call { + _c.Call.Return(run) + return _c +} + +// ClearRequest provides a mock function for the type logoutRequestStoreInterfaceMock +func (_mock *logoutRequestStoreInterfaceMock) ClearRequest(ctx context.Context, key string) error { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for ClearRequest") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// logoutRequestStoreInterfaceMock_ClearRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRequest' +type logoutRequestStoreInterfaceMock_ClearRequest_Call struct { + *mock.Call +} + +// ClearRequest is a helper method to define mock.On call +// - ctx context.Context +// - key string +func (_e *logoutRequestStoreInterfaceMock_Expecter) ClearRequest(ctx interface{}, key interface{}) *logoutRequestStoreInterfaceMock_ClearRequest_Call { + return &logoutRequestStoreInterfaceMock_ClearRequest_Call{Call: _e.mock.On("ClearRequest", ctx, key)} +} + +func (_c *logoutRequestStoreInterfaceMock_ClearRequest_Call) Run(run func(ctx context.Context, key string)) *logoutRequestStoreInterfaceMock_ClearRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *logoutRequestStoreInterfaceMock_ClearRequest_Call) Return(err error) *logoutRequestStoreInterfaceMock_ClearRequest_Call { + _c.Call.Return(err) + return _c +} + +func (_c *logoutRequestStoreInterfaceMock_ClearRequest_Call) RunAndReturn(run func(ctx context.Context, key string) error) *logoutRequestStoreInterfaceMock_ClearRequest_Call { + _c.Call.Return(run) + return _c +} + +// GetRequest provides a mock function for the type logoutRequestStoreInterfaceMock +func (_mock *logoutRequestStoreInterfaceMock) GetRequest(ctx context.Context, key string) (bool, logoutRequestContext, error) { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for GetRequest") + } + + var r0 bool + var r1 logoutRequestContext + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, logoutRequestContext, error)); ok { + return returnFunc(ctx, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) logoutRequestContext); ok { + r1 = returnFunc(ctx, key) + } else { + r1 = ret.Get(1).(logoutRequestContext) + } + if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = returnFunc(ctx, key) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// logoutRequestStoreInterfaceMock_GetRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRequest' +type logoutRequestStoreInterfaceMock_GetRequest_Call struct { + *mock.Call +} + +// GetRequest is a helper method to define mock.On call +// - ctx context.Context +// - key string +func (_e *logoutRequestStoreInterfaceMock_Expecter) GetRequest(ctx interface{}, key interface{}) *logoutRequestStoreInterfaceMock_GetRequest_Call { + return &logoutRequestStoreInterfaceMock_GetRequest_Call{Call: _e.mock.On("GetRequest", ctx, key)} +} + +func (_c *logoutRequestStoreInterfaceMock_GetRequest_Call) Run(run func(ctx context.Context, key string)) *logoutRequestStoreInterfaceMock_GetRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *logoutRequestStoreInterfaceMock_GetRequest_Call) Return(b bool, logoutRequestContextMoqParam logoutRequestContext, err error) *logoutRequestStoreInterfaceMock_GetRequest_Call { + _c.Call.Return(b, logoutRequestContextMoqParam, err) + return _c +} + +func (_c *logoutRequestStoreInterfaceMock_GetRequest_Call) RunAndReturn(run func(ctx context.Context, key string) (bool, logoutRequestContext, error)) *logoutRequestStoreInterfaceMock_GetRequest_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/oauth/oauth2/logout/service.go b/backend/internal/oauth/oauth2/logout/service.go new file mode 100644 index 0000000000..d38e22a211 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/service.go @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package logout implements the OIDC RP-Initiated Logout 1.0 end_session_endpoint +// (GET/POST /oauth2/logout). It resolves the target application from id_token_hint (or client_id), +// validates any post_logout_redirect_uri against the client's registered list, and runs the +// application's sign-out flow to terminate the SSO session before landing the browser. +package logout + +import ( + "context" + "errors" + + "github.com/thunder-id/thunderid/internal/flow/flowexec" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + oauth2utils "github.com/thunder-id/thunderid/internal/oauth/oauth2/utils" + "github.com/thunder-id/thunderid/internal/system/jose/jwt" + "github.com/thunder-id/thunderid/internal/system/log" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +var ( + errInvalidIDTokenHint = errors.New("invalid id_token_hint") + errClientMismatch = errors.New("client_id does not match id_token_hint") + errClientRequired = errors.New("id_token_hint or client_id is required") + errInvalidClient = errors.New("invalid client") + errInvalidPostLogoutRedirectURI = errors.New("invalid post_logout_redirect_uri") + errIDTokenHintRequired = errors.New("id_token_hint is required when post_logout_redirect_uri is provided") +) + +// LogoutRequest holds the RP-initiated logout parameters received from the request. +type LogoutRequest struct { + IDTokenHint string + ClientID string + PostLogoutRedirectURI string + State string +} + +// LogoutResolution is the validated target of a logout request. +type LogoutResolution struct { + AppID string + PostLogoutRedirectURI string + State string +} + +// SignOutInitiation is the result of starting an RP-initiated sign-out: the stored logout-request id +// (echoed to the gate and returned on the completion callback) and the sign-out flow execution id. +type SignOutInitiation struct { + LogoutID string + ExecutionID string +} + +// LogoutServiceInterface validates an RP-initiated logout request, resolves its target, initiates the +// application's sign-out flow, and completes it (issuing the post-logout redirect). +type LogoutServiceInterface interface { + Resolve(ctx context.Context, req LogoutRequest) (*LogoutResolution, error) + InitiateSignOutFlow(ctx context.Context, resolution *LogoutResolution) (*SignOutInitiation, *tidcommon.ServiceError) + CompleteSignOut(ctx context.Context, logoutID string) (string, error) +} + +// logoutService is the default LogoutServiceInterface implementation. It verifies the id_token_hint, +// resolves the target client (and its post-logout redirect allow-list) via the actor provider, drives +// the application's sign-out flow through the flow-exec service, and persists the in-progress logout +// request in its store so the completion callback can issue the post-logout redirect. +type logoutService struct { + jwtService jwt.JWTServiceInterface + actorProvider providers.ActorProvider + flowExecService flowexec.FlowExecServiceInterface + store logoutRequestStoreInterface + issuer string + logger *log.Logger +} + +func newLogoutService(jwtService jwt.JWTServiceInterface, actorProvider providers.ActorProvider, + flowExecService flowexec.FlowExecServiceInterface, store logoutRequestStoreInterface, + issuer string) *logoutService { + return &logoutService{ + jwtService: jwtService, + actorProvider: actorProvider, + flowExecService: flowExecService, + store: store, + issuer: issuer, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "LogoutService")), + } +} + +// InitiateSignOutFlow persists the validated logout target server-side and initiates the application's +// sign-out flow. It returns the stored logout-request id (which the gate echoes back on completion) and +// the flow execution id. The post-logout landing is kept out of the flow entirely — OAuth resolves it on +// the completion callback — keeping the flow engine protocol-agnostic. +func (s *logoutService) InitiateSignOutFlow( + ctx context.Context, resolution *LogoutResolution, +) (*SignOutInitiation, *tidcommon.ServiceError) { + logoutID, err := s.store.AddRequest(ctx, logoutRequestContext{ + AppID: resolution.AppID, + PostLogoutRedirectURI: resolution.PostLogoutRedirectURI, + State: resolution.State, + }) + if err != nil { + s.logger.Error(ctx, "Failed to persist logout request", log.Error(err)) + return nil, &tidcommon.InternalServerError + } + + executionID, svcErr := s.flowExecService.InitiateFlow(ctx, &flowexec.FlowInitContext{ + ApplicationID: resolution.AppID, + FlowType: string(providers.FlowTypeSignOut), + }) + if svcErr != nil { + return nil, svcErr + } + + return &SignOutInitiation{LogoutID: logoutID, ExecutionID: executionID}, nil +} + +// CompleteSignOut is invoked after the sign-out flow completes. It consumes the stored logout request and +// returns the post-logout redirect URI (with state appended), or "" when the RP supplied none or the +// request is unknown/expired. Protocol-level actions that must run on sign-out (e.g. token revocation) +// belong here — the OAuth layer regains control at this point, which it cannot inside the flow. +func (s *logoutService) CompleteSignOut(ctx context.Context, logoutID string) (string, error) { + found, reqCtx, err := s.store.GetRequest(ctx, logoutID) + if err != nil { + return "", err + } + if !found { + return "", nil + } + // Consume the request so a logout id cannot be replayed. + if clearErr := s.store.ClearRequest(ctx, logoutID); clearErr != nil { + s.logger.Warn(ctx, "Failed to clear logout request", log.Error(clearErr)) + } + + if reqCtx.PostLogoutRedirectURI == "" { + return "", nil + } + if reqCtx.State == "" { + return reqCtx.PostLogoutRedirectURI, nil + } + redirectURI, err := oauth2utils.GetURIWithQueryParams( + reqCtx.PostLogoutRedirectURI, map[string]string{constants.RequestParamState: reqCtx.State}) + if err != nil { + return "", err + } + return redirectURI, nil +} + +// Resolve identifies the client from id_token_hint (preferred) or the client_id parameter, validates +// any post_logout_redirect_uri against the client's registered list, and returns the logout target. +func (s *logoutService) Resolve(ctx context.Context, req LogoutRequest) (*LogoutResolution, error) { + // Per OIDC RP-Initiated Logout, if post_logout_redirect_uri is supplied the id_token_hint MUST be + // supplied too; the OP must not redirect to the URI without a valid hint. + if req.PostLogoutRedirectURI != "" && req.IDTokenHint == "" { + return nil, errIDTokenHintRequired + } + + clientID := req.ClientID + if req.IDTokenHint != "" { + hintClientID, err := s.clientIDFromIDTokenHint(ctx, req.IDTokenHint) + if err != nil { + return nil, err + } + if clientID != "" && hintClientID != "" && clientID != hintClientID { + return nil, errClientMismatch + } + if clientID == "" { + clientID = hintClientID + } + } + if clientID == "" { + return nil, errClientRequired + } + + client, svcErr := s.actorProvider.GetOAuthClientByClientID(ctx, clientID) + if svcErr != nil { + // An unresolvable client is the caller's fault (unknown client id); log at debug, not error. + if svcErr.Type == tidcommon.ClientErrorType { + s.logger.Debug(ctx, "Client not found for logout", log.String("clientId", clientID)) + } else { + s.logger.Error(ctx, "Failed to resolve client for logout", log.String("clientId", clientID)) + } + return nil, errInvalidClient + } + if client == nil { + return nil, errInvalidClient + } + + if err := client.ValidatePostLogoutRedirectURI(ctx, req.PostLogoutRedirectURI); err != nil { + return nil, errInvalidPostLogoutRedirectURI + } + + return &LogoutResolution{ + AppID: client.ID, + PostLogoutRedirectURI: req.PostLogoutRedirectURI, + State: req.State, + }, nil +} + +// clientIDFromIDTokenHint verifies the id_token_hint was issued by this server (signature + issuer) +// and returns its audience (the client id). The token's expiry is intentionally not enforced: per +// OIDC RP-Initiated Logout, id_token_hint may be an expired ID token. +func (s *logoutService) clientIDFromIDTokenHint(ctx context.Context, idTokenHint string) (string, error) { + if svcErr := s.jwtService.VerifyJWTSignature(ctx, idTokenHint); svcErr != nil { + return "", errInvalidIDTokenHint + } + payload, err := jwt.DecodeJWTPayload(idTokenHint) + if err != nil { + return "", errInvalidIDTokenHint + } + if iss, _ := payload[constants.ClaimIss].(string); iss != s.issuer { + return "", errInvalidIDTokenHint + } + return audienceClientID(payload), nil +} + +// audienceClientID extracts the client id from an ID token. When the token has multiple audiences the +// authorized party (azp) claim identifies the client, so it is preferred; otherwise the aud claim, +// which may be a single string or an array of strings, is used. +func audienceClientID(payload map[string]interface{}) string { + if azp, ok := payload[constants.ClaimAzp].(string); ok && azp != "" { + return azp + } + switch aud := payload[constants.ClaimAud].(type) { + case string: + return aud + case []interface{}: + if len(aud) > 0 { + if first, ok := aud[0].(string); ok { + return first + } + } + } + return "" +} diff --git a/backend/internal/oauth/oauth2/logout/service_test.go b/backend/internal/oauth/oauth2/logout/service_test.go new file mode 100644 index 0000000000..867e9b47ce --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/service_test.go @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package logout + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/flow/flowexec" + "github.com/thunder-id/thunderid/internal/system/config" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/actorprovidermock" + "github.com/thunder-id/thunderid/tests/mocks/flow/flowexecmock" + "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" +) + +const testIssuer = "https://issuer.test" + +type LogoutServiceTestSuite struct { + suite.Suite +} + +func TestLogoutServiceTestSuite(t *testing.T) { + suite.Run(t, new(LogoutServiceTestSuite)) +} + +func (suite *LogoutServiceTestSuite) SetupTest() { + suite.Require().NoError(config.InitializeServerRuntime(suite.T().TempDir(), &config.Config{})) +} + +func (suite *LogoutServiceTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +func (suite *LogoutServiceTestSuite) newService() (*logoutService, *jwtmock.JWTServiceInterfaceMock, + *actorprovidermock.ActorProviderMock) { + jwtSvc := jwtmock.NewJWTServiceInterfaceMock(suite.T()) + actor := actorprovidermock.NewActorProviderMock(suite.T()) + flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) + store := newLogoutRequestStoreInterfaceMock(suite.T()) + return newLogoutService(jwtSvc, actor, flowSvc, store, testIssuer), jwtSvc, actor +} + +func (suite *LogoutServiceTestSuite) newServiceWithStore( + store logoutRequestStoreInterface, flowSvc *flowexecmock.FlowExecServiceInterfaceMock, +) *logoutService { + return newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), + actorprovidermock.NewActorProviderMock(suite.T()), flowSvc, store, testIssuer) +} + +func (suite *LogoutServiceTestSuite) TestInitiateSignOutFlow_StoresContextAndInitiates() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + // The validated target is persisted server-side, keyed by the returned logout id. + store.EXPECT().AddRequest(mock.Anything, logoutRequestContext{ + AppID: "app-1", PostLogoutRedirectURI: "https://rp.example/after", State: "xyz", + }).Return("logout-1", nil) + flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) + var captured *flowexec.FlowInitContext + flowSvc.EXPECT().InitiateFlow(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, ic *flowexec.FlowInitContext) (string, *tidcommon.ServiceError) { + captured = ic + return "exec-1", nil + }) + svc := suite.newServiceWithStore(store, flowSvc) + + initiation, svcErr := svc.InitiateSignOutFlow(context.Background(), &LogoutResolution{ + AppID: "app-1", PostLogoutRedirectURI: "https://rp.example/after", State: "xyz", + }) + + suite.Nil(svcErr) + suite.Require().NotNil(initiation) + suite.Equal("exec-1", initiation.ExecutionID) + suite.Equal("logout-1", initiation.LogoutID) + // The flow carries no post-logout data — it stays protocol-agnostic. + suite.Require().NotNil(captured) + suite.Equal("app-1", captured.ApplicationID) + suite.Equal("SIGNOUT", captured.FlowType) + suite.Empty(captured.RuntimeData) +} + +func (suite *LogoutServiceTestSuite) TestCompleteSignOut_ReturnsRedirectWithStateAndConsumes() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "logout-1").Return(true, logoutRequestContext{ + AppID: "app-1", PostLogoutRedirectURI: "https://rp.example/after", State: "xyz", + }, nil) + // Single-use: the request is consumed on completion. + store.EXPECT().ClearRequest(mock.Anything, "logout-1").Return(nil) + svc := suite.newServiceWithStore(store, flowexecmock.NewFlowExecServiceInterfaceMock(suite.T())) + + redirectURI, err := svc.CompleteSignOut(context.Background(), "logout-1") + + suite.Require().NoError(err) + suite.Contains(redirectURI, "https://rp.example/after") + suite.Contains(redirectURI, "state=xyz") +} + +func (suite *LogoutServiceTestSuite) TestCompleteSignOut_NoRedirectURI() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "logout-1"). + Return(true, logoutRequestContext{AppID: "app-1"}, nil) + store.EXPECT().ClearRequest(mock.Anything, "logout-1").Return(nil) + svc := suite.newServiceWithStore(store, flowexecmock.NewFlowExecServiceInterfaceMock(suite.T())) + + redirectURI, err := svc.CompleteSignOut(context.Background(), "logout-1") + + suite.Require().NoError(err) + suite.Empty(redirectURI) +} + +func (suite *LogoutServiceTestSuite) TestCompleteSignOut_UnknownID() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "unknown").Return(false, logoutRequestContext{}, nil) + svc := suite.newServiceWithStore(store, flowexecmock.NewFlowExecServiceInterfaceMock(suite.T())) + + redirectURI, err := svc.CompleteSignOut(context.Background(), "unknown") + + suite.Require().NoError(err) + suite.Empty(redirectURI) +} + +func clientWithPostLogout(uris ...string) *providers.OAuthClient { + return &providers.OAuthClient{ID: "app-1", ClientID: "client-x", PostLogoutRedirectURIs: uris} +} + +func makeIDToken(iss, aud string) string { + enc := func(v interface{}) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]string{"alg": "RS256", "typ": "JWT"}) + "." + + enc(map[string]interface{}{"iss": iss, "aud": aud}) + ".sig" +} + +func makeIDTokenMultiAud(iss string, aud []string, azp string) string { + enc := func(v interface{}) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]string{"alg": "RS256", "typ": "JWT"}) + "." + + enc(map[string]interface{}{"iss": iss, "aud": aud, "azp": azp}) + ".sig" +} + +func (suite *LogoutServiceTestSuite) TestResolve_RedirectWithoutIDTokenHintRejected() { + svc, _, _ := suite.newService() + + _, err := svc.Resolve(context.Background(), LogoutRequest{ + ClientID: "client-x", PostLogoutRedirectURI: "https://rp.example/after", + }) + + suite.Require().ErrorIs(err, errIDTokenHintRequired) +} + +func (suite *LogoutServiceTestSuite) TestResolve_UnregisteredRedirectRejected() { + svc, jwtSvc, actor := suite.newService() + token := makeIDToken(testIssuer, "client-x") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout("https://rp.example/after"), nil) + + _, err := svc.Resolve(context.Background(), LogoutRequest{ + IDTokenHint: token, PostLogoutRedirectURI: "https://evil.example/steal", + }) + + suite.Require().ErrorIs(err, errInvalidPostLogoutRedirectURI) +} + +func (suite *LogoutServiceTestSuite) TestResolve_ClientIDWithoutRedirect() { + svc, _, actor := suite.newService() + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout(), nil) + + res, err := svc.Resolve(context.Background(), LogoutRequest{ClientID: "client-x"}) + + suite.Require().NoError(err) + suite.Equal("app-1", res.AppID) + suite.Empty(res.PostLogoutRedirectURI) +} + +func (suite *LogoutServiceTestSuite) TestResolve_NoClientReference() { + svc, _, _ := suite.newService() + + _, err := svc.Resolve(context.Background(), LogoutRequest{}) + + suite.Require().ErrorIs(err, errClientRequired) +} + +func (suite *LogoutServiceTestSuite) TestResolve_UnknownClient() { + svc, _, actor := suite.newService() + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(nil, (*tidcommon.ServiceError)(nil)) + + _, err := svc.Resolve(context.Background(), LogoutRequest{ClientID: "client-x"}) + + suite.Require().ErrorIs(err, errInvalidClient) +} + +func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintIdentifiesClient() { + svc, jwtSvc, actor := suite.newService() + token := makeIDToken(testIssuer, "client-x") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout("https://rp.example/after"), nil) + + res, err := svc.Resolve(context.Background(), LogoutRequest{ + IDTokenHint: token, PostLogoutRedirectURI: "https://rp.example/after", State: "xyz", + }) + + suite.Require().NoError(err) + suite.Equal("app-1", res.AppID) + suite.Equal("https://rp.example/after", res.PostLogoutRedirectURI) + suite.Equal("xyz", res.State) +} + +func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintPrefersAzpForMultiAudience() { + svc, jwtSvc, actor := suite.newService() + token := makeIDTokenMultiAud(testIssuer, []string{"other-aud", "client-x"}, "client-x") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout(), nil) + + res, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().NoError(err) + suite.Equal("app-1", res.AppID) +} + +func (suite *LogoutServiceTestSuite) TestResolve_ClientIDMismatchWithIDTokenHint() { + svc, jwtSvc, _ := suite.newService() + token := makeIDToken(testIssuer, "other-client") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + + _, err := svc.Resolve(context.Background(), LogoutRequest{ClientID: "client-x", IDTokenHint: token}) + + suite.Require().ErrorIs(err, errClientMismatch) +} + +func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintBadSignature() { + svc, jwtSvc, _ := suite.newService() + token := makeIDToken(testIssuer, "client-x") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token). + Return(&tidcommon.ServiceError{Code: "bad", Type: tidcommon.ClientErrorType}) + + _, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().ErrorIs(err, errInvalidIDTokenHint) +} + +func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintWrongIssuer() { + svc, jwtSvc, _ := suite.newService() + token := makeIDToken("https://other.issuer", "client-x") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + + _, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().ErrorIs(err, errInvalidIDTokenHint) +} + +func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintUndecodablePayload() { + svc, jwtSvc, _ := suite.newService() + // Signature verification passes (mocked), but the payload segment is not valid base64url JSON. + token := "header.@@@notbase64@@@.sig" + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + + _, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().ErrorIs(err, errInvalidIDTokenHint) +} + +func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintMultiAudienceWithoutAzp() { + svc, jwtSvc, actor := suite.newService() + // No azp: the client is taken from the first aud entry. + token := makeIDTokenMultiAud(testIssuer, []string{"client-x", "other-aud"}, "") + jwtSvc.EXPECT().VerifyJWTSignature(mock.Anything, token).Return(nil) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout(), nil) + + res, err := svc.Resolve(context.Background(), LogoutRequest{IDTokenHint: token}) + + suite.Require().NoError(err) + suite.Equal("app-1", res.AppID) +} + +func (suite *LogoutServiceTestSuite) TestResolve_ClientResolutionClientError() { + svc, _, actor := suite.newService() + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "no-client"}) + + _, err := svc.Resolve(context.Background(), LogoutRequest{ClientID: "client-x"}) + + suite.Require().ErrorIs(err, errInvalidClient) +} + +func (suite *LogoutServiceTestSuite) TestResolve_ClientResolutionServerError() { + svc, _, actor := suite.newService() + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "boom"}) + + _, err := svc.Resolve(context.Background(), LogoutRequest{ClientID: "client-x"}) + + suite.Require().ErrorIs(err, errInvalidClient) +} + +func (suite *LogoutServiceTestSuite) TestInitiateSignOutFlow_StorePersistError() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().AddRequest(mock.Anything, mock.Anything). + Return("", fmt.Errorf("store down")) + // The flow must not be initiated when the request cannot be persisted. + svc := suite.newServiceWithStore(store, flowexecmock.NewFlowExecServiceInterfaceMock(suite.T())) + + initiation, svcErr := svc.InitiateSignOutFlow(context.Background(), &LogoutResolution{AppID: "app-1"}) + + suite.Nil(initiation) + suite.Require().NotNil(svcErr) + suite.Equal(tidcommon.ServerErrorType, svcErr.Type) +} + +func (suite *LogoutServiceTestSuite) TestCompleteSignOut_GetRequestError() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "logout-1"). + Return(false, logoutRequestContext{}, fmt.Errorf("store down")) + svc := suite.newServiceWithStore(store, flowexecmock.NewFlowExecServiceInterfaceMock(suite.T())) + + _, err := svc.CompleteSignOut(context.Background(), "logout-1") + + suite.Require().Error(err) +} + +func (suite *LogoutServiceTestSuite) TestCompleteSignOut_ClearErrorStillReturnsRedirect() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().GetRequest(mock.Anything, "logout-1").Return(true, logoutRequestContext{ + AppID: "app-1", PostLogoutRedirectURI: "https://rp.example/after", + }, nil) + // A best-effort clear failure is logged but must not fail the completion. + store.EXPECT().ClearRequest(mock.Anything, "logout-1").Return(fmt.Errorf("clear failed")) + svc := suite.newServiceWithStore(store, flowexecmock.NewFlowExecServiceInterfaceMock(suite.T())) + + redirectURI, err := svc.CompleteSignOut(context.Background(), "logout-1") + + suite.Require().NoError(err) + suite.Equal("https://rp.example/after", redirectURI) +} diff --git a/backend/internal/oauth/oauth2/logout/store.go b/backend/internal/oauth/oauth2/logout/store.go new file mode 100644 index 0000000000..14d74a92a5 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/store.go @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package logout + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/thunder-id/thunderid/internal/system/utils" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +const ( + jsonKeyLogoutAppID = "app_id" + jsonKeyLogoutRedirectURI = "post_logout_redirect_uri" + jsonKeyLogoutState = "state" +) + +// logoutRequestContext is the validated RP-initiated logout target held server-side between the +// end_session_endpoint request and the sign-out flow's completion callback. Keeping it here (rather +// than in the flow) leaves the flow engine protocol-agnostic and gives OAuth a hook to run +// protocol-level actions on completion. +type logoutRequestContext struct { + AppID string + PostLogoutRedirectURI string + State string +} + +// logoutRequestStoreInterface stores and retrieves logout request contexts. +type logoutRequestStoreInterface interface { + // AddRequest persists a logout request context and returns its generated id. + AddRequest(ctx context.Context, value logoutRequestContext) (string, error) + // GetRequest returns the context for an id, reporting whether a live (unexpired) entry was found. + GetRequest(ctx context.Context, key string) (bool, logoutRequestContext, error) + // ClearRequest removes the entry for an id so it cannot be replayed. + ClearRequest(ctx context.Context, key string) error +} + +// logoutRequestStore persists logout request contexts in the runtime store, so the backend follows the +// configured runtime datasource (relational database, Redis, or in-memory) rather than being tied to one. +type logoutRequestStore struct { + runtimeStore providers.RuntimeStoreProvider + validityPeriod time.Duration +} + +func newLogoutRequestStore(runtimeStore providers.RuntimeStoreProvider) logoutRequestStoreInterface { + return &logoutRequestStore{ + runtimeStore: runtimeStore, + validityPeriod: 10 * time.Minute, + } +} + +func (s *logoutRequestStore) AddRequest(ctx context.Context, value logoutRequestContext) (string, error) { + key, err := utils.GenerateUUIDv7() + if err != nil { + return "", fmt.Errorf("failed to generate UUID: %w", err) + } + jsonDataBytes, err := json.Marshal(map[string]interface{}{ + jsonKeyLogoutAppID: value.AppID, + jsonKeyLogoutRedirectURI: value.PostLogoutRedirectURI, + jsonKeyLogoutState: value.State, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal logout request context to JSON: %w", err) + } + ttlSeconds := int64(s.validityPeriod.Seconds()) + if err := s.runtimeStore.Put(ctx, providers.NamespaceLogoutReq, key, jsonDataBytes, ttlSeconds); err != nil { + return "", fmt.Errorf("failed to store logout request: %w", err) + } + return key, nil +} + +func (s *logoutRequestStore) GetRequest(ctx context.Context, key string) (bool, logoutRequestContext, error) { + if key == "" { + return false, logoutRequestContext{}, nil + } + data, err := s.runtimeStore.Get(ctx, providers.NamespaceLogoutReq, key) + if err != nil { + return false, logoutRequestContext{}, fmt.Errorf("failed to get logout request: %w", err) + } + if data == nil { + return false, logoutRequestContext{}, nil + } + value, err := unmarshalLogoutRequestContext(data) + if err != nil { + return false, logoutRequestContext{}, err + } + return true, value, nil +} + +func (s *logoutRequestStore) ClearRequest(ctx context.Context, key string) error { + if key == "" { + return nil + } + if err := s.runtimeStore.Delete(ctx, providers.NamespaceLogoutReq, key); err != nil { + return fmt.Errorf("failed to delete logout request: %w", err) + } + return nil +} + +func unmarshalLogoutRequestContext(dataBytes []byte) (logoutRequestContext, error) { + var data map[string]interface{} + if err := json.Unmarshal(dataBytes, &data); err != nil { + return logoutRequestContext{}, fmt.Errorf("failed to unmarshal logout request JSON: %w", err) + } + + value := logoutRequestContext{} + if s, ok := data[jsonKeyLogoutAppID].(string); ok { + value.AppID = s + } + if s, ok := data[jsonKeyLogoutRedirectURI].(string); ok { + value.PostLogoutRedirectURI = s + } + if s, ok := data[jsonKeyLogoutState].(string); ok { + value.State = s + } + return value, nil +} diff --git a/backend/internal/oauth/oauth2/logout/store_test.go b/backend/internal/oauth/oauth2/logout/store_test.go new file mode 100644 index 0000000000..cbd2eb8725 --- /dev/null +++ b/backend/internal/oauth/oauth2/logout/store_test.go @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package logout + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/runtimestore/inmemory" +) + +// LogoutRequestStoreTestSuite exercises the runtime-store-backed logout request store against the +// in-memory runtime store backend, which shares the RuntimeStoreProvider contract with the Redis and +// relational backends. +type LogoutRequestStoreTestSuite struct { + suite.Suite + store logoutRequestStoreInterface +} + +func TestLogoutRequestStoreTestSuite(t *testing.T) { + suite.Run(t, new(LogoutRequestStoreTestSuite)) +} + +func (suite *LogoutRequestStoreTestSuite) SetupTest() { + suite.store = newLogoutRequestStore(inmemory.Initialize("test-deployment")) +} + +func (suite *LogoutRequestStoreTestSuite) TestAddThenGetRoundTrips() { + want := logoutRequestContext{ + AppID: "app-1", + PostLogoutRedirectURI: "https://rp.example/after", + State: "xyz", + } + id, err := suite.store.AddRequest(context.Background(), want) + suite.Require().NoError(err) + suite.Require().NotEmpty(id) + + found, got, err := suite.store.GetRequest(context.Background(), id) + suite.Require().NoError(err) + suite.True(found) + suite.Equal(want, got) +} + +func (suite *LogoutRequestStoreTestSuite) TestGetUnknownKeyReportsNotFound() { + found, _, err := suite.store.GetRequest(context.Background(), "does-not-exist") + suite.Require().NoError(err) + suite.False(found) +} + +func (suite *LogoutRequestStoreTestSuite) TestGetEmptyKeyReportsNotFound() { + found, _, err := suite.store.GetRequest(context.Background(), "") + suite.Require().NoError(err) + suite.False(found) +} + +func (suite *LogoutRequestStoreTestSuite) TestClearRemovesEntry() { + id, err := suite.store.AddRequest(context.Background(), logoutRequestContext{AppID: "app-1"}) + suite.Require().NoError(err) + + suite.Require().NoError(suite.store.ClearRequest(context.Background(), id)) + + found, _, err := suite.store.GetRequest(context.Background(), id) + suite.Require().NoError(err) + suite.False(found, "a cleared logout request must not be retrievable") +} + +func (suite *LogoutRequestStoreTestSuite) TestClearEmptyKeyIsNoOp() { + suite.Require().NoError(suite.store.ClearRequest(context.Background(), "")) +} + +// The following cases drive the underlying runtime-store failure paths via a mock backend. + +func (suite *LogoutRequestStoreTestSuite) TestAddRequest_PutError() { + rt := NewRuntimeStoreProviderMock(suite.T()) + rt.EXPECT().Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("put failed")) + store := newLogoutRequestStore(rt) + + _, err := store.AddRequest(context.Background(), logoutRequestContext{AppID: "app-1"}) + + suite.Require().Error(err) +} + +func (suite *LogoutRequestStoreTestSuite) TestGetRequest_StoreError() { + rt := NewRuntimeStoreProviderMock(suite.T()) + rt.EXPECT().Get(mock.Anything, mock.Anything, "k").Return(nil, fmt.Errorf("get failed")) + store := newLogoutRequestStore(rt) + + found, _, err := store.GetRequest(context.Background(), "k") + + suite.Require().Error(err) + suite.False(found) +} + +func (suite *LogoutRequestStoreTestSuite) TestGetRequest_UnmarshalError() { + rt := NewRuntimeStoreProviderMock(suite.T()) + rt.EXPECT().Get(mock.Anything, mock.Anything, "k").Return([]byte("not-json"), nil) + store := newLogoutRequestStore(rt) + + found, _, err := store.GetRequest(context.Background(), "k") + + suite.Require().Error(err) + suite.False(found) +} + +func (suite *LogoutRequestStoreTestSuite) TestClearRequest_DeleteError() { + rt := NewRuntimeStoreProviderMock(suite.T()) + rt.EXPECT().Delete(mock.Anything, mock.Anything, "k").Return(fmt.Errorf("delete failed")) + store := newLogoutRequestStore(rt) + + err := store.ClearRequest(context.Background(), "k") + + suite.Require().Error(err) +} diff --git a/backend/internal/system/config/config.go b/backend/internal/system/config/config.go index 979ae40a7b..9250034875 100644 --- a/backend/internal/system/config/config.go +++ b/backend/internal/system/config/config.go @@ -638,6 +638,9 @@ func LoadConfig(configPath string, defaultPath string, serverHome string) (*Conf if cfg.GateClient.LoginPath == "" { cfg.GateClient.LoginPath = urlpath.Join(cfg.GateClient.Path, "signin") } + if cfg.GateClient.SignOutPath == "" { + cfg.GateClient.SignOutPath = urlpath.Join(cfg.GateClient.Path, "signout") + } if cfg.GateClient.ErrorPath == "" { cfg.GateClient.ErrorPath = urlpath.Join(cfg.GateClient.Path, "error") } diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index 6c2709afe0..a022fe3cdd 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -551,6 +551,8 @@ var defaultMessages = map[string]string{ "error.flowexecservice.recovery_not_allowed_description": "Recovery flow is disabled for the application", "error.flowexecservice.registration_not_allowed": "Registration not allowed", "error.flowexecservice.registration_not_allowed_description": "Registration flow is disabled for the application", + "error.flowexecservice.signout_not_allowed": "Sign out not allowed", + "error.flowexecservice.signout_not_allowed_description": "Sign out flow is disabled for the application", "error.flowmetaservice.application_fetch_failed_description": "Failed to retrieve application information", "error.flowmetaservice.application_not_found_description": "The specified application does not exist", "error.flowmetaservice.internal_server_error": "Internal server error", diff --git a/backend/internal/system/importer/service.go b/backend/internal/system/importer/service.go index ea18a014bc..a5a5dbdef6 100644 --- a/backend/internal/system/importer/service.go +++ b/backend/internal/system/importer/service.go @@ -744,6 +744,9 @@ func (s *importService) importApplication( if mappedFlowID, ok := flowIDAliases[req.RegistrationFlowID]; ok { req.RegistrationFlowID = mappedFlowID } + if mappedFlowID, ok := flowIDAliases[req.SignOutFlowID]; ok { + req.SignOutFlowID = mappedFlowID + } appDTO := applicationRequestToDTO(&req) normalizeOAuthConfigForImport(ctx, appDTO) @@ -858,6 +861,9 @@ func applicationRequestToDTO(req *appmodel.ApplicationRequestWithID) *appmodel.A RecoveryFlowID: req.RecoveryFlowID, RecoveryFlowHandle: req.RecoveryFlowHandle, IsRecoveryFlowEnabled: req.IsRecoveryFlowEnabled, + SignOutFlowID: req.SignOutFlowID, + SignOutFlowHandle: req.SignOutFlowHandle, + IsSignOutFlowEnabled: req.IsSignOutFlowEnabled, ThemeID: req.ThemeID, LayoutID: req.LayoutID, Assertion: req.Assertion, @@ -887,6 +893,7 @@ func applicationRequestToDTO(req *appmodel.ApplicationRequestWithID) *appmodel.A ClientID: config.OAuthConfig.ClientID, ClientSecret: config.OAuthConfig.ClientSecret, RedirectURIs: config.OAuthConfig.RedirectURIs, + PostLogoutRedirectURIs: config.OAuthConfig.PostLogoutRedirectURIs, GrantTypes: config.OAuthConfig.GrantTypes, ResponseTypes: config.OAuthConfig.ResponseTypes, TokenEndpointAuthMethod: config.OAuthConfig.TokenEndpointAuthMethod, diff --git a/backend/pkg/thunderidengine/config/config.go b/backend/pkg/thunderidengine/config/config.go index ad3630b5fb..e869e2ab26 100644 --- a/backend/pkg/thunderidengine/config/config.go +++ b/backend/pkg/thunderidengine/config/config.go @@ -140,6 +140,7 @@ type GateClientConfig struct { Scheme string `yaml:"scheme" json:"scheme"` Path string `yaml:"path" json:"path"` LoginPath string `yaml:"login_path" json:"login_path"` + SignOutPath string `yaml:"signout_path" json:"signout_path"` ErrorPath string `yaml:"error_path" json:"error_path"` CallbackPath string `yaml:"callback_path" json:"callback_path"` } diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go index c3f419f670..95c76ada1d 100644 --- a/backend/pkg/thunderidengine/engine.go +++ b/backend/pkg/thunderidengine/engine.go @@ -162,7 +162,7 @@ func New(mux *http.ServeMux, opts ...Option) *Engine { err = oauth.Initialize(mux, engineCtx.actorProvider, engineCtx.authnProvider, engineCtx.jwtService, engineCtx.jweService, flowExecService, engineCtx.observabilitySvc, engineCtx.runtimeCryptoSvc, engineCtx.ouProvider, attributeCacheService, engineCtx.authzProvider, engineCtx.resourceProvider, - nil, engineCtx.i18nProvider, engineCtx.idpProvider, nil, oauthConfig) + nil, engineCtx.i18nProvider, engineCtx.idpProvider, nil, runtimeStoreProvider, oauthConfig) if err != nil { logger.Fatal(ctx, "Failed to initialize OAuth services", log.Error(err)) } diff --git a/backend/pkg/thunderidengine/providers/constants.go b/backend/pkg/thunderidengine/providers/constants.go index b9b9f1a7b1..d77b553d52 100644 --- a/backend/pkg/thunderidengine/providers/constants.go +++ b/backend/pkg/thunderidengine/providers/constants.go @@ -55,6 +55,8 @@ const ( FlowTypeUserOnboarding FlowType = "USER_ONBOARDING" // FlowTypeRecovery represents a flow execution for account recovery (e.g., password reset). FlowTypeRecovery FlowType = "RECOVERY" + // FlowTypeSignOut represents a flow execution for terminating an SSO session. + FlowTypeSignOut FlowType = "SIGNOUT" ) // ValidFlowTypes is the set of supported flow types. @@ -63,6 +65,7 @@ var ValidFlowTypes = []FlowType{ FlowTypeRegistration, FlowTypeUserOnboarding, FlowTypeRecovery, + FlowTypeSignOut, } // NodeVariant identifies a PROMPT node sub-type that activates a variant-specific code path. @@ -495,6 +498,7 @@ const ( NamespaceFlow RuntimeStoreNamespace = "flow:state" NamespaceAuthzCode RuntimeStoreNamespace = "authz:code" NamespaceAuthzReq RuntimeStoreNamespace = "authz:req" + NamespaceLogoutReq RuntimeStoreNamespace = "logout:req" NamespacePAR RuntimeStoreNamespace = "par:req" NamespaceCIBA RuntimeStoreNamespace = "ciba:req" NamespaceJTI RuntimeStoreNamespace = "jti:token" diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index 443078b088..8429df8cee 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -557,6 +557,7 @@ type OAuthClient struct { OUID string `yaml:"ouId,omitempty"` ClientID string `yaml:"clientId,omitempty"` RedirectURIs []string `yaml:"redirectUris,omitempty"` + PostLogoutRedirectURIs []string `yaml:"postLogoutRedirectUris,omitempty"` GrantTypes []GrantType `yaml:"grantTypes,omitempty"` ResponseTypes []ResponseType `yaml:"responseTypes,omitempty"` TokenEndpointAuthMethod TokenEndpointAuthMethod `yaml:"tokenEndpointAuthMethod,omitempty"` @@ -675,6 +676,7 @@ func (c *AttestationConfig) WithoutCredentials() *AttestationConfig { // OAuthProfile is the persistence shape (OAUTH_PROFILE JSONB column). type OAuthProfile struct { RedirectURIs []string `json:"redirectUris"` + PostLogoutRedirectURIs []string `json:"postLogoutRedirectUris,omitempty"` GrantTypes []string `json:"grantTypes"` ResponseTypes []string `json:"responseTypes"` TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod"` @@ -699,6 +701,8 @@ type InboundClient struct { IsRegistrationFlowEnabled bool RecoveryFlowID string IsRecoveryFlowEnabled bool + SignOutFlowID string + IsSignOutFlowEnabled bool ThemeID string LayoutID string Assertion *AssertionConfig @@ -1011,6 +1015,9 @@ type InboundAuthProfile struct { RecoveryFlowID string `json:"recoveryFlowId,omitempty" yaml:"recoveryFlowId,omitempty" jsonschema:"Recovery flow ID. Optional. Specifies the user recovery flow."` RecoveryFlowHandle string `json:"recoveryFlowHandle,omitempty" yaml:"recoveryFlowHandle,omitempty" jsonschema:"Recovery flow handle. Optional. Alternative to recoveryFlowId — resolved to an ID at import time."` IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled" yaml:"isRecoveryFlowEnabled" jsonschema:"Enable self-service recovery. Set to true to allow users to recover their accounts (e.g., password reset). Requires recoveryFlowId or recoveryFlowHandle to be set."` + SignOutFlowID string `json:"signOutFlowId,omitempty" yaml:"signOutFlowId,omitempty" jsonschema:"Sign-out flow ID. Optional. Specifies the flow that terminates the SSO session established by the authentication flow."` + SignOutFlowHandle string `json:"signOutFlowHandle,omitempty" yaml:"signOutFlowHandle,omitempty" jsonschema:"Sign-out flow handle. Optional. Alternative to signOutFlowId — resolved to an ID at import time."` + IsSignOutFlowEnabled bool `json:"isSignOutFlowEnabled" yaml:"isSignOutFlowEnabled" jsonschema:"Enable sign-out. Set to true to allow terminating the SSO session for this application. Requires signOutFlowId or signOutFlowHandle to be set."` ThemeID string `json:"themeId,omitempty" yaml:"themeId,omitempty" jsonschema:"Theme configuration ID. Optional. Customizes the visual styling of login pages."` LayoutID string `json:"layoutId,omitempty" yaml:"layoutId,omitempty" jsonschema:"Layout configuration ID. Optional. Customizes the screen structure and component positioning of login pages."` Assertion *AssertionConfig `json:"assertion,omitempty" yaml:"assertion,omitempty" jsonschema:"Assertion configuration. Optional. Customize assertion validity periods and included user attributes."` @@ -1025,6 +1032,7 @@ type OAuthConfigWithSecret struct { ClientID string `json:"clientId,omitempty" yaml:"clientId,omitempty" jsonschema:"OAuth client ID (auto-generated if not provided)"` ClientSecret string `json:"clientSecret,omitempty" yaml:"clientSecret,omitempty" jsonschema:"OAuth client secret (auto-generated if not provided)"` RedirectURIs []string `json:"redirectUris,omitempty" yaml:"redirectUris,omitempty" jsonschema:"Allowed redirect URIs. Required for Public (SPA/Mobile) and Confidential (Server) clients. Omit for M2M."` + PostLogoutRedirectURIs []string `json:"postLogoutRedirectUris,omitempty" yaml:"postLogoutRedirectUris,omitempty" jsonschema:"Allowed post-logout redirect URIs. Optional. A post_logout_redirect_uri supplied to the logout endpoint must match one of these."` GrantTypes []GrantType `json:"grantTypes,omitempty" yaml:"grantTypes,omitempty" jsonschema:"OAuth grant types. Common: [authorization_code, refresh_token] for user apps, [client_credentials] for M2M."` ResponseTypes []ResponseType `json:"responseTypes,omitempty" yaml:"responseTypes,omitempty" jsonschema:"OAuth response types. Common: [code] for user apps. Omit for M2M."` TokenEndpointAuthMethod TokenEndpointAuthMethod `json:"tokenEndpointAuthMethod,omitempty" yaml:"tokenEndpointAuthMethod,omitempty" jsonschema:"Client authentication method. Use 'none' for Public clients, 'client_secret_basic' for Confidential/M2M."` diff --git a/backend/pkg/thunderidengine/providers/oauth_client.go b/backend/pkg/thunderidengine/providers/oauth_client.go index f67cc646a3..d44a66db11 100644 --- a/backend/pkg/thunderidengine/providers/oauth_client.go +++ b/backend/pkg/thunderidengine/providers/oauth_client.go @@ -66,6 +66,12 @@ func (o *OAuthClient) ValidateRedirectURI(ctx context.Context, redirectURI strin return ValidateRedirectURI(ctx, o.RedirectURIs, redirectURI) } +// ValidatePostLogoutRedirectURI validates the given post-logout redirect URI against this client's +// registered post-logout redirect URIs. +func (o *OAuthClient) ValidatePostLogoutRedirectURI(ctx context.Context, postLogoutRedirectURI string) error { + return ValidatePostLogoutRedirectURI(ctx, o.PostLogoutRedirectURIs, postLogoutRedirectURI) +} + // RequiresPKCE reports whether PKCE is required for this client. func (o *OAuthClient) RequiresPKCE() bool { return o.PKCERequired || o.PublicClient @@ -135,6 +141,24 @@ func ValidateRedirectURI(ctx context.Context, redirectURIs []string, redirectURI return nil } +// ValidatePostLogoutRedirectURI validates a post-logout redirect URI against the registered list. +// An empty URI is allowed (the logout endpoint then lands the user on a default page); a supplied +// URI must match one of the registered post-logout redirect URIs. +func ValidatePostLogoutRedirectURI(ctx context.Context, postLogoutRedirectURIs []string, + postLogoutRedirectURI string) error { + if postLogoutRedirectURI == "" { + return nil + } + if !matchAnyRedirectURIPattern(postLogoutRedirectURIs, postLogoutRedirectURI) { + return fmt.Errorf("post_logout_redirect_uri does not match any registered post-logout redirect URI") + } + if _, err := utils.ParseURL(postLogoutRedirectURI); err != nil { + log.GetLogger().Error(ctx, "Failed to parse post-logout redirect URI", log.Error(err)) + return fmt.Errorf("invalid post_logout_redirect_uri: %s", err.Error()) + } + return nil +} + func matchAnyRedirectURIPattern(patterns []string, redirectURI string) bool { wildcardEnabled := config.GetServerRuntime().Config.OAuth.AllowWildcardRedirectURI for _, pattern := range patterns { diff --git a/backend/tests/mocks/flow/sessionmock/Service_mock.go b/backend/tests/mocks/flow/sessionmock/Service_mock.go index 26837684fe..cbd4231d04 100644 --- a/backend/tests/mocks/flow/sessionmock/Service_mock.go +++ b/backend/tests/mocks/flow/sessionmock/Service_mock.go @@ -350,3 +350,77 @@ func (_c *ServiceMock_SaveCheckpoint_Call) RunAndReturn(run func(ctx context.Con _c.Call.Return(run) return _c } + +// Terminate provides a mock function for the type ServiceMock +func (_mock *ServiceMock) Terminate(ctx context.Context, handle string, flowID string) (*session.Session, error) { + ret := _mock.Called(ctx, handle, flowID) + + if len(ret) == 0 { + panic("no return value specified for Terminate") + } + + var r0 *session.Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*session.Session, error)); ok { + return returnFunc(ctx, handle, flowID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *session.Session); ok { + r0 = returnFunc(ctx, handle, flowID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*session.Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, handle, flowID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_Terminate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Terminate' +type ServiceMock_Terminate_Call struct { + *mock.Call +} + +// Terminate is a helper method to define mock.On call +// - ctx context.Context +// - handle string +// - flowID string +func (_e *ServiceMock_Expecter) Terminate(ctx interface{}, handle interface{}, flowID interface{}) *ServiceMock_Terminate_Call { + return &ServiceMock_Terminate_Call{Call: _e.mock.On("Terminate", ctx, handle, flowID)} +} + +func (_c *ServiceMock_Terminate_Call) Run(run func(ctx context.Context, handle string, flowID string)) *ServiceMock_Terminate_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ServiceMock_Terminate_Call) Return(session1 *session.Session, err error) *ServiceMock_Terminate_Call { + _c.Call.Return(session1, err) + return _c +} + +func (_c *ServiceMock_Terminate_Call) RunAndReturn(run func(ctx context.Context, handle string, flowID string) (*session.Session, error)) *ServiceMock_Terminate_Call { + _c.Call.Return(run) + return _c +} diff --git a/frontend/apps/console/src/App.tsx b/frontend/apps/console/src/App.tsx index 96cdf811b5..dc6b9577d0 100644 --- a/frontend/apps/console/src/App.tsx +++ b/frontend/apps/console/src/App.tsx @@ -443,6 +443,26 @@ export default function App(): JSX.Element { > } /> + + + + } + > + } /> + + + + + } + > + } /> + + ); } diff --git a/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/SignOutFlowSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/SignOutFlowSection.tsx new file mode 100644 index 0000000000..14bf360784 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/SignOutFlowSection.tsx @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {SettingsCard} from '@thunderid/components'; +import {Box, Typography, TextField, Autocomplete, CircularProgress, Alert} from '@wso2/oxygen-ui'; +import {useTranslation, Trans} from 'react-i18next'; +import {Link} from 'react-router'; +import useGetFlows from '../../../../flows/api/useGetFlows'; +import {FlowType} from '../../../../flows/models/flows'; +import type {Application} from '../../../models/application'; + +/** + * Props for the {@link SignOutFlowSection} component. + */ +interface SignOutFlowSectionProps { + /** + * The application being edited + */ + application: Application; + /** + * Partial application object containing edited fields + */ + editedApp: Partial; + /** + * Callback function to handle field value changes + * @param field - The application field being updated + * @param value - The new value for the field + */ + onFieldChange: (field: keyof Application, value: unknown) => void; + /** + * Singular noun used to refer to the entity in user-visible copy (default: 'application'). + */ + entityLabel?: string; +} + +/** + * Section component for selecting the signout flow. + * + * Provides: + * - Toggle switch to enable/disable signout + * - Autocomplete dropdown to select from available signout flows + * - Loading state while fetching flows + * + * @param props - Component props + * @returns SignOut flow selection UI within a SettingsCard + */ +export default function SignOutFlowSection({ + application, + editedApp, + onFieldChange, + entityLabel = 'application', +}: SignOutFlowSectionProps) { + const {t} = useTranslation(); + const {data: signoutFlowsData, isLoading: loadingSignOutFlows} = useGetFlows({flowType: FlowType.SIGNOUT}); + + const signoutFlowOptions = signoutFlowsData?.flows ?? []; + + return ( + onFieldChange('isSignOutFlowEnabled', enabled)} + > + {(editedApp.signOutFlowId ?? application.signOutFlowId) && ( + + , + , + ]} + /> + + )} + (typeof option === 'string' ? option : option.name)} + value={ + signoutFlowOptions.find((flow) => flow.id === (editedApp.signOutFlowId ?? application.signOutFlowId)) ?? null + } + onChange={(_event, newValue) => onFieldChange('signOutFlowId', newValue?.id ?? '')} + loading={loadingSignOutFlows} + disabled={application.isReadOnly} + renderInput={(params) => ( + + {loadingSignOutFlows ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + renderOption={(props, option) => ( +
  • + + {option.name} + + {option.handle} + + +
  • + )} + /> +
    + ); +} diff --git a/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/EditFlowsSettings.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/EditFlowsSettings.test.tsx index 28be1f53ad..620b1a8bd1 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/EditFlowsSettings.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/EditFlowsSettings.test.tsx @@ -47,6 +47,14 @@ vi.mock('../RecoveryFlowSection', () => ({ ), })); +vi.mock('../SignOutFlowSection', () => ({ + default: ({application, editedApp}: {application: Application; editedApp: Partial}) => ( +
    + SignOutFlowSection - App: {application.id}, Edited Sign Out Flow: {editedApp.signOutFlowId ?? 'None'} +
    + ), +})); + describe('EditFlowsSettings', () => { const mockOnFieldChange = vi.fn(); const mockApplication: Application = { @@ -72,6 +80,7 @@ describe('EditFlowsSettings', () => { expect(screen.getByTestId('auth-flow-section')).toBeInTheDocument(); expect(screen.getByTestId('registration-flow-section')).toBeInTheDocument(); expect(screen.getByTestId('recovery-flow-section')).toBeInTheDocument(); + expect(screen.getByTestId('signout-flow-section')).toBeInTheDocument(); }); it('should pass application to child components', () => { @@ -179,6 +188,7 @@ describe('EditFlowsSettings', () => { expect(sections[0]).toHaveAttribute('data-testid', 'auth-flow-section'); expect(sections[1]).toHaveAttribute('data-testid', 'registration-flow-section'); expect(sections[2]).toHaveAttribute('data-testid', 'recovery-flow-section'); + expect(sections[3]).toHaveAttribute('data-testid', 'signout-flow-section'); }); }); }); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/SignOutFlowSection.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/SignOutFlowSection.test.tsx new file mode 100644 index 0000000000..3d06227e24 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/flows-settings/__tests__/SignOutFlowSection.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {MemoryRouter} from 'react-router'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import useGetFlows from '../../../../../flows/api/useGetFlows'; +import type {Application} from '../../../../models/application'; +import SignOutFlowSection from '../SignOutFlowSection'; + +// Mock the useGetFlows hook +vi.mock('../../../../../flows/api/useGetFlows'); + +type MockedUseGetFlows = ReturnType; + +// Mock the SettingsCard so the toggle is a simple button +vi.mock('@thunderid/components', () => ({ + SettingsCard: ({ + title, + description, + enabled = false, + onToggle = undefined, + children, + }: { + title: string; + description: string; + enabled?: boolean; + onToggle?: (enabled: boolean) => void; + children: React.ReactNode; + }) => ( +
    +
    {title}
    +
    {description}
    + {onToggle && ( + + )} + {children} +
    + ), +})); + +describe('SignOutFlowSection', () => { + const mockOnFieldChange = vi.fn(); + const mockApplication: Application = { + id: 'app-123', + name: 'Test App', + signOutFlowId: 'signout-flow-1', + isSignOutFlowEnabled: true, + } as Application; + + const mockSignOutFlows = [ + {id: 'signout-flow-1', name: 'Default SignOut Flow', handle: 'default-signout'}, + {id: 'signout-flow-2', name: 'Custom SignOut Flow', handle: 'custom-signout'}, + ]; + + const mockFlows = (flows: unknown[], isLoading = false): void => { + vi.mocked(useGetFlows).mockReturnValue({data: {flows}, isLoading} as unknown as MockedUseGetFlows); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should query SIGNOUT flows', () => { + mockFlows(mockSignOutFlows); + render( + + + , + ); + expect(useGetFlows).toHaveBeenCalledWith({flowType: 'SIGNOUT'}); + }); + + it('should render the autocomplete and toggle', () => { + mockFlows(mockSignOutFlows); + render( + + + , + ); + expect(screen.getByPlaceholderText('Select a signout flow')).toBeInTheDocument(); + expect(screen.getByTestId('toggle-button')).toHaveTextContent('Toggle: ON'); + }); + + it('should show a loading indicator while fetching flows', () => { + mockFlows([], true); + render( + + + , + ); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should display the selected flow, preferring editedApp over application', () => { + mockFlows(mockSignOutFlows); + render( + + + , + ); + expect(screen.getByPlaceholderText('Select a signout flow')).toHaveValue('Custom SignOut Flow'); + }); + + it('should show the info alert only when a signout flow is selected', () => { + mockFlows(mockSignOutFlows); + const {rerender} = render( + + + , + ); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('should call onFieldChange when the toggle is clicked', async () => { + const user = userEvent.setup(); + mockFlows(mockSignOutFlows); + render( + + + , + ); + await user.click(screen.getByTestId('toggle-button')); + expect(mockOnFieldChange).toHaveBeenCalledWith('isSignOutFlowEnabled', false); + }); + + it('should call onFieldChange with the selected signout flow id', async () => { + const user = userEvent.setup(); + mockFlows(mockSignOutFlows); + render( + + + , + ); + + await user.click(screen.getByPlaceholderText('Select a signout flow')); + await waitFor(() => { + expect(screen.getByText('Custom SignOut Flow')).toBeInTheDocument(); + }); + await user.click(screen.getByText('Custom SignOut Flow')); + + expect(mockOnFieldChange).toHaveBeenCalledWith('signOutFlowId', 'signout-flow-2'); + }); + + it('should disable the picker for a read-only application', () => { + mockFlows(mockSignOutFlows); + render( + + + , + ); + expect(screen.getByPlaceholderText('Select a signout flow')).toBeDisabled(); + }); +}); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/general-settings/AccessSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/general-settings/AccessSection.tsx index d6ffa6d99b..a1526c6a8e 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/general-settings/AccessSection.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/general-settings/AccessSection.tsx @@ -90,6 +90,11 @@ export default function AccessSection({ const [redirectUris, setRedirectUris] = useState(() => oauth2Config?.redirectUris ?? []); const [uriErrors, setUriErrors] = useState>({}); + const [postLogoutRedirectUris, setPostLogoutRedirectUris] = useState( + () => oauth2Config?.postLogoutRedirectUris ?? [], + ); + const [postLogoutUriErrors, setPostLogoutUriErrors] = useState>({}); + const userTypeOptions = userTypesData?.types.map((schema) => schema.name) ?? []; const generalSettingsSchema = z.object({ @@ -109,11 +114,8 @@ export default function AccessSection({ }, }); - const validateUri = (uri: string, index: number): boolean => { - if (!uri || uri.trim() === '') { - setUriErrors((prev) => ({...prev, [index]: t('applications:edit.general.redirectUris.error.empty')})); - return false; - } + // Pure URI-format check shared by the redirect and post-logout redirect URI fields. + const isValidUriFormat = (uri: string): boolean => { try { // Replace wildcards in the host portion with a placeholder so new URL() can parse it. // Path wildcards (e.g., /callback/*, /**) parse fine natively. @@ -131,52 +133,79 @@ export default function AccessSection({ } // eslint-disable-next-line no-new new URL(uriForValidation); - - setUriErrors((prev) => { - const newErrors = {...prev}; - delete newErrors[index]; - - return newErrors; - }); - return true; } catch { - setUriErrors((prev) => ({...prev, [index]: t('applications:edit.general.redirectUris.error.invalid')})); - return false; } }; - const handleAddUri = () => { - setRedirectUris((prev) => [...prev, '']); - }; - - const handleRemoveUri = (index: number) => { - const newUris = redirectUris.filter((_, i) => i !== index); - setRedirectUris(newUris); + const validateUri = (uri: string, index: number): boolean => { + if (!uri || uri.trim() === '') { + setUriErrors((prev) => ({...prev, [index]: t('applications:edit.general.redirectUris.error.empty')})); + return false; + } + if (!isValidUriFormat(uri)) { + setUriErrors((prev) => ({...prev, [index]: t('applications:edit.general.redirectUris.error.invalid')})); + return false; + } setUriErrors((prev) => { const newErrors = {...prev}; delete newErrors[index]; + return newErrors; + }); + return true; + }; - const reindexed: Record = {}; - Object.entries(newErrors).forEach(([key, value]) => { - const oldIndex = parseInt(key, 10); - - if (oldIndex > index) { - reindexed[oldIndex - 1] = value; - } else if (oldIndex < index) { - reindexed[oldIndex] = value; - } + // Post-signout redirect URIs are optional: an empty row is allowed (filtered out on save), only a + // non-empty malformed value is an error. + const validatePostLogoutUri = (uri: string, index: number): boolean => { + if (!uri || uri.trim() === '') { + setPostLogoutUriErrors((prev) => { + const newErrors = {...prev}; + delete newErrors[index]; + return newErrors; }); + return false; + } + if (!isValidUriFormat(uri)) { + setPostLogoutUriErrors((prev) => ({ + ...prev, + [index]: t('applications:edit.general.postLogoutRedirectUris.error.invalid', 'Enter a valid URI'), + })); + return false; + } + setPostLogoutUriErrors((prev) => { + const newErrors = {...prev}; + delete newErrors[index]; + return newErrors; + }); + return true; + }; - return reindexed; + // Drop the error for a removed row and shift higher-indexed errors down by one. + const reindexErrors = (errors: Record, removedIndex: number): Record => { + const next = {...errors}; + delete next[removedIndex]; + const reindexed: Record = {}; + Object.entries(next).forEach(([key, value]) => { + const oldIndex = parseInt(key, 10); + if (oldIndex > removedIndex) { + reindexed[oldIndex - 1] = value; + } else if (oldIndex < removedIndex) { + reindexed[oldIndex] = value; + } }); + return reindexed; + }; + // Writes both URI lists (from the given arrays) into the oauth2 inbound config in a single update, + // so editing one list never clobbers the other. + const commitUris = (nextRedirect: string[], nextPostLogout: string[]) => { if (!oauth2Config) return; - const validUris = newUris.filter((uri) => uri.trim() !== ''); const updatedConfig = { ...oauth2Config, - redirectUris: validUris, + redirectUris: nextRedirect.filter((uri) => uri.trim() !== ''), + postLogoutRedirectUris: nextPostLogout.filter((uri) => uri.trim() !== ''), }; const updatedInboundAuth = application.inboundAuthConfig?.map((config) => { if (config.type === 'oauth2') { @@ -187,6 +216,17 @@ export default function AccessSection({ onFieldChange('inboundAuthConfig', updatedInboundAuth); }; + const handleAddUri = () => { + setRedirectUris((prev) => [...prev, '']); + }; + + const handleRemoveUri = (index: number) => { + const newUris = redirectUris.filter((_, i) => i !== index); + setRedirectUris(newUris); + setUriErrors((prev) => reindexErrors(prev, index)); + commitUris(newUris, postLogoutRedirectUris); + }; + const handleUriChange = (index: number, value: string) => { setRedirectUris((prev) => { const newUris = [...prev]; @@ -205,27 +245,46 @@ export default function AccessSection({ } }; - const updateRedirectUris = () => { - const validUris = redirectUris.filter((uri) => uri.trim() !== ''); - if (!oauth2Config) return; + const handleUriBlur = (index: number) => { + const uri = redirectUris[index]; + if (validateUri(uri, index) && uri.trim() !== '') { + commitUris(redirectUris, postLogoutRedirectUris); + } + }; - const updatedConfig = { - ...oauth2Config, - redirectUris: validUris, - }; - const updatedInboundAuth = application.inboundAuthConfig?.map((config) => { - if (config.type === 'oauth2') { - return {...config, config: updatedConfig}; - } - return config; + const handleAddPostLogoutUri = () => { + setPostLogoutRedirectUris((prev) => [...prev, '']); + }; + + const handleRemovePostLogoutUri = (index: number) => { + const newUris = postLogoutRedirectUris.filter((_, i) => i !== index); + setPostLogoutRedirectUris(newUris); + setPostLogoutUriErrors((prev) => reindexErrors(prev, index)); + commitUris(redirectUris, newUris); + }; + + const handlePostLogoutUriChange = (index: number, value: string) => { + setPostLogoutRedirectUris((prev) => { + const newUris = [...prev]; + newUris[index] = value; + + return newUris; }); - onFieldChange('inboundAuthConfig', updatedInboundAuth); + + if (value.trim() !== '') { + setPostLogoutUriErrors((prev) => { + const newErrors = {...prev}; + delete newErrors[index]; + + return newErrors; + }); + } }; - const handleUriBlur = (index: number) => { - const uri = redirectUris[index]; - if (validateUri(uri, index) && uri.trim() !== '') { - updateRedirectUris(); + const handlePostLogoutUriBlur = (index: number) => { + const uri = postLogoutRedirectUris[index]; + if (validatePostLogoutUri(uri, index) && uri.trim() !== '') { + commitUris(redirectUris, postLogoutRedirectUris); } }; @@ -347,6 +406,64 @@ export default function AccessSection({ )} + + {oauth2Config && ( + + + {t('applications:edit.general.postLogoutRedirectUris.title', 'Post-Logout Redirect URIs')} + + + {t( + 'applications:edit.general.postLogoutRedirectUris.description', + 'Allowed URIs to redirect to after signout. A post_logout_redirect_uri passed to the signout endpoint must match one of these.', + )} + + + + {postLogoutRedirectUris.map((uri, index) => ( + // IMPORTANT: Do not remove the suppression since it affects functionality. + // eslint-disable-next-line react/no-array-index-key + + + handlePostLogoutUriChange(index, e.target.value)} + onBlur={() => handlePostLogoutUriBlur(index)} + error={!!postLogoutUriErrors[index]} + helperText={postLogoutUriErrors[index]} + placeholder="https://example.com/logged-out" + disabled={application.isReadOnly} + /> + + + handleRemovePostLogoutUri(index)} + color="error" + sx={{mt: 1}} + disabled={application.isReadOnly} + > + + + + + ))} + + + + + + + )} ); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/general-settings/__tests__/AccessSection.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/general-settings/__tests__/AccessSection.test.tsx index 6b9c211bda..658cf9bffe 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/general-settings/__tests__/AccessSection.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/general-settings/__tests__/AccessSection.test.tsx @@ -328,7 +328,8 @@ describe('AccessSection', () => { />, ); - const addButton = screen.getByRole('button', {name: /Add URI/i}); + // The redirect URIs "Add URI" button is the first (post-logout redirect URIs adds a second). + const addButton = screen.getAllByRole('button', {name: /Add URI/i})[0]; await user.click(addButton); const inputs = screen.getAllByPlaceholderText('https://example.com/callback'); @@ -1046,9 +1047,10 @@ describe('AccessSection', () => { const redirectUriInput = screen.getByDisplayValue('https://example.com/callback'); expect(redirectUriInput).toBeDisabled(); - // Add URI button should be disabled - const addButton = screen.getByRole('button', {name: /Add URI/i}); - expect(addButton).toBeDisabled(); + // Both "Add URI" buttons (redirect and post-logout redirect URIs) should be disabled + screen.getAllByRole('button', {name: /Add URI/i}).forEach((addButton) => { + expect(addButton).toBeDisabled(); + }); // Delete button should be disabled const deleteButton = screen.getByRole('button', {name: /delete/i}); @@ -1059,4 +1061,69 @@ describe('AccessSection', () => { expect(autocompleteInput).toBeDisabled(); }); }); + + describe('Post-Logout Redirect URIs', () => { + beforeEach(() => { + vi.mocked(useGetUserTypes).mockReturnValue({ + data: mockUserTypes, + isLoading: false, + } as unknown as MockedUseGetUserTypes); + }); + + it('should render existing post-logout redirect URIs', () => { + const oauth2Config = {...mockOAuth2Config, postLogoutRedirectUris: ['https://example.com/after-signout']}; + render( + , + ); + + expect(screen.getByDisplayValue('https://example.com/after-signout')).toBeInTheDocument(); + }); + + it('should add a new post-logout redirect URI row', async () => { + const user = userEvent.setup(); + render( + , + ); + + // The post-logout "Add URI" button is the second one. + const addButtons = screen.getAllByRole('button', {name: /Add URI/i}); + await user.click(addButtons[addButtons.length - 1]); + + expect(screen.getAllByPlaceholderText('https://example.com/logged-out')).toHaveLength(1); + }); + + it('should commit postLogoutRedirectUris (with redirect URIs preserved) on blur', async () => { + const user = userEvent.setup(); + const oauth2Config = {...mockOAuth2Config, postLogoutRedirectUris: ['']}; + render( + , + ); + + const input = screen.getByPlaceholderText('https://example.com/logged-out'); + await user.type(input, 'https://example.com/after-signout'); + await user.tab(); + + const call = mockOnFieldChange.mock.calls.find((c) => c[0] === 'inboundAuthConfig'); + expect(call).toBeDefined(); + const configs = call![1] as {type: string; config: {redirectUris: string[]; postLogoutRedirectUris: string[]}}[]; + const oauth = configs.find((c) => c.type === 'oauth2')!; + expect(oauth.config.postLogoutRedirectUris).toEqual(['https://example.com/after-signout']); + expect(oauth.config.redirectUris).toEqual(['https://example.com/callback']); + }); + }); }); diff --git a/frontend/apps/console/src/features/applications/models/application.ts b/frontend/apps/console/src/features/applications/models/application.ts index d4ca6ee8c3..0c324d5a6c 100644 --- a/frontend/apps/console/src/features/applications/models/application.ts +++ b/frontend/apps/console/src/features/applications/models/application.ts @@ -220,6 +220,18 @@ export interface Application { */ isRecoveryFlowEnabled?: boolean; + /** + * SignOut flow ID + * @example 'b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e' + */ + signOutFlowId?: string; + + /** + * Whether signout flow is enabled + * @example true + */ + isSignOutFlowEnabled?: boolean; + /** * User attributes to include * @example ['email', 'username', 'given_name', 'family_name', 'roles'] diff --git a/frontend/apps/console/src/features/applications/models/oauth.ts b/frontend/apps/console/src/features/applications/models/oauth.ts index 3b48c91f56..a1cf1394a2 100644 --- a/frontend/apps/console/src/features/applications/models/oauth.ts +++ b/frontend/apps/console/src/features/applications/models/oauth.ts @@ -346,6 +346,13 @@ export interface OAuth2Config { */ redirectUris?: string[]; + /** + * List of valid URIs the OP may redirect the user to after RP-initiated signout. + * A post_logout_redirect_uri supplied to the signout endpoint must match one of these. + * @example ['https://myapp.com', 'https://myapp.com/logged-out'] + */ + postLogoutRedirectUris?: string[]; + /** * Allowed OAuth2 grant types * Defines which OAuth2 flows the application can use diff --git a/frontend/apps/console/src/features/flows/components/create-flow/SelectFlowType.tsx b/frontend/apps/console/src/features/flows/components/create-flow/SelectFlowType.tsx index b52f8e9845..06f3490b56 100644 --- a/frontend/apps/console/src/features/flows/components/create-flow/SelectFlowType.tsx +++ b/frontend/apps/console/src/features/flows/components/create-flow/SelectFlowType.tsx @@ -17,7 +17,7 @@ */ import {Box, Card, CardActionArea, CardContent, Stack, Typography} from '@wso2/oxygen-ui'; -import {KeyRound, Lock, UserPlus} from '@wso2/oxygen-ui-icons-react'; +import {KeyRound, Lock, LogOut, UserPlus} from '@wso2/oxygen-ui-icons-react'; import type {JSX} from 'react'; import {useTranslation} from 'react-i18next'; import {FlowType} from '../../models/flows'; @@ -65,6 +65,14 @@ export default function SelectFlowType({selectedType, onTypeChange, onReadyChang descriptionDefault: 'Let users recover their password or account', icon: , }, + { + type: FlowType.SIGNOUT, + labelKey: 'flows:create.type.signout.label', + labelDefault: 'Sign Out', + descriptionKey: 'flows:create.type.signout.description', + descriptionDefault: 'Confirm and terminate an established SSO session', + icon: , + }, ]; const handleSelect = (type: string): void => { @@ -80,8 +88,8 @@ export default function SelectFlowType({selectedType, onTypeChange, onReadyChang { expect(screen.getByTestId('icon-key-round')).toBeInTheDocument(); expect(screen.getByTestId('icon-user-plus')).toBeInTheDocument(); }); + + it('should render SignOut option', () => { + render(); + + expect(screen.getByText('Sign Out')).toBeInTheDocument(); + expect(screen.getByText('Confirm and terminate an established SSO session')).toBeInTheDocument(); + }); }); describe('Selection', () => { @@ -96,6 +103,15 @@ describe('SelectFlowType', () => { expect(mockOnReadyChange).toHaveBeenCalledWith(true); }); + it('should call onTypeChange with SIGNOUT when SignOut is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Sign Out')); + + expect(mockOnTypeChange).toHaveBeenCalledWith('SIGNOUT'); + expect(mockOnReadyChange).toHaveBeenCalledWith(true); + }); + it('should call onTypeChange with REGISTRATION when Self Sign-up is clicked', () => { render(); diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/call/Call.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/call/Call.tsx index 53903f8894..47d2627a71 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/call/Call.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/call/Call.tsx @@ -52,6 +52,8 @@ type CallStepData = StepData & {flow?: {ref?: string}}; const CALL_NODE_WIDTH = 260; +// Sign-out flows are intentionally absent: they cannot be selected as call targets, so a Call node +// never references one and there is nothing to open. const FLOW_TYPE_TO_ROUTE_SEGMENT: Record = { [FlowType.AUTHENTICATION]: 'signin', [FlowType.REGISTRATION]: 'registration', diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/call/__tests__/Call.test.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/call/__tests__/Call.test.tsx index ed62bab328..69bbf3e2e7 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/call/__tests__/Call.test.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/call/__tests__/Call.test.tsx @@ -239,6 +239,17 @@ describe('Call', () => { expect(mockNavigate).toHaveBeenCalledWith('/flows/recovery/flow-rec'); }); + it('does not offer opening a referenced SIGNOUT flow', () => { + // Sign-out flows cannot be call targets, so there is no route to open one. + mockUseGetFlows.mockReturnValue({ + data: {flows: [{id: 'flow-so', name: 'Sign Out', flowType: 'SIGNOUT'}]}, + isLoading: false, + error: null, + }); + renderCall({flow: {ref: 'flow-so'}}); + expect(screen.getByTestId('call-open-referenced-flow')).toBeDisabled(); + }); + it('does not navigate when the user cancels the confirmation dialog', () => { mockUseGetFlows.mockReturnValue({ data: {flows: [{id: 'flow-a', name: 'Flow A', flowType: 'AUTHENTICATION'}]}, diff --git a/frontend/apps/console/src/features/flows/data/templates.json b/frontend/apps/console/src/features/flows/data/templates.json index 65f1805c7d..09cb04b033 100644 --- a/frontend/apps/console/src/features/flows/data/templates.json +++ b/frontend/apps/console/src/features/flows/data/templates.json @@ -12806,5 +12806,222 @@ ] }, "nodes": [] + }, + { + "resourceType": "TEMPLATE", + "category": "STARTER", + "type": "BLANK", + "flowType": "SIGNOUT", + "display": { + "label": "Blank", + "description": "Start from scratch with an empty canvas", + "image": "assets/images/icons/arrowhead-right-outline.svg", + "showOnResourcePanel": true + }, + "config": { + "name": "New Sign Out Flow", + "handle": "new-signout-flow", + "nodes": [ + { + "id": "start", + "type": "START", + "layout": { + "size": { + "width": 101, + "height": 34 + }, + "position": { + "x": 62, + "y": 278 + } + }, + "onSuccess": "view_prompt" + }, + { + "id": "view_prompt", + "type": "PROMPT", + "layout": { + "size": { + "width": 350, + "height": 466 + }, + "position": { + "x": 463, + "y": 62 + } + }, + "meta": { + "components": [ + { + "category": "DISPLAY", + "id": "text_heading", + "label": "Heading", + "resourceType": "ELEMENT", + "type": "TEXT", + "variant": "HEADING_3" + }, + { + "category": "BLOCK", + "components": [ + { + "category": "ACTION", + "eventType": "SUBMIT", + "id": "action_continue", + "label": "Continue", + "resourceType": "ELEMENT", + "type": "ACTION", + "variant": "PRIMARY" + } + ], + "id": "block_actions", + "resourceType": "ELEMENT", + "type": "BLOCK" + } + ] + }, + "prompts": [ + { + "action": { + "ref": "action_continue", + "nextNode": "end" + } + } + ] + }, + { + "id": "end", + "type": "END", + "layout": { + "size": { + "width": 85, + "height": 34 + }, + "position": { + "x": 1657, + "y": 278 + } + } + } + ] + }, + "nodes": [] + }, + { + "resourceType": "TEMPLATE", + "category": "STARTER", + "type": "BASIC", + "flowType": "SIGNOUT", + "display": { + "label": "Confirm & Sign Out", + "description": "Ask the user to confirm, then terminate their SSO session", + "image": "assets/images/icons/arrowhead-right-outline.svg", + "showOnResourcePanel": true + }, + "config": { + "name": "Basic Sign Out Flow", + "handle": "basic-signout-flow", + "nodes": [ + { + "id": "start", + "type": "START", + "layout": { + "size": { + "width": 101, + "height": 34 + }, + "position": { + "x": 62, + "y": 278 + } + }, + "onSuccess": "prompt_confirm" + }, + { + "id": "prompt_confirm", + "type": "PROMPT", + "layout": { + "size": { + "width": 350, + "height": 300 + }, + "position": { + "x": 463, + "y": 150 + } + }, + "meta": { + "components": [ + { + "align": "center", + "category": "DISPLAY", + "id": "text_signout_title", + "label": "Sign out of your account?", + "resourceType": "ELEMENT", + "type": "TEXT", + "variant": "HEADING_3" + }, + { + "category": "BLOCK", + "components": [ + { + "category": "ACTION", + "eventType": "SUBMIT", + "id": "action_confirm", + "label": "Sign out", + "resourceType": "ELEMENT", + "type": "ACTION", + "variant": "PRIMARY" + } + ], + "id": "block_signout", + "resourceType": "ELEMENT", + "type": "BLOCK" + } + ] + }, + "prompts": [ + { + "action": { + "ref": "action_confirm", + "nextNode": "session_signout" + } + } + ] + }, + { + "id": "session_signout", + "type": "TASK_EXECUTION", + "layout": { + "size": { + "width": 217, + "height": 113 + }, + "position": { + "x": 960, + "y": 240 + } + }, + "executor": { + "name": "SessionSignOutExecutor" + }, + "onSuccess": "end" + }, + { + "id": "end", + "type": "END", + "layout": { + "size": { + "width": 85, + "height": 34 + }, + "position": { + "x": 1400, + "y": 278 + } + } + } + ] + }, + "nodes": [] } ] diff --git a/frontend/apps/console/src/features/flows/models/flows.ts b/frontend/apps/console/src/features/flows/models/flows.ts index 62b91aac4c..2146084870 100644 --- a/frontend/apps/console/src/features/flows/models/flows.ts +++ b/frontend/apps/console/src/features/flows/models/flows.ts @@ -55,6 +55,11 @@ export const FlowType = { * Recovery flows handle password and account recovery processes */ RECOVERY: 'RECOVERY', + + /** + * SignOut flows terminate an established SSO session + */ + SIGNOUT: 'SIGNOUT', } as const; /** diff --git a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/CallProperties.tsx b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/CallProperties.tsx index 9302098eda..35cd565429 100644 --- a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/CallProperties.tsx +++ b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/CallProperties.tsx @@ -23,6 +23,7 @@ import {useParams} from 'react-router'; import useGetFlows from '@/features/flows/api/useGetFlows'; import type {CommonResourcePropertiesPropsInterface} from '@/features/flows/components/resource-property-panel/ResourceProperties'; import useValidationStatus from '@/features/flows/hooks/useValidationStatus'; +import {FlowType} from '@/features/flows/models/flows'; import type {BasicFlowDefinition} from '@/features/flows/models/responses'; import type {StepData} from '@/features/flows/models/steps'; @@ -49,10 +50,13 @@ function CallProperties({resource, onChange}: CallPropertiesPropsInterface): Rea return stepData?.flow?.filterFlowType; }, [resource]); + // Sign-out flows are excluded as call targets: terminating an SSO session part-way through another + // flow (e.g. a login flow) is not a meaningful composition, so they are not offered here. const flows: BasicFlowDefinition[] = useMemo(() => { const list = data?.flows ?? []; return list.filter( - (f: BasicFlowDefinition) => f.id !== flowId && (!filterFlowType || f.flowType === filterFlowType), + (f: BasicFlowDefinition) => + f.id !== flowId && f.flowType !== FlowType.SIGNOUT && (!filterFlowType || f.flowType === filterFlowType), ); }, [data, flowId, filterFlowType]); diff --git a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/CallProperties.test.tsx b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/CallProperties.test.tsx index 2f2bbd86b8..656621e040 100644 --- a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/CallProperties.test.tsx +++ b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/CallProperties.test.tsx @@ -78,12 +78,13 @@ describe('CallProperties', () => { expect(combobox).toHaveAttribute('aria-disabled', 'true'); }); - it('lists flows from the API and excludes the flow currently being edited', () => { + it('lists flows from the API and excludes the flow being edited and sign-out flows', () => { mockUseGetFlows.mockReturnValue({ data: { flows: [ {id: 'flow-a', name: 'Flow A', flowType: 'AUTHENTICATION'}, {id: 'flow-b', name: 'Flow B', flowType: 'REGISTRATION'}, + {id: 'flow-so', name: 'Sign Out', flowType: 'SIGNOUT'}, {id: 'current-flow-id', name: 'Self', flowType: 'AUTHENTICATION'}, ], }, @@ -95,6 +96,8 @@ describe('CallProperties', () => { expect(screen.getByText('Flow A (AUTHENTICATION)')).toBeInTheDocument(); expect(screen.getByText('Flow B (REGISTRATION)')).toBeInTheDocument(); expect(screen.queryByText('Self (AUTHENTICATION)')).not.toBeInTheDocument(); + // Sign-out flows are not valid call targets and must not appear in the picker. + expect(screen.queryByText('Sign Out (SIGNOUT)')).not.toBeInTheDocument(); }); it('writes the chosen flow id back to data.flow', () => { diff --git a/frontend/apps/console/src/features/login-flow/data/executors.json b/frontend/apps/console/src/features/login-flow/data/executors.json index d9bde338cf..e5bdad6cda 100644 --- a/frontend/apps/console/src/features/login-flow/data/executors.json +++ b/frontend/apps/console/src/features/login-flow/data/executors.json @@ -925,5 +925,25 @@ "onSuccess": "" } } + }, + { + "resourceType": "STEP", + "category": "EXECUTOR", + "type": "TASK_EXECUTION", + "display": { + "header": "Session Sign Out Executor", + "label": "End Session", + "image": "assets/images/icons/link.svg", + "showOnResourcePanel": true + }, + "data": { + "action": { + "type": "EXECUTOR", + "executor": { + "name": "SessionSignOutExecutor" + }, + "onSuccess": "" + } + } } ] diff --git a/frontend/apps/gate/src/App.tsx b/frontend/apps/gate/src/App.tsx index 89864de580..834dc019a2 100644 --- a/frontend/apps/gate/src/App.tsx +++ b/frontend/apps/gate/src/App.tsx @@ -25,6 +25,7 @@ import DefaultLayout from './layouts/DefaultLayout'; const AcceptInvitePage = lazy(() => import('./pages/AcceptInvitePage')); const ErrorPage = lazy(() => import('./pages/ErrorPage')); +const SignOutPage = lazy(() => import('./pages/SignOutPage')); const RecoveryPage = lazy(() => import('./pages/RecoveryPage')); const SignInPage = lazy(() => import('./pages/SignInPage')); const SignUpPage = lazy(() => import('./pages/SignUpPage')); @@ -40,6 +41,7 @@ export default function App(): JSX.Element { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/apps/gate/src/components/SignOut/SignOut.tsx b/frontend/apps/gate/src/components/SignOut/SignOut.tsx new file mode 100644 index 0000000000..65a21c8ab9 --- /dev/null +++ b/frontend/apps/gate/src/components/SignOut/SignOut.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {AuthPageLayout} from '@thunderid/design'; +import {useThunderID} from '@thunderid/react'; +import type {JSX} from 'react'; +import SignOutBox from './SignOutBox'; + +export default function SignOut(): JSX.Element { + const {isMetaLoading} = useThunderID(); + + return ( + + + + ); +} diff --git a/frontend/apps/gate/src/components/SignOut/SignOutBox.tsx b/frontend/apps/gate/src/components/SignOut/SignOutBox.tsx new file mode 100644 index 0000000000..a89d2e0676 --- /dev/null +++ b/frontend/apps/gate/src/components/SignOut/SignOutBox.tsx @@ -0,0 +1,172 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {useConfig} from '@thunderid/contexts'; +import {AuthCardLayout, FlowComponentRenderer, useDesign} from '@thunderid/design'; +import {useLogger} from '@thunderid/logger/react'; +import {normalizeFlowResponse, useThunderID, type EmbeddedFlowComponent} from '@thunderid/react'; +import {Alert, Box, CircularProgress} from '@wso2/oxygen-ui'; +import type {JSX} from 'react'; +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {useSearchParams} from 'react-router'; + +/** + * The subset of the /flow/execute response this box reads: the status (to know when the flow is done) + * and the challenge token to echo on the next interactive submit. + */ +interface FlowExecuteResponse { + flowStatus?: string; + challengeToken?: string; +} + +/** + * Renders the sign-out flow the /oauth2/logout endpoint initiates. + * + * The endpoint runs the flow up to its first interactive step and redirects the browser here with an + * `executionId` and a `logoutId`. This box resumes that execution against the generic /flow/execute + * endpoint until it completes (a flow with no interactive step completes on the first call; a flow with + * a confirmation step renders its components first). On completion it calls the sign-out completion + * endpoint (/oauth2/logout/callback) with the `logoutId`; the server runs any protocol-level actions and + * returns the validated post-logout redirect URI for the browser to land on. Keeping the redirect in the + * OAuth layer (not the flow) leaves the flow engine protocol-agnostic. + * + * `credentials: 'include'` ensures the per-flow SSO cookie is sent and the clearing Set-Cookie the flow + * emits on completion is applied by the browser. + */ +export default function SignOutBox(): JSX.Element { + const [searchParams] = useSearchParams(); + const executionId = searchParams.get('executionId') ?? ''; + const logoutId = searchParams.get('logoutId') ?? ''; + const {getServerUrl} = useConfig(); + const {resolveFlowTemplateLiterals} = useThunderID(); + const {t} = useTranslation(); + const logger = useLogger('SignOutBox'); + const {isDesignEnabled} = useDesign(); + + const baseUrl = getServerUrl() ?? (import.meta.env.VITE_THUNDER_BASE_URL as string); + + const [components, setComponents] = useState([]); + const [values, setValues] = useState>({}); + const [challengeToken, setChallengeToken] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [flowError, setFlowError] = useState(null); + + // Completes the sign-out with the OAuth layer once the flow finishes: the server runs any + // protocol-level actions and returns the validated post-logout redirect URI to land on. + const completeSignOut = async (): Promise => { + const response = await fetch(`${baseUrl}/oauth2/logout/callback`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + credentials: 'include', + body: JSON.stringify({logoutId}), + }); + if (!response.ok) { + throw new Error(`logout callback failed: ${response.status}`); + } + const result = (await response.json()) as {redirect_uri?: string}; + if (result.redirect_uri) { + window.location.href = result.redirect_uri; + } + // No redirect_uri: the RP requested no landing; sign-out is complete. + }; + + // Executes one /flow/execute call: completes with the OAuth layer on COMPLETE, else renders next step. + const run = async (payload: Record): Promise => { + setIsLoading(true); + setFlowError(null); + try { + const response = await fetch(`${baseUrl}/flow/execute`, { + method: 'POST', + headers: {'Content-Type': 'application/json', Accept: 'application/json'}, + credentials: 'include', + body: JSON.stringify({...payload, verbose: true}), + }); + if (!response.ok) { + throw new Error(`flow execute failed: ${response.status}`); + } + const res = (await response.json()) as FlowExecuteResponse; + + if (res.flowStatus === 'COMPLETE') { + await completeSignOut(); + return; + } + + // Each interactive step mints a fresh challenge token that the next submit must echo back. + setChallengeToken(res.challengeToken ?? ''); + const {components: next} = normalizeFlowResponse(res, t, {throwOnError: false}); + setComponents(next); + } catch (error) { + logger.error('Sign-out flow error:', error instanceof Error ? error : undefined); + setFlowError(t('signout:errors.failed.description', 'Something went wrong. Please try again.')); + } finally { + setIsLoading(false); + } + }; + + // Resume the execution the sign-out endpoint initiated. + useEffect(() => { + void run({executionId}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [executionId]); + + return ( + + {flowError && ( + + {flowError} + + )} + {isLoading && components.length === 0 ? ( + + + + ) : ( + components.length > 0 && ( + + {components.map((component, index) => ( + setValues((prev) => ({...prev, [id]: value}))} + onSubmit={(action: {id?: string}, inputs: Record) => { + void run({executionId, challengeToken, action: action.id ?? component.id, inputs}); + }} + /> + ))} + + ) + )} + + ); +} diff --git a/frontend/apps/gate/src/components/SignOut/__tests__/SignOut.test.tsx b/frontend/apps/gate/src/components/SignOut/__tests__/SignOut.test.tsx new file mode 100644 index 0000000000..1976a85bf6 --- /dev/null +++ b/frontend/apps/gate/src/components/SignOut/__tests__/SignOut.test.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {render, screen} from '@thunderid/test-utils'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import SignOut from '../SignOut'; + +// Mock child component +vi.mock('../SignOutBox', () => ({ + default: () =>
    SignOutBox
    , +})); + +// Mock useThunderID hook +const mockUseThunderID = vi.fn(); +vi.mock('@thunderid/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + useThunderID: () => mockUseThunderID(), + }; +}); + +// Mock AuthPageLayout +vi.mock('@thunderid/design', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AuthPageLayout: ({children}: {children: React.ReactNode}) =>
    {children}
    , + }; +}); + +describe('SignOut', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseThunderID.mockReturnValue({ + isMetaLoading: false, + }); + }); + + it('renders without crashing', () => { + const {container} = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders SignOutBox component', () => { + render(); + expect(screen.getByTestId('signout-box')).toBeInTheDocument(); + }); + + it('renders AuthPageLayout', () => { + render(); + expect(screen.getByTestId('auth-page-layout')).toBeInTheDocument(); + }); + + it('renders when isMetaLoading is true', () => { + mockUseThunderID.mockReturnValue({ + isMetaLoading: true, + }); + render(); + expect(screen.getByTestId('auth-page-layout')).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/gate/src/components/SignOut/__tests__/SignOutBox.test.tsx b/frontend/apps/gate/src/components/SignOut/__tests__/SignOutBox.test.tsx new file mode 100644 index 0000000000..395ec75379 --- /dev/null +++ b/frontend/apps/gate/src/components/SignOut/__tests__/SignOutBox.test.tsx @@ -0,0 +1,221 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {act} from '@testing-library/react'; +import {render, screen, waitFor} from '@thunderid/test-utils'; +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import SignOutBox from '../SignOutBox'; + +const {mockLogger} = vi.hoisted(() => ({ + mockLogger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('@thunderid/logger/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useLogger: () => mockLogger, + }; +}); + +// Mock useDesign + layout/renderer so the box renders without the real design context. +const mockUseDesign = vi.fn(); +let capturedOnSubmit: ((action: {id?: string}, inputs: Record) => void) | undefined; +vi.mock('@thunderid/design', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + useDesign: () => mockUseDesign(), + AuthCardLayout: ({children}: {children: React.ReactNode}) =>
    {children}
    , + FlowComponentRenderer: ({ + component, + onSubmit, + }: { + component: {id?: string; type: string}; + onSubmit: (action: {id?: string}, inputs: Record) => void; + }) => { + capturedOnSubmit = onSubmit; + return ( +
    {component.id ?? component.type}
    + ); + }, + }; +}); + +// Mock useConfig +const mockGetServerUrl = vi.fn().mockReturnValue('https://api.example.com'); +vi.mock('@thunderid/contexts', () => ({ + useConfig: () => ({ + getServerUrl: mockGetServerUrl, + }), +})); + +// Mock react-router hooks +let mockSearchParams = new URLSearchParams(); +vi.mock('react-router', () => ({ + useSearchParams: () => [mockSearchParams], +})); + +// Mock the SDK: useThunderID + a controllable normalizeFlowResponse. +const mockNormalize = vi.fn().mockReturnValue({components: [], additionalData: {}, executionId: ''}); +vi.mock('@thunderid/react', async () => { + const actual = await vi.importActual('@thunderid/react'); + return { + ...actual, + useThunderID: () => ({resolveFlowTemplateLiterals: (template: string) => template}), + normalizeFlowResponse: (...args: unknown[]) => mockNormalize(...args) as {components: unknown[]}, + }; +}); + +const assignSpy = vi.fn(); + +describe('SignOutBox', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseDesign.mockReturnValue({isDesignEnabled: false, isLoading: false}); + mockGetServerUrl.mockReturnValue('https://api.example.com'); + mockSearchParams = new URLSearchParams({executionId: 'exec-1', logoutId: 'logout-1'}); + mockNormalize.mockReturnValue({components: [], additionalData: {}, executionId: ''}); + capturedOnSubmit = undefined; + // Default: an interactive step so the mount fetch neither redirects nor errors. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ok: true, json: () => Promise.resolve({flowStatus: 'PROMPT', challengeToken: ''})}), + ); + Object.defineProperty(window, 'location', {value: {href: ''}, writable: true, configurable: true}); + Object.defineProperty(window.location, 'href', {set: assignSpy, configurable: true}); + }); + + it('renders the AuthCardLayout', async () => { + render(); + expect(await screen.findByTestId('auth-card-layout')).toBeInTheDocument(); + }); + + it('resumes the execution against /flow/execute on mount', async () => { + render(); + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/flow/execute', + expect.objectContaining({method: 'POST', credentials: 'include'}) as RequestInit, + ); + }); + const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as {body: string}).body) as {executionId?: string}; + expect(body.executionId).toBe('exec-1'); + }); + + it('completes via the logout callback and redirects to the returned URI', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + if (url.endsWith('/oauth2/logout/callback')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({redirect_uri: 'https://rp.example/after?state=xyz'}), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({flowStatus: 'COMPLETE'})}); + }), + ); + render(); + await waitFor(() => { + expect(assignSpy).toHaveBeenCalledWith('https://rp.example/after?state=xyz'); + }); + // The callback was posted with the logout id from the URL. + const callbackCall = vi.mocked(fetch).mock.calls.find((c) => (c[0] as string).endsWith('/oauth2/logout/callback')); + expect(callbackCall).toBeDefined(); + const body = JSON.parse((callbackCall?.[1] as {body: string}).body) as {logoutId?: string}; + expect(body.logoutId).toBe('logout-1'); + }); + + it('does not redirect when the logout callback returns no redirect_uri', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + if (url.endsWith('/oauth2/logout/callback')) { + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({flowStatus: 'COMPLETE'})}); + }), + ); + render(); + await waitFor(() => { + expect(vi.mocked(fetch).mock.calls.some((c) => (c[0] as string).endsWith('/oauth2/logout/callback'))).toBe(true); + }); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it('renders the confirmation step components for an interactive flow', async () => { + mockNormalize.mockReturnValue({components: [{id: 'confirm', type: 'ACTION'}], additionalData: {}, executionId: ''}); + render(); + expect(await screen.findByTestId('flow-component-confirm')).toBeInTheDocument(); + }); + + it('echoes the challenge token from the prompt step on the next submit', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ok: true, json: () => Promise.resolve({flowStatus: 'PROMPT', challengeToken: 'ct-1'})}) + .mockResolvedValueOnce({ok: true, json: () => Promise.resolve({flowStatus: 'COMPLETE'})}) + // The COMPLETE response triggers the logout completion callback. + .mockResolvedValue({ok: true, json: () => Promise.resolve({})}); + vi.stubGlobal('fetch', mockFetch); + mockNormalize.mockReturnValue({components: [{id: 'confirm', type: 'ACTION'}], additionalData: {}, executionId: ''}); + + render(); + await screen.findByTestId('flow-component-confirm'); + + expect(capturedOnSubmit).toBeDefined(); + await act(async () => { + capturedOnSubmit?.({id: 'action_confirm'}, {}); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + const submitBody = JSON.parse((mockFetch.mock.calls[1][1] as {body: string}).body) as { + challengeToken?: string; + action?: string; + }; + expect(submitBody.challengeToken).toBe('ct-1'); + expect(submitBody.action).toBe('action_confirm'); + }); + + it('shows an error alert and logs when the flow execute call fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false, status: 500})); + render(); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('falls back to VITE_THUNDER_BASE_URL when getServerUrl returns null', async () => { + mockGetServerUrl.mockReturnValue(null); + render(); + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + `${import.meta.env.VITE_THUNDER_BASE_URL as string}/flow/execute`, + expect.objectContaining({method: 'POST'}) as RequestInit, + ); + }); + }); +}); diff --git a/frontend/apps/gate/src/constants/__tests__/routes.test.ts b/frontend/apps/gate/src/constants/__tests__/routes.test.ts index 5eae9114ea..eaee5f3e8f 100644 --- a/frontend/apps/gate/src/constants/__tests__/routes.test.ts +++ b/frontend/apps/gate/src/constants/__tests__/routes.test.ts @@ -52,6 +52,10 @@ describe('ROUTES', () => { expect(ROUTES.AUTH.CALLBACK).toBe('/callback'); }); + it('has AUTH.SIGNOUT path', () => { + expect(ROUTES.AUTH.SIGNOUT).toBe('/signout'); + }); + it('Routes interface has correct structure', () => { const routes: Routes = { ROOT: '/', @@ -62,6 +66,7 @@ describe('ROUTES', () => { INVITE: '/invite', CALLBACK: '/callback', RECOVERY: '/recovery', + SIGNOUT: '/signout', }, }; expect(routes.ROOT).toBe('/'); diff --git a/frontend/apps/gate/src/constants/routes.ts b/frontend/apps/gate/src/constants/routes.ts index 2675faa540..66b19affc7 100644 --- a/frontend/apps/gate/src/constants/routes.ts +++ b/frontend/apps/gate/src/constants/routes.ts @@ -52,6 +52,10 @@ export interface Routes { * Recovery page route. */ RECOVERY: string; + /** + * Sign-out page route. + */ + SIGNOUT: string; }; } @@ -78,6 +82,7 @@ const ROUTES: Routes = { INVITE: '/invite', CALLBACK: '/callback', RECOVERY: '/recovery', + SIGNOUT: '/signout', }, } as const; diff --git a/frontend/apps/gate/src/pages/SignOutPage.tsx b/frontend/apps/gate/src/pages/SignOutPage.tsx new file mode 100644 index 0000000000..aa790d0ab7 --- /dev/null +++ b/frontend/apps/gate/src/pages/SignOutPage.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type {JSX} from 'react'; +import SignOut from '../components/SignOut/SignOut'; + +export default function SignOutPage(): JSX.Element { + return ; +} diff --git a/frontend/apps/gate/src/pages/__tests__/SignOutPage.test.tsx b/frontend/apps/gate/src/pages/__tests__/SignOutPage.test.tsx new file mode 100644 index 0000000000..b374a23ed3 --- /dev/null +++ b/frontend/apps/gate/src/pages/__tests__/SignOutPage.test.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {render, screen} from '@thunderid/test-utils'; +import {describe, it, expect, vi} from 'vitest'; +import SignOutPage from '../SignOutPage'; + +// Mock the SignOut component +vi.mock('../../components/SignOut/SignOut', () => ({ + default: () =>
    SignOut Component
    , +})); + +describe('SignOutPage', () => { + it('renders without crashing', () => { + const {container} = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders SignOut component', () => { + render(); + expect(screen.getByTestId('signout-component')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 9d1223a248..2da5cdada9 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -2469,6 +2469,11 @@ const translations = { 'edit.general.redirectUris.addUri': 'Add URI', 'edit.general.redirectUris.error.empty': 'Invalid Redirect: URI must not be empty.', 'edit.general.redirectUris.error.invalid': 'Invalid Redirect: Please enter a valid URL.', + 'edit.general.postLogoutRedirectUris.title': 'Post-Logout Redirect URIs', + 'edit.general.postLogoutRedirectUris.description': + 'Allowed URIs to redirect to after logout. A post_logout_redirect_uri passed to the logout endpoint must match one of these.', + 'edit.general.postLogoutRedirectUris.addUri': 'Add URI', + 'edit.general.postLogoutRedirectUris.error.invalid': 'Invalid Redirect: Please enter a valid URL.', 'edit.general.allowedUserTypes.placeholder': 'Select user types', 'edit.general.allowedUserTypes.hint': 'Users of these types can authenticate with this application', 'edit.general.applicationUrl.hint': 'The homepage URL of your application', @@ -2506,6 +2511,12 @@ const translations = { 'edit.flows.recoveryFlow.hint': 'Select the flow that handles account recovery for this {{entity}}.', 'edit.flows.recoveryFlow.alert': 'To modify the selected flow, <0>open the flow builder. To create a new flow, visit the <1>Flows page.', + 'edit.flows.labels.signOutFlow': 'Sign Out Flow', + 'edit.flows.labels.signOutFlow.description': 'Confirm and terminate the SSO session when people sign out.', + 'edit.flows.signOutFlow.placeholder': 'Select a sign-out flow', + 'edit.flows.signOutFlow.hint': 'Select the flow that runs when a user signs out of this {{entity}}.', + 'edit.flows.signOutFlow.alert': + 'To modify the selected flow, <0>open the flow builder. To create a new flow, visit the <1>Flows page.', 'edit.flows.editFlow': 'Edit flow', // Customization section diff --git a/tests/integration/oauth/discovery/discovery_test.go b/tests/integration/oauth/discovery/discovery_test.go index a614215176..a9330cec33 100644 --- a/tests/integration/oauth/discovery/discovery_test.go +++ b/tests/integration/oauth/discovery/discovery_test.go @@ -202,8 +202,9 @@ func (ts *DiscoveryTestSuite) TestOIDCDiscovery_GET_Success() { ts.Contains(metadata.ClaimsSupported, "email", "Should support email claim (from email scope)") ts.Contains(metadata.ClaimsSupported, "phone_number", "Should support phone_number claim (from phone scope)") - // Verify not implemented endpoints are empty - ts.Empty(metadata.EndSessionEndpoint, "EndSessionEndpoint should be empty (not implemented)") + // Verify RP-initiated logout endpoint is advertised + ts.NotEmpty(metadata.EndSessionEndpoint, "EndSessionEndpoint should be present") + ts.Contains(metadata.EndSessionEndpoint, "/oauth2/logout", "EndSessionEndpoint should contain correct path") // Verify RFC 9207 issuer identification support ts.True(metadata.AuthorizationResponseIssParameterSupported,