Merge commit from fork

* fix: Improve host header handling and deployment mode detection

* ZITADEL_CLOUD env

* refactor service config

* service config

* cleanup

* cleanup

* util for original and public host

* instanceHost, publicHost

* cleanup constructUrl

* cleanup middleware

* add x-zitadel-forward-host as fallback for current cloud setup

* fix build

* fix: lint

* fix: update test mocks

* fix: provide basepath to unit tests

* host handling

* unit tests

* imports

* fix: getPublicHost instead of getInstanceHost

* unit tests

* update nextjs, implement jwt verify for logout_token

* pass logout information in jwt to login v2

* handle hosts in jwks url

* point idp callbacks to the api and not the login UI

* logout_hint out of logout token, typecheck

* rm unused imports

* fix oidc integration test

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Max Peintner
2025-12-08 10:01:49 +01:00
committed by GitHub
co-authored by Livio Spring
parent f9e3871bb3
commit df75be96ff
75 changed files with 2075 additions and 1817 deletions
+26 -9
View File
@@ -10,10 +10,11 @@ import (
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/crypto"
"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"
@@ -33,6 +34,7 @@ const (
LoginPostLogoutRedirectParam = "post_logout_redirect"
LoginLogoutHintParam = "logout_hint"
LoginUILocalesParam = "ui_locales"
LoginLogoutTokenParam = "logout_token"
LoginPath = "/login"
LogoutPath = "/logout"
LogoutDonePath = "/logout/done"
@@ -298,7 +300,11 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
} else {
logoutURI = logoutURI.JoinPath(LogoutPath)
}
return buildLoginV2LogoutURL(logoutURI, redirectURI, endSessionRequest.LogoutHint, endSessionRequest.UILocales), nil
signer, _, err := GetSignerOnce(o.query.GetActiveSigningWebKey)(ctx)
if err != nil {
return "", err
}
return buildLoginV2LogoutURL(logoutURI, redirectURI, endSessionRequest.LogoutHint, endSessionRequest.UILocales, signer)
}
// V1:
@@ -375,7 +381,13 @@ func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postL
return login.ExternalLogoutPath(sessionID)
}
func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, uiLocales []language.Tag) string {
type logoutTokenPayload struct {
PostLogoutRedirectURI string `json:"post_logout_redirect_uri,omitempty"`
LogoutHint string `json:"logout_hint,omitempty"`
UILocales oidc.Locales `json:"ui_locales,omitempty"`
}
func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, uiLocales oidc.Locales, signer jose.Signer) (string, error) {
if strings.HasSuffix(logoutURI.Path, "/") && len(logoutURI.Path) > 1 {
logoutURI.Path = strings.TrimSuffix(logoutURI.Path, "/")
}
@@ -386,14 +398,19 @@ func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, u
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, " "))
q.Set(LoginUILocalesParam, uiLocales.String())
}
logoutToken, err := crypto.Sign(&logoutTokenPayload{
PostLogoutRedirectURI: redirectURI,
LogoutHint: logoutHint,
UILocales: uiLocales,
}, signer)
if err != nil {
return "", err
}
q.Set(LoginLogoutTokenParam, logoutToken)
logoutURI.RawQuery = q.Encode()
return logoutURI.String()
return logoutURI.String(), nil
}
// v2PostLogoutRedirectURI will take care that the post_logout_redirect_uri is correctly set for v2 logins.
+43 -1
View File
@@ -1,9 +1,13 @@
package oidc
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/url"
"testing"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
@@ -18,6 +22,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
redirectURI string
logoutHint string
uiLocales []language.Tag
signer jose.Signer
expectedParams map[string]string
}{
{
@@ -26,6 +31,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
redirectURI: "https://client/cb",
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"logout_token": "", // presence checked separately
},
},
{
@@ -36,6 +42,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"logout_hint": "[email protected]",
"logout_token": "", // presence checked separately
},
},
{
@@ -46,6 +53,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"ui_locales": "en it",
"logout_token": "", // presence checked separately
},
},
{
@@ -58,6 +66,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
"post_logout_redirect": "https://client/cb",
"logout_hint": "logoutme",
"ui_locales": "de-CH fr",
"logout_token": "", // presence checked separately
},
},
{
@@ -66,6 +75,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
redirectURI: "https://client/cb",
expectedParams: map[string]string{
"post_logout_redirect": "https://client/cb",
"logout_token": "", // presence checked separately
},
},
}
@@ -79,9 +89,10 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
require.NoError(t, err)
// When
got := buildLoginV2LogoutURL(logoutURI, tc.redirectURI, tc.logoutHint, tc.uiLocales)
got, err := buildLoginV2LogoutURL(logoutURI, tc.redirectURI, tc.logoutHint, tc.uiLocales, signer)
// Then
require.NoError(t, err)
gotURL, err := url.Parse(got)
require.NoError(t, err)
require.NotContains(t, gotURL.String(), "/logout/")
@@ -91,8 +102,39 @@ func TestBuildLoginV2LogoutURL(t *testing.T) {
require.Len(t, q, len(tc.expectedParams))
for k, v := range tc.expectedParams {
if k == LoginLogoutTokenParam {
assertLogoutToken(t, q.Get(k), &logoutTokenPayload{
PostLogoutRedirectURI: tc.redirectURI,
LogoutHint: tc.logoutHint,
UILocales: tc.uiLocales,
})
continue
}
assert.Equal(t, v, q.Get(k))
}
})
}
}
func assertLogoutToken(t *testing.T, token string, payload *logoutTokenPayload) {
signature, err := jose.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
require.NoError(t, err)
logoutToken := new(logoutTokenPayload)
err = json.Unmarshal(signature.UnsafePayloadWithoutVerification(), logoutToken)
require.NoError(t, err)
assert.Equal(t, payload, logoutToken)
}
var (
privKey, _ = rsa.GenerateKey(rand.Reader, 2048)
signer = func() jose.Signer {
signer, _ := jose.NewSigner(
jose.SigningKey{
Algorithm: jose.RS256,
Key: privKey,
},
(&jose.SignerOptions{}).WithType("JWT"),
)
return signer
}()
)
@@ -551,15 +551,16 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) {
require.NoError(t, err)
}
func buildLogoutURL(origin, logoutURLV2 string, redirectURI string, extraParams map[string]string) string {
func buildLogoutURL(origin, logoutURLV2, redirectURI string, extraParams map[string]string) *url.URL {
u, _ := url.Parse(origin + logoutURLV2 + redirectURI)
q := u.Query()
for k, v := range extraParams {
q.Set(k, v)
}
q.Set("logout_token", "signed-logout-token") // placeholder
u.RawQuery = q.Encode()
// Append the redirect URI as a URL-escaped string
return u.String()
return u
}
func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
@@ -567,7 +568,7 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
name string
clientID string
authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string
logoutURL string
logoutURL *url.URL
}{
{
name: "login header",
@@ -614,7 +615,18 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
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())
requiredQueries := tt.logoutURL.Query()
for key, value := range requiredQueries {
if key == "logout_token" {
assert.NotEmpty(t, value[0], "logout_token must be present")
continue
}
assert.Equal(t, value[0], postLogoutRedirect.Query().Get(key))
}
requiredURLWithoutQueries := *tt.logoutURL
requiredURLWithoutQueries.RawQuery = ""
assert.Equal(t, requiredURLWithoutQueries.String(), postLogoutRedirect.Scheme+"://"+postLogoutRedirect.Host+postLogoutRedirect.Path)
// userinfo must not fail until login UI terminated session
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)