From 35dfe144f734ab1c676403ae5670db135a442bb3 Mon Sep 17 00:00:00 2001 From: Maduranga Siriwardena Date: Thu, 23 Jul 2026 21:28:26 +0530 Subject: [PATCH] Make id_token_hint optional for RP-initiated logout Drop the requirement that id_token_hint accompany post_logout_redirect_uri. A logout carrying only client_id (with or without a registered post_logout_redirect_uri) now resolves; the redirect target is still confirmed against the client's registered allow-list, satisfying the OIDC RP-Initiated Logout requirement for confirming the redirection target. When no valid id_token_hint is supplied, the OAuth layer marks the sign-out flow with logoutPromptRequired. SessionSignOutExecutor reads it and, when its node opts in via the promptOnSignOut property, routes to the node's onIncomplete confirmation prompt before terminating the session, prompting once per flow run. Nodes that do not opt in terminate as before, leaving the always-prompt default sign-out flow unchanged. Add a Confirm Sign Out Without a Hint flow-builder template that wires this behavior with builder-native constructs (an onIncomplete edge and the promptOnSignOut property) so it renders and round-trips in the flow editor. Expose promptOnSignOut as an editable checkbox on the End Session executor in the Console flow editor, mirroring how other executors surface node properties. The property is a boolean, consistent with the provisioning executor's options. --- backend/internal/flow/common/constants.go | 8 ++ backend/internal/flow/executor/constants.go | 4 + .../flow/executor/session_signout_executor.go | 31 +++++ .../executor/session_signout_executor_test.go | 77 ++++++++++ .../internal/oauth/oauth2/logout/service.go | 28 ++-- .../oauth/oauth2/logout/service_test.go | 55 +++++++- .../src/features/flows/data/templates.json | 131 ++++++++++++++++++ .../flows/models/__tests__/steps.test.ts | 8 +- .../src/features/flows/models/steps.ts | 1 + .../__tests__/resolveStepMetadata.test.ts | 37 +++++ .../ExecutionExtendedProperties.tsx | 4 + .../ExecutionExtendedProperties.test.tsx | 32 +++++ .../SessionSignOutProperties.tsx | 61 ++++++++ .../execution-properties/constants.ts | 1 + .../features/login-flow/data/executors.json | 3 + frontend/packages/i18n/src/locales/en-US.ts | 6 + 16 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 frontend/apps/console/src/features/login-flow/components/resource-property-panel/extended-properties/execution-properties/SessionSignOutProperties.tsx 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.',