diff --git a/docs/docs/apis/openidoauth/endpoints.mdx b/docs/docs/apis/openidoauth/endpoints.mdx index 13745eaeb1..79d533ab3a 100644 --- a/docs/docs/apis/openidoauth/endpoints.mdx +++ b/docs/docs/apis/openidoauth/endpoints.mdx @@ -656,12 +656,14 @@ The endpoint has to be opened in the user agent (browser) to terminate the user No parameters are needed apart from the user agent cookie, but you can provide the following to customize the behavior: -| Parameter | Description | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| id_token_hint | the id_token that was previously issued to the client | -| client_id | client_id of the application | -| post_logout_redirect_uri | Callback uri of the logout where the user (agent) will be redirected to. Must match exactly one of the preregistered in Console. | -| state | Opaque value used to maintain state between the request and the callback | +| Parameter | Description | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| id_token_hint | the id_token that was previously issued to the client | +| client_id | client_id of the application | +| post_logout_redirect_uri | Callback uri of the logout where the user (agent) will be redirected to. Must match exactly one of the preregistered in Console. | +| state | Opaque value used to maintain state between the request and the callback | +| logout_hint | A valid login name of a user. Will be used to select the user to logout. Only supported when using the login UI V2. | +| ui_locales | Spaces delimited list of preferred locales for the login UI, e.g. `de-CH de en`. If none is provided or matches the possible locales provided by the login UI, the `accept-language` header of the browser will be taken into account. | The `post_logout_redirect_uri` will be checked against the previously registered uris of the client provided by the `azp` claim of the `id_token_hint` or the `client_id` parameter. If both parameters are provided, they must be equal. diff --git a/go.mod b/go.mod index 22980acfaf..eb4856d087 100644 --- a/go.mod +++ b/go.mod @@ -82,7 +82,7 @@ require ( github.com/twilio/twilio-go v1.26.1 github.com/zitadel/exifremove v0.1.0 github.com/zitadel/logging v0.6.2 - github.com/zitadel/oidc/v3 v3.39.1 + github.com/zitadel/oidc/v3 v3.42.0 github.com/zitadel/passwap v0.9.0 github.com/zitadel/saml v0.3.5 github.com/zitadel/schema v1.3.1 @@ -101,8 +101,8 @@ require ( golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 golang.org/x/net v0.40.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.15.0 - golang.org/x/text v0.26.0 + golang.org/x/sync v0.16.0 + golang.org/x/text v0.27.0 google.golang.org/api v0.233.0 google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 google.golang.org/grpc v1.72.1 @@ -119,7 +119,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect - github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.0 // indirect github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect github.com/crewjam/httperr v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect diff --git a/go.sum b/go.sum index 7221111a2b..48f90f954a 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= -github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA= +github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -810,8 +810,10 @@ github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8 github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek= github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= -github.com/zitadel/oidc/v3 v3.39.1 h1:6QwGwI3yxh4somT7fwRCeT1KOn/HOGv0PA0dFciwJjE= -github.com/zitadel/oidc/v3 v3.39.1/go.mod h1:aH8brOrzoliAybVdfq2xIdGvbtl0j/VsKRNa7WE72gI= +github.com/zitadel/oidc/v3 v3.41.1-0.20250718152526-16ebef905b40 h1:MmUhfhwIcPStWqsTW+Pw+kYa5SNY7TxwzktUDohwO78= +github.com/zitadel/oidc/v3 v3.41.1-0.20250718152526-16ebef905b40/go.mod h1:Y/rY7mHTzMGrZgf7REgQZFWxySlaSVqqFdBmNZq+9wA= +github.com/zitadel/oidc/v3 v3.42.0 h1:cqlCYIEapmDprp5a5hUl9ivkUOu1SQxOqbrKdalHqGk= +github.com/zitadel/oidc/v3 v3.42.0/go.mod h1:Y/rY7mHTzMGrZgf7REgQZFWxySlaSVqqFdBmNZq+9wA= github.com/zitadel/passwap v0.9.0 h1:QvDK8OHKdb73C0m+mwXvu87UJSBqix3oFwTVENHdv80= github.com/zitadel/passwap v0.9.0/go.mod h1:6QzwFjDkIr3FfudzSogTOx5Ydhq4046dRJtDM/kX+G8= github.com/zitadel/saml v0.3.5 h1:L1RKWS5y66cGepVxUGjx/WSBOtrtSpRA/J3nn5BJLOY= @@ -948,8 +950,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -992,8 +994,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index b29e157fc2..152176a59c 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -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. diff --git a/internal/api/oidc/auth_request_test.go b/internal/api/oidc/auth_request_test.go new file mode 100644 index 0000000000..0210ead49e --- /dev/null +++ b/internal/api/oidc/auth_request_test.go @@ -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)) + } + }) + } +} diff --git a/internal/api/oidc/integration_test/auth_request_test.go b/internal/api/oidc/integration_test/auth_request_test.go index ad78184a04..77e389f7be 100644 --- a/internal/api/oidc/integration_test/auth_request_test.go +++ b/internal/api/oidc/integration_test/auth_request_test.go @@ -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()) diff --git a/internal/api/oidc/integration_test/oidc_test.go b/internal/api/oidc/integration_test/oidc_test.go index 2b43154743..d3d80b0557 100644 --- a/internal/api/oidc/integration_test/oidc_test.go +++ b/internal/api/oidc/integration_test/oidc_test.go @@ -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())