diff --git a/backend/internal/flow/common/constants.go b/backend/internal/flow/common/constants.go index 1318e60861..acb0967c0f 100644 --- a/backend/internal/flow/common/constants.go +++ b/backend/internal/flow/common/constants.go @@ -244,6 +244,14 @@ const ( // from the SSO checkpoint snapshot so each flow execution mints a fresh tfid rather than reusing a // prior one on SSO reuse. RuntimeKeyTokenFamilyID = "tokenFamilyId" + // RuntimeKeyLogoutPromptRequired is set by the OAuth RP-initiated logout layer when a logout was + // requested without a valid id_token_hint. A sign-out flow's session sign-out node reads it to + // decide whether the End-User must confirm the logout before the session is terminated. + RuntimeKeyLogoutPromptRequired = "logoutPromptRequired" + // RuntimeKeyLogoutPromptShown is the session sign-out node's own guard, set when it routes to the + // confirmation prompt so that on re-run (after the user confirms) it terminates instead of + // prompting again. + RuntimeKeyLogoutPromptShown = "logoutPromptShown" ) // 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 d4f0d64402..bc319fbd9c 100644 --- a/backend/internal/flow/executor/constants.go +++ b/backend/internal/flow/executor/constants.go @@ -115,6 +115,10 @@ const ( propertyKeyCallbackType = "callbackType" propertyKeyLoginHintAttribute = "loginHintAttribute" propertyKeyMaxOTPAttempts = "maxAttempts" + // propertyKeyPromptOnSignOut, when set to boolean true on a session sign-out node, makes the executor + // confirm the logout with the End-User (via the node's onIncomplete prompt) whenever the RP-initiated + // logout was not accompanied by a valid id_token_hint (RuntimeKeyLogoutPromptRequired). + propertyKeyPromptOnSignOut = "promptOnSignOut" ) // nonSearchableInputs contains the list of user inputs/ attributes that are non-searchable. diff --git a/backend/internal/flow/executor/session_signout_executor.go b/backend/internal/flow/executor/session_signout_executor.go index 10d0db9694..2a7e46548e 100644 --- a/backend/internal/flow/executor/session_signout_executor.go +++ b/backend/internal/flow/executor/session_signout_executor.go @@ -60,6 +60,12 @@ func newSessionSignOutExecutor(flowFactory core.FlowFactoryInterface, sso sessio // 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. +// +// When the node opts in with the promptOnSignOut property and the RP-initiated logout arrived without +// a valid id_token_hint (RuntimeKeyLogoutPromptRequired), the executor first routes to the node's +// onIncomplete confirmation prompt and only terminates the session once the End-User confirms. This +// keeps the confirmation logic in the executor rather than a node condition the flow editor cannot +// represent. func (e *sessionSignOutExecutor) Execute(ctx *providers.NodeContext) (*providers.ExecutorResponse, error) { logger := e.logger.With(log.String(log.LoggerKeyExecutionID, ctx.ExecutionID)) @@ -69,6 +75,16 @@ func (e *sessionSignOutExecutor) Execute(ctx *providers.NodeContext) (*providers EngineData: make(map[string]string), } + // Ask the End-User to confirm before terminating when the node requests it and no valid + // id_token_hint established the request's legitimacy. The prompt is shown once: the marker is + // persisted in RuntimeData so the re-run (after confirmation) proceeds to terminate the session. + if e.confirmationRequired(ctx) { + execResp.RuntimeData[common.RuntimeKeyLogoutPromptShown] = dataValueTrue + execResp.Status = providers.ExecUserInputRequired + logger.Debug(ctx.Context, "Routing to sign-out confirmation prompt") + return execResp, nil + } + in := session.SSOInputsFrom(ctx.Context) if _, err := e.sso.Terminate(ctx.Context, in.Handle, in.FlowID); err != nil { return execResp, err @@ -82,3 +98,18 @@ func (e *sessionSignOutExecutor) Execute(ctx *providers.NodeContext) (*providers logger.Debug(ctx.Context, "Terminated SSO session on sign-out", log.String("flowId", in.FlowID)) return execResp, nil } + +// confirmationRequired reports whether the executor should route to its onIncomplete confirmation +// prompt before terminating the session. It is true only when the node opts in (promptOnSignOut), +// the RP-initiated logout requires a prompt (no valid id_token_hint), and the prompt has not already +// been shown in this flow run. +func (e *sessionSignOutExecutor) confirmationRequired(ctx *providers.NodeContext) bool { + promptEnabled, _ := ctx.NodeProperties[propertyKeyPromptOnSignOut].(bool) + if !promptEnabled { + return false + } + if ctx.RuntimeData[common.RuntimeKeyLogoutPromptRequired] != dataValueTrue { + return false + } + return ctx.RuntimeData[common.RuntimeKeyLogoutPromptShown] != dataValueTrue +} diff --git a/backend/internal/flow/executor/session_signout_executor_test.go b/backend/internal/flow/executor/session_signout_executor_test.go index 57b77be077..bb90deaf70 100644 --- a/backend/internal/flow/executor/session_signout_executor_test.go +++ b/backend/internal/flow/executor/session_signout_executor_test.go @@ -109,3 +109,80 @@ func (suite *SessionSignOutExecutorTestSuite) TestTerminateError() { suite.Require().Error(err) suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionCleared]) } + +// TestPromptsWhenConfirmationRequired covers a prompt-enabled node whose logout arrived without a +// valid id_token_hint: the executor routes to the confirmation prompt (incomplete), marks the prompt +// as shown, and does not terminate the session. +func (suite *SessionSignOutExecutorTestSuite) TestPromptsWhenConfirmationRequired() { + sso := sessionmock.NewServiceMock(suite.T()) + exec := suite.newExecutor(sso) + + ctx := signOutNodeContext() + ctx.NodeProperties = map[string]interface{}{propertyKeyPromptOnSignOut: true} + ctx.RuntimeData = map[string]string{common.RuntimeKeyLogoutPromptRequired: dataValueTrue} + + resp, err := exec.Execute(ctx) + + suite.Require().NoError(err) + suite.Equal(providers.ExecUserInputRequired, resp.Status) + suite.Equal(dataValueTrue, resp.RuntimeData[common.RuntimeKeyLogoutPromptShown]) + suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionCleared]) + sso.AssertNotCalled(suite.T(), "Terminate", mock.Anything, mock.Anything, mock.Anything) +} + +// TestTerminatesAfterConfirmation covers the re-run once the prompt has been shown: the guard marker +// is present, so the executor terminates the session instead of prompting again. +func (suite *SessionSignOutExecutorTestSuite) TestTerminatesAfterConfirmation() { + 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) + + ctx := signOutNodeContext() + ctx.NodeProperties = map[string]interface{}{propertyKeyPromptOnSignOut: true} + ctx.RuntimeData = map[string]string{ + common.RuntimeKeyLogoutPromptRequired: dataValueTrue, + common.RuntimeKeyLogoutPromptShown: dataValueTrue, + } + + resp, err := exec.Execute(ctx) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Equal(dataValueTrue, resp.EngineData[common.RuntimeKeySSOSessionCleared]) +} + +// TestTerminatesWhenHintProvided covers a prompt-enabled node whose logout carried a valid +// id_token_hint (no prompt flag): the executor terminates directly without confirming. +func (suite *SessionSignOutExecutorTestSuite) TestTerminatesWhenHintProvided() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Terminate(mock.Anything, "handle-abc", "flow-1").Return(nil, nil) + exec := suite.newExecutor(sso) + + ctx := signOutNodeContext() + ctx.NodeProperties = map[string]interface{}{propertyKeyPromptOnSignOut: true} + + resp, err := exec.Execute(ctx) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Equal(dataValueTrue, resp.EngineData[common.RuntimeKeySSOSessionCleared]) +} + +// TestTerminatesWhenNodeDoesNotOptIn covers a node without the promptOnSignOut property (e.g. the +// always-prompt default flow, where a separate prompt node precedes this one): even when a prompt was +// requested, the executor terminates rather than emitting a second, unhandled prompt. +func (suite *SessionSignOutExecutorTestSuite) TestTerminatesWhenNodeDoesNotOptIn() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Terminate(mock.Anything, "handle-abc", "flow-1").Return(nil, nil) + exec := suite.newExecutor(sso) + + ctx := signOutNodeContext() + ctx.RuntimeData = map[string]string{common.RuntimeKeyLogoutPromptRequired: dataValueTrue} + + resp, err := exec.Execute(ctx) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Equal(dataValueTrue, resp.EngineData[common.RuntimeKeySSOSessionCleared]) +} diff --git a/backend/internal/oauth/oauth2/logout/service.go b/backend/internal/oauth/oauth2/logout/service.go index bd60fb3f09..6a7c3d0eee 100644 --- a/backend/internal/oauth/oauth2/logout/service.go +++ b/backend/internal/oauth/oauth2/logout/service.go @@ -26,6 +26,7 @@ import ( "context" "errors" + flowcommon "github.com/thunder-id/thunderid/internal/flow/common" "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" @@ -42,7 +43,6 @@ var ( 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. @@ -62,6 +62,10 @@ type LogoutResolution struct { State string Headers map[string][]string QueryParams map[string][]string + // PromptRequired reports whether the sign-out flow must confirm the logout with the End-User. + // Per OIDC RP-Initiated Logout the OP MUST ask when no id_token_hint was supplied; it is set + // true in that case so a conditional sign-out flow can decide whether to render its prompt. + PromptRequired bool } // SignOutInitiation is the result of starting an RP-initiated sign-out: the stored logout-request id @@ -127,14 +131,19 @@ func (s *logoutService) InitiateSignOutFlow( // JWT with user identity claims into the flow context store. forwardedQueryParams := filterQueryParams(resolution.QueryParams, constants.RequestParamIDTokenHint) - executionID, svcErr := s.flowExecService.InitiateFlow(ctx, &flowexec.FlowInitContext{ + initContext := &flowexec.FlowInitContext{ ApplicationID: resolution.AppID, FlowType: string(providers.FlowTypeSignOut), InitiatorRequest: &providers.InitiatorRequest{ Headers: sysutils.FilterSensitiveHeaders(resolution.Headers), QueryParams: forwardedQueryParams, }, - }) + } + if resolution.PromptRequired { + initContext.RuntimeData = map[string]string{flowcommon.RuntimeKeyLogoutPromptRequired: "true"} + } + + executionID, svcErr := s.flowExecService.InitiateFlow(ctx, initContext) if svcErr != nil { return nil, svcErr } @@ -175,13 +184,13 @@ func (s *logoutService) CompleteSignOut(ctx context.Context, logoutID string) (s // 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. +// +// id_token_hint is not required: a request carrying only client_id is accepted, and any +// post_logout_redirect_uri is still confirmed legitimate by matching the client's registered list +// (the OP's "other means" of confirming the redirection target per OIDC RP-Initiated Logout). When no +// id_token_hint is supplied the resolution is marked PromptRequired so the sign-out flow can confirm +// the logout with the End-User, as the spec requires in that case. 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) @@ -223,6 +232,7 @@ func (s *logoutService) Resolve(ctx context.Context, req LogoutRequest) (*Logout State: req.State, Headers: req.Headers, QueryParams: req.QueryParams, + PromptRequired: req.IDTokenHint == "", }, nil } diff --git a/backend/internal/oauth/oauth2/logout/service_test.go b/backend/internal/oauth/oauth2/logout/service_test.go index 51ab0957cd..14f8d8cf71 100644 --- a/backend/internal/oauth/oauth2/logout/service_test.go +++ b/backend/internal/oauth/oauth2/logout/service_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + flowcommon "github.com/thunder-id/thunderid/internal/flow/common" "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" @@ -104,6 +105,28 @@ func (suite *LogoutServiceTestSuite) TestInitiateSignOutFlow_StoresContextAndIni suite.Empty(captured.RuntimeData) } +func (suite *LogoutServiceTestSuite) TestInitiateSignOutFlow_PromptRequiredSetsRuntimeData() { + store := newLogoutRequestStoreInterfaceMock(suite.T()) + store.EXPECT().AddRequest(mock.Anything, mock.Anything).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) + + _, svcErr := svc.InitiateSignOutFlow(context.Background(), &LogoutResolution{ + AppID: "app-1", PromptRequired: true, + }) + + suite.Nil(svcErr) + // The confirmation requirement is carried to the flow so a conditional prompt node can read it. + suite.Require().NotNil(captured) + suite.Equal("true", captured.RuntimeData[flowcommon.RuntimeKeyLogoutPromptRequired]) +} + func (suite *LogoutServiceTestSuite) TestCompleteSignOut_ReturnsRedirectWithStateAndConsumes() { store := newLogoutRequestStoreInterfaceMock(suite.T()) store.EXPECT().GetRequest(mock.Anything, "logout-1").Return(true, logoutRequestContext{ @@ -301,14 +324,34 @@ func makeIDTokenMultiAud(iss string, aud []string, azp string) string { enc(map[string]interface{}{"iss": iss, "aud": aud, "azp": azp}) + ".sig" } -func (suite *LogoutServiceTestSuite) TestResolve_RedirectWithoutIDTokenHintRejected() { - svc, _, _ := suite.newService() +func (suite *LogoutServiceTestSuite) TestResolve_ClientIDWithRedirectWithoutIDTokenHint() { + svc, _, actor := suite.newService() + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout("https://rp.example/after"), nil) - _, err := svc.Resolve(context.Background(), LogoutRequest{ + res, err := svc.Resolve(context.Background(), LogoutRequest{ ClientID: "client-x", PostLogoutRedirectURI: "https://rp.example/after", }) - suite.Require().ErrorIs(err, errIDTokenHintRequired) + // id_token_hint is not required: the redirect is confirmed by the client's registered + // allow-list, and the missing hint marks the logout for End-User confirmation. + suite.Require().NoError(err) + suite.Equal("app-1", res.AppID) + suite.Equal("https://rp.example/after", res.PostLogoutRedirectURI) + suite.True(res.PromptRequired) +} + +func (suite *LogoutServiceTestSuite) TestResolve_UnregisteredRedirectWithoutIDTokenHintRejected() { + svc, _, actor := suite.newService() + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout("https://rp.example/after"), nil) + + // Dropping the mandatory id_token_hint must not weaken redirect-target validation. + _, err := svc.Resolve(context.Background(), LogoutRequest{ + ClientID: "client-x", PostLogoutRedirectURI: "https://evil.example/steal", + }) + + suite.Require().ErrorIs(err, errInvalidPostLogoutRedirectURI) } func (suite *LogoutServiceTestSuite) TestResolve_UnregisteredRedirectRejected() { @@ -335,6 +378,8 @@ func (suite *LogoutServiceTestSuite) TestResolve_ClientIDWithoutRedirect() { suite.Require().NoError(err) suite.Equal("app-1", res.AppID) suite.Empty(res.PostLogoutRedirectURI) + // No id_token_hint supplied, so the logout must be confirmed with the End-User. + suite.True(res.PromptRequired) } func (suite *LogoutServiceTestSuite) TestResolve_NoClientReference() { @@ -370,6 +415,8 @@ func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintIdentifiesClient() { suite.Equal("app-1", res.AppID) suite.Equal("https://rp.example/after", res.PostLogoutRedirectURI) suite.Equal("xyz", res.State) + // A valid id_token_hint establishes legitimacy, so no confirmation prompt is forced. + suite.False(res.PromptRequired) } func (suite *LogoutServiceTestSuite) TestResolve_IDTokenHintPrefersAzpForMultiAudience() { diff --git a/frontend/apps/console/src/features/flows/data/templates.json b/frontend/apps/console/src/features/flows/data/templates.json index 5d1fdc3682..a74fe6bd1a 100644 --- a/frontend/apps/console/src/features/flows/data/templates.json +++ b/frontend/apps/console/src/features/flows/data/templates.json @@ -13021,5 +13021,136 @@ ] }, "nodes": [] + }, + { + "resourceType": "TEMPLATE", + "category": "STARTER", + "type": "BASIC_CONDITIONAL", + "flowType": "SIGNOUT", + "display": { + "label": "Confirm Sign Out Without a Hint", + "description": "Ask the user to confirm only when no valid id_token_hint was provided, otherwise sign out directly", + "image": "assets/images/icons/arrowhead-right-outline.svg", + "showOnResourcePanel": true + }, + "config": { + "name": "Conditional Sign Out Flow", + "handle": "conditional-signout-flow", + "nodes": [ + { + "id": "start", + "type": "START", + "layout": { + "size": { + "width": 101, + "height": 34 + }, + "position": { + "x": 62, + "y": 278 + } + }, + "onSuccess": "session_signout" + }, + { + "id": "session_signout", + "type": "TASK_EXECUTION", + "properties": { + "promptOnSignOut": true + }, + "layout": { + "size": { + "width": 217, + "height": 113 + }, + "position": { + "x": 463, + "y": 240 + } + }, + "executor": { + "name": "SessionSignOutExecutor" + }, + "onSuccess": "end", + "onIncomplete": "prompt_confirm" + }, + { + "id": "prompt_confirm", + "type": "PROMPT", + "layout": { + "size": { + "width": 350, + "height": 300 + }, + "position": { + "x": 900, + "y": 150 + } + }, + "meta": { + "components": [ + { + "align": "center", + "category": "DISPLAY", + "id": "text_signout_title", + "label": "{{ t(signout:forms.confirm.title) }}", + "resourceType": "ELEMENT", + "type": "TEXT", + "variant": "HEADING_1" + }, + { + "align": "center", + "category": "DISPLAY", + "id": "text_signout_desc", + "label": "{{ t(signout:forms.confirm.description) }}", + "resourceType": "ELEMENT", + "type": "TEXT", + "variant": "BODY_1" + }, + { + "category": "BLOCK", + "components": [ + { + "category": "ACTION", + "eventType": "SUBMIT", + "id": "action_confirm", + "label": "{{ t(signout:forms.confirm.actions.submit.label) }}", + "resourceType": "ELEMENT", + "type": "ACTION", + "variant": "PRIMARY" + } + ], + "id": "block_signout", + "resourceType": "ELEMENT", + "type": "BLOCK" + } + ] + }, + "prompts": [ + { + "action": { + "ref": "action_confirm", + "nextNode": "session_signout" + } + } + ] + }, + { + "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/__tests__/steps.test.ts b/frontend/apps/console/src/features/flows/models/__tests__/steps.test.ts index d6204fc031..4c3fa08a95 100644 --- a/frontend/apps/console/src/features/flows/models/__tests__/steps.test.ts +++ b/frontend/apps/console/src/features/flows/models/__tests__/steps.test.ts @@ -182,6 +182,10 @@ describe('steps models', () => { expect(ExecutionTypes.Session).toBe('SessionExecutor'); }); + it('should have SessionSignOut type', () => { + expect(ExecutionTypes.SessionSignOut).toBe('SessionSignOutExecutor'); + }); + it('should have AuthAssert type', () => { expect(ExecutionTypes.AuthAssert).toBe('AuthAssertExecutor'); }); @@ -190,8 +194,8 @@ describe('steps models', () => { expect(ExecutionTypes.Authorization).toBe('AuthorizationExecutor'); }); - it('should have exactly 25 execution types', () => { - expect(Object.keys(ExecutionTypes)).toHaveLength(25); + it('should have exactly 26 execution types', () => { + expect(Object.keys(ExecutionTypes)).toHaveLength(26); }); }); diff --git a/frontend/apps/console/src/features/flows/models/steps.ts b/frontend/apps/console/src/features/flows/models/steps.ts index de4e4a956e..302d470d75 100644 --- a/frontend/apps/console/src/features/flows/models/steps.ts +++ b/frontend/apps/console/src/features/flows/models/steps.ts @@ -121,6 +121,7 @@ export const ExecutionTypes = { UserTypeResolver: 'UserTypeResolver', SSOCheck: 'SSOCheckExecutor', Session: 'SessionExecutor', + SessionSignOut: 'SessionSignOutExecutor', AuthAssert: 'AuthAssertExecutor', Authorization: 'AuthorizationExecutor', } as const; diff --git a/frontend/apps/console/src/features/flows/utils/__tests__/resolveStepMetadata.test.ts b/frontend/apps/console/src/features/flows/utils/__tests__/resolveStepMetadata.test.ts index 892a6bcdb7..5aebf3b9d1 100644 --- a/frontend/apps/console/src/features/flows/utils/__tests__/resolveStepMetadata.test.ts +++ b/frontend/apps/console/src/features/flows/utils/__tests__/resolveStepMetadata.test.ts @@ -248,6 +248,43 @@ describe('resolveStepMetadata', () => { expect(properties.maxPerPrompt).toBe(5); }); + it('should coerce the sign-out prompt property string to a boolean based on the executor default', () => { + const steps: Step[] = [ + createMockStep({ + id: 'session-sign-out-step', + type: 'TASK_EXECUTION', + data: { + action: { + executor: {name: 'SessionSignOutExecutor'}, + }, + properties: { + promptOnSignOut: 'true', + }, + }, + }), + ]; + + const resources = createMockResources({ + executors: [ + createMockStep({ + type: 'TASK_EXECUTION', + data: { + action: { + executor: {name: 'SessionSignOutExecutor'}, + }, + properties: { + promptOnSignOut: false, + }, + }, + }), + ], + }); + + const result = resolveStepMetadata(resources, steps); + + expect(result[0].data.properties!.promptOnSignOut).toBe(true); + }); + it('should fall back to executor defaults for invalid numeric strings in persisted executor properties', () => { const steps: Step[] = [ createMockStep({ diff --git a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/ExecutionExtendedProperties.tsx b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/ExecutionExtendedProperties.tsx index 7466d18305..2ab162cad2 100644 --- a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/ExecutionExtendedProperties.tsx +++ b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/ExecutionExtendedProperties.tsx @@ -35,6 +35,7 @@ import OUResolverProperties from './execution-properties/OUResolverProperties'; import PasskeyProperties from './execution-properties/PasskeyProperties'; import PermissionValidatorProperties from './execution-properties/PermissionValidatorProperties'; import ProvisioningProperties from './execution-properties/ProvisioningProperties'; +import SessionSignOutProperties from './execution-properties/SessionSignOutProperties'; import SmsProperties from './execution-properties/SmsProperties'; import SsoCheckProperties from './execution-properties/SsoCheckProperties'; import UserTypeResolverProperties from './execution-properties/UserTypeResolverProperties'; @@ -130,6 +131,9 @@ function ExecutionExtendedProperties({resource, onChange}: ExecutionExtendedProp case ExecutionTypes.SSOCheck: executorSpecificProperties = ; break; + case ExecutionTypes.SessionSignOut: + executorSpecificProperties = ; + break; case ExecutionTypes.CredentialSetter: case ExecutionTypes.AttributeUniquenessValidator: case ExecutionTypes.Session: diff --git a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/ExecutionExtendedProperties.test.tsx b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/ExecutionExtendedProperties.test.tsx index 5e1cbff538..92d382cca6 100644 --- a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/ExecutionExtendedProperties.test.tsx +++ b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/__tests__/ExecutionExtendedProperties.test.tsx @@ -1092,6 +1092,38 @@ describe('ExecutionExtendedProperties', () => { }); }); + describe('Session Sign Out Executor', () => { + const signOutResource = { + id: 'session-sign-out-executor-1', + data: { + action: { + executor: { + name: ExecutionTypes.SessionSignOut, + }, + }, + properties: { + promptOnSignOut: false, + }, + }, + } as unknown as Resource; + + it('should render session sign out configuration', () => { + render(); + + expect(screen.getByText('flows:core.executions.sessionSignOut.description')).toBeInTheDocument(); + expect(screen.getByText('flows:core.executions.sessionSignOut.promptOnSignOut.label')).toBeInTheDocument(); + }); + + it('should call onChange without debounce when promptOnSignOut checkbox is toggled', () => { + render(); + + const checkbox = screen.getAllByRole('checkbox')[0]; + fireEvent.click(checkbox); + + expect(mockOnChange).toHaveBeenCalledWith('data.properties.promptOnSignOut', true, signOutResource); + }); + }); + describe('OU Executor', () => { const ouResource = { id: 'ou-executor-1', diff --git a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/SessionSignOutProperties.tsx b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/SessionSignOutProperties.tsx new file mode 100644 index 0000000000..5a20434c1f --- /dev/null +++ b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/SessionSignOutProperties.tsx @@ -0,0 +1,61 @@ +/** + * 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 {Checkbox, FormControlLabel, FormHelperText, Stack, Typography} from '@wso2/oxygen-ui'; +import {useCallback, useMemo, type ReactNode} from 'react'; +import {useTranslation} from 'react-i18next'; +import type {CommonResourcePropertiesPropsInterface} from './types'; +import type {StepData} from '@/features/flows/models/steps'; + +function SessionSignOutProperties({resource, onChange}: CommonResourcePropertiesPropsInterface): ReactNode { + const {t} = useTranslation(); + + const properties = useMemo(() => { + const stepData = resource?.data as StepData | undefined; + return stepData?.properties ?? {}; + }, [resource]); + + const handleBooleanPropertyChange = useCallback( + (propertyName: string, value: boolean): void => { + onChange(`data.properties.${propertyName}`, value, resource); + }, + [resource, onChange], + ); + + return ( + + + {t('flows:core.executions.sessionSignOut.description')} + + + handleBooleanPropertyChange('promptOnSignOut', e.target.checked)} + size="small" + /> + } + label={t('flows:core.executions.sessionSignOut.promptOnSignOut.label')} + /> + {t('flows:core.executions.sessionSignOut.promptOnSignOut.hint')} + + ); +} + +export default SessionSignOutProperties; diff --git a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/constants.ts b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/constants.ts index 90902216bb..c5b0261895 100644 --- a/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/constants.ts +++ b/frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/constants.ts @@ -143,4 +143,5 @@ export const EXECUTORS_WITH_FIXED_INPUTS = new Set([ ExecutionTypes.OpenID4VPVerify, ExecutionTypes.SSOCheck, ExecutionTypes.Session, + ExecutionTypes.SessionSignOut, ]); 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 4e6fc7912a..1e08f3b2f2 100644 --- a/frontend/apps/console/src/features/login-flow/data/executors.json +++ b/frontend/apps/console/src/features/login-flow/data/executors.json @@ -945,6 +945,9 @@ "name": "SessionSignOutExecutor" }, "onSuccess": "" + }, + "properties": { + "promptOnSignOut": false } } } diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index fb2ec2e328..7b08679fbd 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -3310,6 +3310,12 @@ const translations = { 'core.executions.provisioning.assignGroup.placeholder': 'Comma-separated group IDs to assign', 'core.executions.provisioning.assignRole.label': 'Assign Role', 'core.executions.provisioning.assignRole.placeholder': 'Comma-separated role IDs to assign', + + // Session sign out executor + 'core.executions.sessionSignOut.description': 'Configure the session sign out executor settings.', + 'core.executions.sessionSignOut.promptOnSignOut.label': 'Prompt for Confirmation', + 'core.executions.sessionSignOut.promptOnSignOut.hint': + 'Ask the user to confirm before signing out when the logout request has no valid ID token hint.', 'core.placeholders.dynamicInputPlaceholder.title': 'Dynamic Input', 'core.placeholders.dynamicInputPlaceholder.hint': 'Resolves input fields passed from runtime.',