feat(OIDC): handle logout hint on end_session_endpoint (#10039)

# Which Problems Are Solved

The OIDC session endpoint allows to pass a `id_token_hint` to identify
the session to terminate. In case the application is not able to pass
that, e.g. Console currently allows multiple sessions to be open, but
will only store the id_token of the current session, allowing to pass
the `logout_hint` to identify the user adds some new possibilities.

# How the Problems Are Solved

In case the end_session_endpoint is called with no `id_token_hint`, but
a `logout_hint` and the v2 login UI is configured, the information is
passed to the login UI also as `login_hint` parameter to allow the login
UI to determine the session to be terminated, resp. let the user decide.

# Additional Changes

Also added the `ui_locales` as parameter to handle and pass to the V2
login UI.

# Dependencies ⚠️ 

~These changes depend on https://github.com/zitadel/oidc/pull/774~

# Additional Context

closes #9847

---------

Co-authored-by: Marco Ardizzone <marco@zitadel.com>
This commit is contained in:
Livio Spring
2025-07-28 09:55:55 -04:00
committed by GitHub
parent e4f633bcb3
commit 5d2d1d6da6
7 changed files with 169 additions and 35 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
http_utils "github.com/zitadel/zitadel/internal/api/http"
@@ -30,6 +31,8 @@ import (
const (
LoginClientHeader = "x-zitadel-login-client"
LoginPostLogoutRedirectParam = "post_logout_redirect"
LoginLogoutHintParam = "logout_hint"
LoginUILocalesParam = "ui_locales"
LoginPath = "/login"
LogoutPath = "/logout"
LogoutDonePath = "/logout/done"
@@ -283,14 +286,19 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
// we'll redirect to the UI (V2) and let it decide which session to terminate
//
// If there's no id_token_hint and for v1 logins, we handle them separately
if endSessionRequest.IDTokenHintClaims == nil &&
(authz.GetFeatures(ctx).LoginV2.Required || headers.Get(LoginClientHeader) != "") {
if endSessionRequest.IDTokenHintClaims == nil && (authz.GetFeatures(ctx).LoginV2.Required || headers.Get(LoginClientHeader) != "") {
redirectURI := v2PostLogoutRedirectURI(endSessionRequest.RedirectURI)
// if no base uri is set, fallback to the default configured in the runtime config
if authz.GetFeatures(ctx).LoginV2.BaseURI == nil || authz.GetFeatures(ctx).LoginV2.BaseURI.String() == "" {
return o.defaultLogoutURLV2 + redirectURI, nil
logoutURI := authz.GetFeatures(ctx).LoginV2.BaseURI
// if no logout uri is set, fallback to the default configured in the runtime config
if logoutURI == nil || logoutURI.String() == "" {
logoutURI, err = url.Parse(o.defaultLogoutURLV2)
if err != nil {
return "", err
}
} else {
logoutURI = logoutURI.JoinPath(LogoutPath)
}
return buildLoginV2LogoutURL(authz.GetFeatures(ctx).LoginV2.BaseURI, redirectURI), nil
return buildLoginV2LogoutURL(logoutURI, redirectURI, endSessionRequest.LogoutHint, endSessionRequest.UILocales), nil
}
// V1:
@@ -367,12 +375,25 @@ func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postL
return login.ExternalLogoutPath(sessionID)
}
func buildLoginV2LogoutURL(baseURI *url.URL, redirectURI string) string {
baseURI.JoinPath(LogoutPath)
q := baseURI.Query()
func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, uiLocales []language.Tag) string {
if strings.HasSuffix(logoutURI.Path, "/") && len(logoutURI.Path) > 1 {
logoutURI.Path = strings.TrimSuffix(logoutURI.Path, "/")
}
q := logoutURI.Query()
q.Set(LoginPostLogoutRedirectParam, redirectURI)
baseURI.RawQuery = q.Encode()
return baseURI.String()
if logoutHint != "" {
q.Set(LoginLogoutHintParam, logoutHint)
}
if len(uiLocales) > 0 {
locales := make([]string, len(uiLocales))
for i, locale := range uiLocales {
locales[i] = locale.String()
}
q.Set(LoginUILocalesParam, strings.Join(locales, " "))
}
logoutURI.RawQuery = q.Encode()
return logoutURI.String()
}
// v2PostLogoutRedirectURI will take care that the post_logout_redirect_uri is correctly set for v2 logins.

View File

@@ -0,0 +1,98 @@
package oidc
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
)
func TestBuildLoginV2LogoutURL(t *testing.T) {
t.Parallel()
tt := []struct {
testName string
logoutURIStr string
redirectURI string
logoutHint string
uiLocales []language.Tag
expectedParams map[string]string
}{
{
testName: "basic with only redirectURI",
logoutURIStr: "https://example.com/logout",
redirectURI: "https://client/cb",
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
},
},
{
testName: "with logout hint",
logoutURIStr: "https://example.com/logout",
redirectURI: "https://client/cb",
logoutHint: "user@example.com",
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"logout_hint": "user@example.com",
},
},
{
testName: "with ui_locales",
logoutURIStr: "https://example.com/logout",
redirectURI: "https://client/cb",
uiLocales: []language.Tag{language.English, language.Italian},
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"ui_locales": "en it",
},
},
{
testName: "with all params",
logoutURIStr: "https://example.com/logout",
redirectURI: "https://client/cb",
logoutHint: "logoutme",
uiLocales: []language.Tag{language.Make("de-CH"), language.Make("fr")},
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"logout_hint": "logoutme",
"ui_locales": "de-CH fr",
},
},
{
testName: "base with trailing slash",
logoutURIStr: "https://example.com/logout/",
redirectURI: "https://client/cb",
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
// t.Parallel()
// Given
logoutURI, err := url.Parse(tc.logoutURIStr)
require.NoError(t, err)
// When
got := buildLoginV2LogoutURL(logoutURI, tc.redirectURI, tc.logoutHint, tc.uiLocales)
// Then
gotURL, err := url.Parse(got)
require.NoError(t, err)
require.NotContains(t, gotURL.String(), "/logout/")
q := gotURL.Query()
// Ensure no unexpected params
require.Len(t, q, len(tc.expectedParams))
for k, v := range tc.expectedParams {
assert.Equal(t, v, q.Get(k))
}
})
}
}

View File

@@ -498,7 +498,7 @@ func TestOPStorage_TerminateSession(t *testing.T) {
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state", "", nil)
require.NoError(t, err)
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
@@ -535,7 +535,7 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) {
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state", "", nil)
require.NoError(t, err)
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
@@ -551,6 +551,17 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) {
require.NoError(t, err)
}
func buildLogoutURL(origin, logoutURLV2 string, redirectURI string, extraParams map[string]string) string {
u, _ := url.Parse(origin + logoutURLV2 + redirectURI)
q := u.Query()
for k, v := range extraParams {
q.Set(k, v)
}
u.RawQuery = q.Encode()
// Append the redirect URI as a URL-escaped string
return u.String()
}
func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
tests := []struct {
name string
@@ -565,7 +576,7 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
return clientID
}(),
authRequestID: createAuthRequest,
logoutURL: http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure) + Instance.Config.LogoutURLV2 + logoutRedirectURI + "?state=state",
logoutURL: buildLogoutURL(http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure), Instance.Config.LogoutURLV2, logoutRedirectURI+"?state=state", map[string]string{"logout_hint": "hint", "ui_locales": "it-IT en-US"}),
},
{
name: "login v2 config",
@@ -574,7 +585,7 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
return clientID
}(),
authRequestID: createAuthRequestNoLoginClientHeader,
logoutURL: http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure) + Instance.Config.LogoutURLV2 + logoutRedirectURI + "?state=state",
logoutURL: buildLogoutURL(http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure), Instance.Config.LogoutURLV2, logoutRedirectURI+"?state=state", map[string]string{"logout_hint": "hint", "ui_locales": "it-IT en-US"}),
},
}
for _, tt := range tests {
@@ -601,7 +612,7 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state")
postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state", "hint", oidc.ParseLocales([]string{"it-IT", "en-US"}))
require.NoError(t, err)
assert.Equal(t, tt.logoutURL, postLogoutRedirect.String())

View File

@@ -311,7 +311,7 @@ func Test_ZITADEL_API_terminated_session(t *testing.T) {
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
// end session
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state", "", nil)
require.NoError(t, err)
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())