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 <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
authored and GitHub committed 2026-10-31 18:20:56 +01:00
1 parent 723095e66d
commit 4ed6f89b60
2 files changed
+54

No files matched your search

+43
View File
@@ -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{