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
1 change: 1 addition & 0 deletions backend/dbscripts/runtimedb/postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ CREATE TABLE "RUNTIME_STORE_ATTRIBUTE_CACHE" PARTITION OF "RUNTIME_STORE" FOR VA
CREATE TABLE "RUNTIME_STORE_FLOW_STATE" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('flow:state');
CREATE TABLE "RUNTIME_STORE_AUTHZ_CODE" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('authz:code');
CREATE TABLE "RUNTIME_STORE_AUTHZ_REQ" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('authz:req');
CREATE TABLE "RUNTIME_STORE_LOGOUT_REQ" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('logout:req');
CREATE TABLE "RUNTIME_STORE_PAR_REQ" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('par:req');
CREATE TABLE "RUNTIME_STORE_CIBA_REQ" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('ciba:req');
CREATE TABLE "RUNTIME_STORE_JTI_TOKEN" PARTITION OF "RUNTIME_STORE" FOR VALUES IN ('jti:token');
Expand Down
128 changes: 128 additions & 0 deletions tests/integration/oauth/sso/rp_logout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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.
*/

package sso

import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"

"github.com/thunder-id/thunderid/tests/integration/testutils"
)

// TestRPInitiatedLogoutEndsSession drives a full OIDC RP-Initiated Logout: after establishing an SSO
// session it hits the end_session_endpoint with a valid id_token_hint and a registered
// post_logout_redirect_uri, runs the sign-out flow the gate would run, and confirms the completion
// callback returns the post-logout redirect. It then asserts the SSO cookie is cleared and a fresh
// authorize re-prompts for credentials, proving the session was terminated.
func (ts *SSOLogoutTestSuite) TestRPInitiatedLogoutEndsSession() {
client := ts.newSessionClient()

idToken := ts.login(client, logoutUsername, "logout_state_1")
ts.Require().NotEmpty(ts.ssoCookieNames(client), "an SSO cookie should be set after login")

// Initiate RP-initiated logout; the endpoint redirects the browser to the gate sign-out page.
executionID, logoutID := ts.initiateLogout(client, idToken, postLogoutRedirectURI, "logout_state_2")
ts.Require().NotEmpty(executionID, "the sign-out flow execution id should be present")
ts.Require().NotEmpty(logoutID, "the logout id should be present")

// Run the sign-out flow (what the gate does): it terminates the session and clears the cookie.
step := ts.flowExecute(client, map[string]interface{}{"executionId": executionID})
ts.Require().Equal("COMPLETE", step.FlowStatus, "the sign-out flow should complete")

// The completion callback consumes the stored request and returns the validated post-logout
// redirect with state appended.
redirect := ts.completeLogout(client, logoutID)
parsed, err := url.Parse(redirect)
ts.Require().NoError(err, "failed to parse post-logout redirect")
ts.Equal(postLogoutRedirectURI, parsed.Scheme+"://"+parsed.Host+parsed.Path,
"post-logout redirect should match the registered URI")
ts.Equal("logout_state_2", parsed.Query().Get("state"), "state should be echoed on the post-logout redirect")

// The sign-out flow cleared the per-flow cookie.
ts.Empty(ts.ssoCookieNames(client), "the SSO cookie should be cleared after sign-out")

// A fresh authorize now presents the credential prompt again: the SSO session is gone, so the
// flow can no longer be skipped. Assert the exact prompt step (an INCOMPLETE credential VIEW)
// rather than merely "not COMPLETE", which a failed or unrelated incomplete state would also
// satisfy without proving re-authentication was requested.
_, reAuthExecutionID := ts.authorize(client, "openid", "logout_state_3")
reAuthStep := ts.flowExecute(client, map[string]interface{}{"executionId": reAuthExecutionID})
ts.Equal("INCOMPLETE", reAuthStep.FlowStatus, "after sign-out, authorize must re-prompt for credentials")
ts.Equal("VIEW", reAuthStep.Type, "the re-prompt should render the credential input view")
ts.NotNil(reAuthStep.Data, "the credential prompt should carry view data")
ts.Empty(reAuthStep.Assertion, "no assertion should be issued when credentials are still required")
}

// initiateLogout posts to the end_session_endpoint and returns the sign-out flow executionId and the
// logoutId carried on the gate sign-out redirect.
func (ts *SSOLogoutTestSuite) initiateLogout(
client *http.Client, idTokenHint, postLogoutRedirect, state string,
) (string, string) {
form := url.Values{}
if idTokenHint != "" {
form.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirect != "" {
form.Set("post_logout_redirect_uri", postLogoutRedirect)
}
if state != "" {
form.Set("state", state)
}

req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/logout", strings.NewReader(form.Encode()))
ts.Require().NoError(err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := client.Do(req)
ts.Require().NoError(err, "logout request failed")
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
ts.Require().Equal(http.StatusFound, resp.StatusCode,
"logout should redirect to the gate sign-out page: %s", string(body))

parsed, err := url.Parse(resp.Header.Get("Location"))
ts.Require().NoError(err, "failed to parse gate sign-out redirect")
query := parsed.Query()
return query.Get("executionId"), query.Get("logoutId")
}

// completeLogout posts the logout id to the completion callback and returns the post-logout redirect URI.
func (ts *SSOLogoutTestSuite) completeLogout(client *http.Client, logoutID string) string {
body := strings.NewReader(`{"logoutId":"` + logoutID + `"}`)
req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/logout/callback", body)
ts.Require().NoError(err)
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
ts.Require().NoError(err, "logout callback request failed")
defer resp.Body.Close()

respBody, _ := io.ReadAll(resp.Body)
ts.Require().Equal(http.StatusOK, resp.StatusCode, "logout callback failed: %s", string(respBody))

var out struct {
RedirectURI string `json:"redirect_uri"`
}
ts.Require().NoError(json.Unmarshal(respBody, &out), "failed to decode logout callback response")
return out.RedirectURI
}
39 changes: 39 additions & 0 deletions tests/integration/oauth/sso/sso_reuse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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.
*/

package sso

// TestSSOSessionReuseSkipsAuthentication verifies the core SSO promise: once a per-flow session is
// established, a subsequent authorize on the same flow (carrying the SSO cookie) is satisfied without
// re-prompting for credentials. The initial /flow/execute step completes immediately, whereas a
// first-time login would return a credential prompt.
func (ts *SSOLogoutTestSuite) TestSSOSessionReuseSkipsAuthentication() {
client := ts.newSessionClient()

// First login establishes the SSO session and sets the per-flow cookie.
ts.login(client, ssoReuseUsername, "reuse_state_1")
ts.Require().NotEmpty(ts.ssoCookieNames(client), "an SSO cookie should be set after first login")

// Second authorize with the SSO cookie present: SSO_CHECK finds the live session and the flow
// completes on its initial step, skipping the credential prompt.
_, executionID := ts.authorize(client, "openid", "reuse_state_2")
step := ts.flowExecute(client, map[string]interface{}{"executionId": executionID})

ts.Equal("COMPLETE", step.FlowStatus, "second authorize should skip authentication via SSO")
ts.NotEmpty(step.Assertion, "SSO-skipped flow should still yield an assertion")
}
Loading
Loading