From 90d704313352dbbe418eac5baa8a089ac264e2cd Mon Sep 17 00:00:00 2001 From: Maduranga Siriwardena Date: Fri, 31 Jul 2026 12:16:47 +0530 Subject: [PATCH] Sign out without a confirmation prompt Drop the unconditional confirmation prompt from the Default Sign Out Flow so signing out completes on the first flow execution, and stop sending id_token_hint from the console so no signed token carrying identity claims lands in the sign-out URL. Read the logout parameters from r.Form rather than the query string. On a POST to the end_session_endpoint the parameters arrive in the form body, so the query string carried none of them and the sign-out flow received an empty parameter set. Add a sign-out flow template that terminates the session with no confirmation step, mirroring the new default. --- .../bootstrap/01-default-resources.yaml | 37 +--------- .../internal/oauth/oauth2/logout/handler.go | 4 +- .../oauth/oauth2/logout/handler_test.go | 28 ++++++-- .../src/features/flows/data/templates.json | 70 ++++++++++++++++++- .../src/hocs/__tests__/withConfig.test.tsx | 14 ++++ frontend/apps/console/src/hocs/withConfig.tsx | 1 + 6 files changed, 112 insertions(+), 42 deletions(-) diff --git a/backend/cmd/server/bootstrap/01-default-resources.yaml b/backend/cmd/server/bootstrap/01-default-resources.yaml index a53e11ab3c..42aa640666 100644 --- a/backend/cmd/server/bootstrap/01-default-resources.yaml +++ b/backend/cmd/server/bootstrap/01-default-resources.yaml @@ -575,7 +575,7 @@ flowType: SIGNOUT nodes: - id: start type: START - onSuccess: prompt_confirm + onSuccess: session_signout layout: size: width: 101 @@ -583,37 +583,6 @@ nodes: position: x: 62 y: 278 -- id: prompt_confirm - type: PROMPT - layout: - size: - width: 350 - height: 300 - position: - x: 463 - y: 150 - meta: - components: - - type: TEXT - id: text_signout_title - label: '{{ t(signout:forms.confirm.title) }}' - variant: HEADING_1 - - type: TEXT - id: text_signout_desc - label: '{{ t(signout:forms.confirm.description) }}' - variant: BODY_1 - - type: BLOCK - id: block_signout - components: - - type: ACTION - id: action_confirm - label: '{{ t(signout:forms.confirm.actions.submit.label) }}' - variant: PRIMARY - eventType: SUBMIT - prompts: - - action: - ref: action_confirm - nextNode: session_signout - id: session_signout type: TASK_EXECUTION layout: @@ -621,7 +590,7 @@ nodes: width: 217 height: 113 position: - x: 960 + x: 463 y: 240 executor: name: SessionSignOutExecutor @@ -633,7 +602,7 @@ nodes: width: 85 height: 34 position: - x: 1400 + x: 900 y: 278 --- resource_type: flow diff --git a/backend/internal/oauth/oauth2/logout/handler.go b/backend/internal/oauth/oauth2/logout/handler.go index 8dbf9c5d7e..250a05032a 100644 --- a/backend/internal/oauth/oauth2/logout/handler.go +++ b/backend/internal/oauth/oauth2/logout/handler.go @@ -70,7 +70,9 @@ func (h *logoutHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { PostLogoutRedirectURI: r.FormValue(constants.RequestParamPostLogoutRedirect), State: r.FormValue(constants.RequestParamState), Headers: sysutils.SanitizeRawMultiValueStringMap(r.Header), - QueryParams: sysutils.SanitizeRawMultiValueStringMap(r.URL.Query()), + // r.Form, not r.URL.Query(): on a POST the parameters arrive in the form body, which the query + // string does not carry. ParseForm merges both, so the flow sees the same set either way. + QueryParams: sysutils.SanitizeRawMultiValueStringMap(r.Form), } // Validate before initiating anything: the post-logout redirect URI is validated here (against the diff --git a/backend/internal/oauth/oauth2/logout/handler_test.go b/backend/internal/oauth/oauth2/logout/handler_test.go index 10f8b5aa90..03f13275d4 100644 --- a/backend/internal/oauth/oauth2/logout/handler_test.go +++ b/backend/internal/oauth/oauth2/logout/handler_test.go @@ -24,6 +24,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -32,6 +33,7 @@ import ( "github.com/thunder-id/thunderid/internal/flow/flowexec" oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/system/config" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" @@ -166,19 +168,33 @@ func (suite *LogoutHandlerTestSuite) TestHandleLogout_FlowInitiationError_MapsSt } } -// A POST to the end_session_endpoint initiates the flow and redirects to the gate, exactly like GET. -func (suite *LogoutHandlerTestSuite) TestHandleLogout_POSTInitiatesAndRedirects() { +// A POST to the end_session_endpoint carries its parameters in the form body rather than the query +// string. The handler must read them from there and forward them to the sign-out flow, so that a POST +// behaves exactly like a GET. +func (suite *LogoutHandlerTestSuite) TestHandleLogout_POSTReadsFormBodyAndRedirects() { actor := actorprovidermock.NewActorProviderMock(suite.T()) - actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x").Return(clientWithPostLogout(), nil) + actor.EXPECT().GetOAuthClientByClientID(mock.Anything, "client-x"). + Return(clientWithPostLogout("https://rp.example/after"), nil) flowSvc := flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) - flowSvc.EXPECT().InitiateFlow(mock.Anything, mock.Anything).Return("exec-2", nil) + var capturedParams map[string][]string + flowSvc.EXPECT().InitiateFlow(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, ic *flowexec.FlowInitContext) (string, *tidcommon.ServiceError) { + capturedParams = ic.InitiatorRequest.QueryParams + return "exec-2", nil + }) store := newLogoutRequestStoreInterfaceMock(suite.T()) store.EXPECT().AddRequest(mock.Anything, mock.Anything).Return("logout-1", nil) svc := newLogoutService(jwtmock.NewJWTServiceInterfaceMock(suite.T()), actor, flowSvc, store, testIssuer) handler := newLogoutHandler(svc, gateConfig()) - req := httptest.NewRequest(http.MethodPost, "/oauth2/logout?client_id=client-x", nil) + body := url.Values{ + "client_id": {"client-x"}, + "post_logout_redirect_uri": {"https://rp.example/after"}, + "state": {"xyz"}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth2/logout", strings.NewReader(body.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() handler.HandleLogout(rec, req) @@ -187,6 +203,8 @@ func (suite *LogoutHandlerTestSuite) TestHandleLogout_POSTInitiatesAndRedirects( location := rec.Header().Get("Location") suite.Contains(location, "https://gate.example:9443/signout") suite.Contains(location, "executionId=exec-2") + suite.Equal([]string{"xyz"}, capturedParams[constants.RequestParamState], + "body parameters must reach the sign-out flow the same way query parameters do") } // The completion callback consumes the stored logout request and returns the post-logout redirect URI. diff --git a/frontend/apps/console/src/features/flows/data/templates.json b/frontend/apps/console/src/features/flows/data/templates.json index 0ff7b7e068..e6bd3f6ea4 100644 --- a/frontend/apps/console/src/features/flows/data/templates.json +++ b/frontend/apps/console/src/features/flows/data/templates.json @@ -12895,6 +12895,72 @@ }, "nodes": [] }, + { + "resourceType": "TEMPLATE", + "category": "STARTER", + "type": "DIRECT", + "flowType": "SIGNOUT", + "display": { + "label": "Silent Sign Out", + "description": "Terminate the user's SSO session immediately, with no confirmation step", + "image": "assets/images/icons/arrowhead-right-outline.svg", + "showOnResourcePanel": true + }, + "config": { + "name": "Silent Sign Out Flow", + "handle": "silent-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", + "layout": { + "size": { + "width": 217, + "height": 113 + }, + "position": { + "x": 463, + "y": 240 + } + }, + "executor": { + "name": "SessionSignOutExecutor" + }, + "onSuccess": "end" + }, + { + "id": "end", + "type": "END", + "layout": { + "size": { + "width": 85, + "height": 34 + }, + "position": { + "x": 900, + "y": 278 + } + } + } + ] + }, + "nodes": [] + }, { "resourceType": "TEMPLATE", "category": "STARTER", @@ -13028,8 +13094,8 @@ "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", + "label": "Conditional Confirmation", + "description": "Ask the user to confirm only when the request has no ID token hint, otherwise sign out immediately", "image": "assets/images/icons/arrowhead-right-outline.svg", "showOnResourcePanel": true }, diff --git a/frontend/apps/console/src/hocs/__tests__/withConfig.test.tsx b/frontend/apps/console/src/hocs/__tests__/withConfig.test.tsx index 8d80ee5a41..0ef281c1ae 100644 --- a/frontend/apps/console/src/hocs/__tests__/withConfig.test.tsx +++ b/frontend/apps/console/src/hocs/__tests__/withConfig.test.tsx @@ -65,6 +65,7 @@ vi.mock('@thunderid/react', () => ({ signInOptions, preferences, sendCookiesInRequests, + sendIdTokenInLogoutRequest, discovery, /* eslint-enable react/require-default-props */ }: { @@ -77,6 +78,7 @@ vi.mock('@thunderid/react', () => ({ signInOptions?: Record; preferences?: Record; sendCookiesInRequests?: boolean; + sendIdTokenInLogoutRequest?: boolean; discovery?: Record; }) => { capturedProviderProps = { @@ -88,6 +90,7 @@ vi.mock('@thunderid/react', () => ({ signInOptions, preferences, sendCookiesInRequests, + sendIdTokenInLogoutRequest, discovery, }; return ( @@ -369,6 +372,17 @@ describe('withConfig (console)', () => { }); }); + // --- RP-initiated logout --- + + describe('rp-initiated logout', () => { + it('sets sendIdTokenInLogoutRequest=false so the ID token stays out of the sign-out URL', () => { + mockGetClientUrl.mockReturnValue('https://client.example.com'); + + render(); + expect(capturedProviderProps.sendIdTokenInLogoutRequest).toBe(false); + }); + }); + // --- resource indicator --- describe('resource indicator', () => { diff --git a/frontend/apps/console/src/hocs/withConfig.tsx b/frontend/apps/console/src/hocs/withConfig.tsx index 324070f2da..4608ee1831 100644 --- a/frontend/apps/console/src/hocs/withConfig.tsx +++ b/frontend/apps/console/src/hocs/withConfig.tsx @@ -46,6 +46,7 @@ export default function withConfig

(WrappedComponent: Component const sdkDefaults: Partial = { discovery: {wellKnown: {enabled: true}}, ...(resourceIdentifier ? {signInOptions: {resource: resourceIdentifier}} : {}), + sendIdTokenInLogoutRequest: false, // When the trusted issuer is a generic OIDC provider, suppress the SDK's // product-specific bootstrap calls that would otherwise 404 / be CORS-blocked // at the external authorization server: flow metadata (`{baseUrl}/flow/meta`).