Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 3 additions & 34 deletions backend/cmd/server/bootstrap/01-default-resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -575,53 +575,22 @@ flowType: SIGNOUT
nodes:
- id: start
type: START
onSuccess: prompt_confirm
onSuccess: session_signout
layout:
size:
width: 101
height: 34
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:
size:
width: 217
height: 113
position:
x: 960
x: 463
Comment on lines +578 to +593

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🔴 Documentation Required

This PR introduces user-facing changes that are not covered by documentation updates under docs/.
Please update the relevant documentation before merging.

Missing documentation:

  • Default and console sign-out flows: Update the relevant sign-out flow guide under docs/content/guides/ to describe immediate default sign-out, the Silent Sign Out template, and Conditional Confirmation behavior.
  • OAuth logout POST parameters: Update docs/content/apis.mdx to document form-encoded POST support for the logout endpoint and its accepted parameters.
📍 Affects 3 files
  • backend/cmd/server/bootstrap/01-default-resources.yaml#L578-L593 (this comment)
  • frontend/apps/console/src/features/flows/data/templates.json#L12898-L12963
  • backend/internal/oauth/oauth2/logout/handler.go#L73-L75
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cmd/server/bootstrap/01-default-resources.yaml` around lines 578 -
593, The sign-out flow and OAuth logout behavior require documentation updates.
In the relevant sign-out guide under docs/content/guides/, document immediate
default sign-out, the Silent Sign Out template, and Conditional Confirmation
behavior; in docs/content/apis.mdx, document form-encoded POST support for the
logout endpoint and its accepted parameters. The YAML, templates.json, and
handler.go sites require no direct code changes.

Source: Path instructions

y: 240
executor:
name: SessionSignOutExecutor
Expand All @@ -633,7 +602,7 @@ nodes:
width: 85
height: 34
position:
x: 1400
x: 900
y: 278
---
resource_type: flow
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/oauth/oauth2/logout/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions backend/internal/oauth/oauth2/logout/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
70 changes: 68 additions & 2 deletions frontend/apps/console/src/features/flows/data/templates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down
14 changes: 14 additions & 0 deletions frontend/apps/console/src/hocs/__tests__/withConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ vi.mock('@thunderid/react', () => ({
signInOptions,
preferences,
sendCookiesInRequests,
sendIdTokenInLogoutRequest,
discovery,
/* eslint-enable react/require-default-props */
}: {
Expand All @@ -77,6 +78,7 @@ vi.mock('@thunderid/react', () => ({
signInOptions?: Record<string, string>;
preferences?: Record<string, unknown>;
sendCookiesInRequests?: boolean;
sendIdTokenInLogoutRequest?: boolean;
discovery?: Record<string, unknown>;
}) => {
capturedProviderProps = {
Expand All @@ -88,6 +90,7 @@ vi.mock('@thunderid/react', () => ({
signInOptions,
preferences,
sendCookiesInRequests,
sendIdTokenInLogoutRequest,
discovery,
};
return (
Expand Down Expand Up @@ -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(<WithConfigComponent />);
expect(capturedProviderProps.sendIdTokenInLogoutRequest).toBe(false);
});
});

// --- resource indicator ---

describe('resource indicator', () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/apps/console/src/hocs/withConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default function withConfig<P extends object>(WrappedComponent: Component
const sdkDefaults: Partial<ThunderIDProviderProps> = {
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`).
Expand Down
Loading