0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-03-02 11:05:29 +01:00

Implements OIDC RP-Initiated Logout (#36724)

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 <nikita.vakula@alpsalpine.com>
Co-authored-by: Nikita Vakula <nikita.vakula@alpsalpine.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Nikita Vakula 2026-03-01 07:28:26 +01:00 committed by GitHub
parent f02f419173
commit 649ebeb120
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 127 additions and 10 deletions

View File

@ -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 ''"`

View File

@ -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

View File

@ -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())
})
}

View File

@ -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()
}

View File

@ -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() {

View File

@ -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

View File

@ -55,7 +55,7 @@
</div>
<div class="divider"></div>
<a class="item link-action" href data-url="{{AppSubUrl}}/user/logout">
<a class="item" href="{{AppSubUrl}}/user/logout">
{{svg "octicon-sign-out"}}
{{ctx.Locale.Tr "sign_out"}}
</a>
@ -128,7 +128,7 @@
</a>
{{end}}
<div class="divider"></div>
<a class="item link-action" href data-url="{{AppSubUrl}}/user/logout">
<a class="item" href="{{AppSubUrl}}/user/logout">
{{svg "octicon-sign-out"}}
{{ctx.Locale.Tr "sign_out"}}
</a>

View File

@ -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")