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.',