From 4ed6f89b60ac27a5aa35481f6fede2e4ebea593e Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 31 Oct 2026 18:20:56 +0100 Subject: [PATCH] fix(auth): only auto-link the account the external identity points to (#40121) With `[oauth2_client].ACCOUNT_LINKING = auto`, submitting the link account sign-up form with a user name or email that already exists linked the external identity to that existing account and signed in as it, without checking that the external identity belongs to that account. Automatic linking now only happens when the existing user matches the external identity: its email equals the provider email, or its name equals the user name derived from the provider (per `[oauth2_client].USERNAME`, same as auto registration). Otherwise the linking login page is shown, which requires the existing account's password. Auto registration is unchanged, since the name and email there already come from the provider. --------- Co-authored-by: silverwind Co-authored-by: Giteabot --- routers/web/auth/auth.go | 11 +++++++++ tests/integration/oauth_test.go | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 75d331f7487..f2ff5fcc358 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -449,6 +449,14 @@ func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) { } } +func oauth2IdentityMatchesUser(gothUser *goth.User, u *user_model.User) bool { + if gothUser.Email != "" && strings.EqualFold(gothUser.Email, u.Email) { + return true + } + uname, err := extractUserNameFromOAuth2(gothUser) + return err == nil && uname != "" && strings.EqualFold(uname, u.Name) +} + // HandleSignOut resets the session and sets the cookies func HandleSignOut(ctx *context.Context) { _ = ctx.Session.Flush() @@ -649,6 +657,9 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, return false } } + if !oauth2IdentityMatchesUser(&possibleLinkAccountData.GothUser, user) { + break // name or email came from the sign-up form, report the conflict instead of linking + } // TODO: probably we should respect 'remember' user's choice... oauth2LinkAccount(ctx, user, possibleLinkAccountData, true) return false // user is already created here, all redirects are handled diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index cf04fd70fa7..5d74e748c92 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -115,6 +115,49 @@ func TestOAuth2AvatarFromPicture(t *testing.T) { }) } +func TestOAuth2LinkAccountSignupAutoLinking(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)() + defer test.MockVariableValue(&setting.Service.RequireExternalRegistrationPassword, false)() + + addOAuth2Source(t, "test-link-signup", oauth2.Source{}) + authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "test-link-signup") + require.NoError(t, err) + + signUp := func(t *testing.T, gothUser goth.User, userName, email string, expectedStatus int) (*TestSession, *httptest.ResponseRecorder) { + defer web.RouteMockReset() + web.RouteMock(web.MockAfterMiddlewares, func(ctx *context.Context) { + require.NoError(t, auth.Oauth2SetLinkAccountData(ctx, auth.LinkAccountData{AuthSourceID: authSource.ID, GothUser: gothUser})) + }) + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/link_account_signup", map[string]string{ + "user_name": userName, + "email": email, + }) + return session, session.MakeRequest(t, req, expectedStatus) + } + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + for _, form := range []struct{ name, userName, email, errMsg string }{ + {"FormNamesOtherUser", user2.Name, "new-user@example.com", "The username is already taken."}, + {"FormUsesOtherUsersEmail", "new-user", user2.Email, "The email address is already used."}, + } { + t.Run(form.name, func(t *testing.T) { + gothUser := goth.User{UserID: form.name, Email: "external@example.com"} + _, resp := signUp(t, gothUser, form.userName, form.email, http.StatusOK) + assert.Contains(t, resp.Body.String(), form.errMsg) + unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: gothUser.UserID}, unittest.OrderBy("external_id")) + }) + } + + t.Run("ExternalIdentityMatchesUser", func(t *testing.T) { + gothUser := goth.User{UserID: "user2-sub", Email: user2.Email} + session, _ := signUp(t, gothUser, user2.Name, user2.Email, http.StatusSeeOther) + unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: gothUser.UserID, UserID: user2.ID}, unittest.OrderBy("external_id")) + session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK) + }) +} + func testOAuth2PrepareTestCode(t *testing.T) { require.NoError(t, db.TruncateBeans(t.Context(), &auth_model.OAuth2AuthorizationCode{})) err := db.Insert(t.Context(), &auth_model.OAuth2AuthorizationCode{