From 649ebeb120a50981853fbc8aff6e0ba5fde238c4 Mon Sep 17 00:00:00 2001 From: Nikita Vakula <52108696+krjakbrjak@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:28:26 +0100 Subject: [PATCH] Implements OIDC RP-Initiated Logout (#36724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At logout time, if the user authenticated via OIDC, we look up the provider's `end_session_endpoint` (already discovered by Goth from the OIDC metadata) and redirect there with `client_id` and `post_logout_redirect_uri`. Non-OIDC OAuth2 providers (GitHub, GitLab, etc.) are unaffected — they fall back to local-only logout. Fix #14270 --------- Signed-off-by: Nikita Vakula Co-authored-by: Nikita Vakula Co-authored-by: wxiaoguang --- models/auth/source.go | 2 +- routers/web/auth/auth.go | 15 +++++++- routers/web/auth/auth_test.go | 48 ++++++++++++++++++++++-- routers/web/auth/oauth.go | 37 ++++++++++++++++++ routers/web/web.go | 2 +- services/auth/source/oauth2/providers.go | 21 +++++++++++ templates/base/head_navbar.tmpl | 4 +- tests/integration/signout_test.go | 8 +++- 8 files changed, 127 insertions(+), 10 deletions(-) diff --git a/models/auth/source.go b/models/auth/source.go index 08cfc9615b..c0b262f870 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -117,7 +117,7 @@ func RegisterTypeConfig(typ Type, exemplar Config) { type Source struct { ID int64 `xorm:"pk autoincr"` Type Type - Name string `xorm:"UNIQUE"` + Name string `xorm:"UNIQUE"` // it can be the OIDC's provider name, see services/auth/source/oauth2/source_register.go: RegisterSource IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"` IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"` TwoFactorPolicy string `xorm:"two_factor_policy NOT NULL DEFAULT ''"` diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index c50a197f51..fbc3f9f07d 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -425,8 +425,21 @@ func SignOut(ctx *context.Context) { Data: ctx.Session.ID(), }) } + + // prepare the sign-out URL before destroying the session + redirectTo := buildSignOutRedirectURL(ctx) HandleSignOut(ctx) - ctx.JSONRedirect(setting.AppSubURL + "/") + ctx.Redirect(redirectTo) +} + +func buildSignOutRedirectURL(ctx *context.Context) string { + // TODO: can also support REVERSE_PROXY_AUTHENTICATION logout URL in the future + if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 { + if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" { + return s + } + } + return setting.AppSubURL + "/" } // SignUp render the register page diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go index 5ccffe0d50..d1f808181a 100644 --- a/routers/web/auth/auth_test.go +++ b/routers/web/auth/auth_test.go @@ -5,10 +5,12 @@ package auth import ( "net/http" + "net/http/httptest" "net/url" "testing" auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" @@ -19,6 +21,7 @@ import ( "github.com/markbates/goth" "github.com/markbates/goth/gothic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) { @@ -29,10 +32,10 @@ func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) { IsActive: true, Cfg: &cfg, }) - assert.NoError(t, err) + require.NoError(t, err) } -func TestUserLogin(t *testing.T) { +func TestWebAuthUserLogin(t *testing.T) { ctx, resp := contexttest.MockContext(t, "/user/login") SignIn(ctx) assert.Equal(t, http.StatusOK, resp.Code) @@ -60,7 +63,7 @@ func TestUserLogin(t *testing.T) { assert.Equal(t, "/", test.RedirectURL(resp)) } -func TestSignUpOAuth2Login(t *testing.T) { +func TestWebAuthOAuth2(t *testing.T) { defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)() _ = oauth2.Init(t.Context()) @@ -92,4 +95,43 @@ func TestSignUpOAuth2Login(t *testing.T) { assert.Equal(t, "/user/login", test.RedirectURL(resp)) assert.Contains(t, ctx.Flash.ErrorMsg, "auth.oauth.signin.error.general") }) + + t.Run("OIDCLogout", func(t *testing.T) { + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + _, _ = w.Write([]byte(`{ + "issuer": "` + mockServer.URL + `", + "authorization_endpoint": "` + mockServer.URL + `/authorize", + "token_endpoint": "` + mockServer.URL + `/token", + "userinfo_endpoint": "` + mockServer.URL + `/userinfo", + "end_session_endpoint": "https://example.com/oidc-logout?oidc-key=oidc-val" + }`)) + default: + http.NotFound(w, r) + } + })) + defer mockServer.Close() + + addOAuth2Source(t, "oidc-auth-source", oauth2.Source{ + Provider: "openidConnect", + ClientID: "mock-client-id", + OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration", + }) + authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source") + require.NoError(t, err) + + mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")} + ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt) + ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID} + SignOut(ctx) + assert.Equal(t, http.StatusSeeOther, resp.Code) + u, err := url.Parse(test.RedirectURL(resp)) + require.NoError(t, err) + expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}} + assert.Equal(t, expectedValues, u.Query()) + u.RawQuery = "" + assert.Equal(t, "https://example.com/oidc-logout", u.String()) + }) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index b96ea17bc3..bb0784e434 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -10,6 +10,7 @@ import ( "html" "io" "net/http" + "net/url" "sort" "strings" @@ -17,6 +18,7 @@ import ( user_model "code.gitea.io/gitea/models/user" auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/session" @@ -506,3 +508,38 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ // no user found to login return nil, gothUser, nil } + +// buildOIDCEndSessionURL constructs an OIDC RP-Initiated Logout URL for the +// given user. Returns "" if the user's auth source is not OIDC or doesn't +// advertise an end_session_endpoint. +func buildOIDCEndSessionURL(ctx *context.Context, doer *user_model.User) string { + authSource, err := auth.GetSourceByID(ctx, doer.LoginSource) + if err != nil { + log.Error("Failed to get auth source for OIDC logout (source=%d): %v", doer.LoginSource, err) + return "" + } + + oauth2Cfg, ok := authSource.Cfg.(*oauth2.Source) + if !ok { + return "" + } + + endSessionEndpoint := oauth2.GetOIDCEndSessionEndpoint(authSource.Name) + if endSessionEndpoint == "" { + return "" + } + + endSessionURL, err := url.Parse(endSessionEndpoint) + if err != nil { + log.Error("Failed to parse end_session_endpoint %q: %v", endSessionEndpoint, err) + return "" + } + + // RP-Initiated Logout 1.0: use client_id to identify the client to the IdP. + // https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout + params := endSessionURL.Query() + params.Set("client_id", oauth2Cfg.ClientID) + params.Set("post_logout_redirect_uri", httplib.GuessCurrentAppURL(ctx)) + endSessionURL.RawQuery = params.Encode() + return endSessionURL.String() +} diff --git a/routers/web/web.go b/routers/web/web.go index ce037afe1b..d973064b22 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -691,7 +691,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/recover_account", auth.ResetPasswdPost) m.Get("/forgot_password", auth.ForgotPasswd) m.Post("/forgot_password", auth.ForgotPasswdPost) - m.Post("/logout", auth.SignOut) + m.Get("/logout", auth.SignOut) m.Get("/stopwatches", reqSignIn, user.GetStopwatches) m.Get("/search_candidates", optExploreSignIn, user.SearchCandidates) m.Group("/oauth2", func() { diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go index bb21cb53fe..68bd4a1d4c 100644 --- a/services/auth/source/oauth2/providers.go +++ b/services/auth/source/oauth2/providers.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/setting" "github.com/markbates/goth" + "github.com/markbates/goth/providers/openidConnect" ) // Provider is an interface for describing a single OAuth2 provider @@ -197,6 +198,26 @@ func ClearProviders() { goth.ClearProviders() } +// GetOIDCEndSessionEndpoint returns the OIDC end_session_endpoint for the +// given provider name. Returns "" if the provider is not OIDC or doesn't +// advertise an end_session_endpoint in its discovery document. +func GetOIDCEndSessionEndpoint(providerName string) string { + gothRWMutex.RLock() + defer gothRWMutex.RUnlock() + + provider, ok := goth.GetProviders()[providerName] + if !ok { + return "" + } + + oidcProvider, ok := provider.(*openidConnect.Provider) + if !ok || oidcProvider.OpenIDConfig == nil { + return "" + } + + return oidcProvider.OpenIDConfig.EndSessionEndpoint +} + var ErrAuthSourceNotActivated = errors.New("auth source is not activated") // used to create different types of goth providers diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 43cbcbdc0c..22f1ba0b73 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -55,7 +55,7 @@
- + {{svg "octicon-sign-out"}} {{ctx.Locale.Tr "sign_out"}} @@ -128,7 +128,7 @@ {{end}}
- + {{svg "octicon-sign-out"}} {{ctx.Locale.Tr "sign_out"}} diff --git a/tests/integration/signout_test.go b/tests/integration/signout_test.go index 7fd0b5c64a..0c0ac5dd87 100644 --- a/tests/integration/signout_test.go +++ b/tests/integration/signout_test.go @@ -7,7 +7,10 @@ import ( "net/http" "testing" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" ) func TestSignOut(t *testing.T) { @@ -15,8 +18,9 @@ func TestSignOut(t *testing.T) { session := loginUser(t, "user2") - req := NewRequest(t, "POST", "/user/logout") - session.MakeRequest(t, req, http.StatusOK) + req := NewRequest(t, "GET", "/user/logout") + resp := session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) // try to view a private repo, should fail req = NewRequest(t, "GET", "/user2/repo2")