feat: add option to use preferred_username claim when registering users via oauth2 (#12504)

This is a continuation of #3346 based on the advise of https://codeberg.org/forgejo/forgejo/issues/1452#issuecomment-14591307.

fixes: #1452
docs: https://codeberg.org/forgejo/docs/pulls/1938

Extends the `oauth2_client` `USERNAME` setting to be able to use the `preferred_username` claim.

Co-authored-by: thepaperpilot <thepaperpilot@gmail.com>
Co-authored-by: Anthony Lawn <thepaperpilot@gmail.com>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12504
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
IRONM00N 2026-05-18 00:14:46 +02:00 committed by Gusted
parent cf087a2f12
commit d130e1ee94
4 changed files with 213 additions and 1 deletions

View file

@ -22,11 +22,13 @@ const (
OAuth2UsernameNickname OAuth2UsernameType = "nickname"
// OAuth2UsernameEmail username of oauth2 email field will be used as gitea name
OAuth2UsernameEmail OAuth2UsernameType = "email"
// @OAuth2UsernamePreferredUsername oauth2 preferred_username field will be used as gitea name
OAuth2UsernamePreferredUsername OAuth2UsernameType = "preferred_username"
)
func (username OAuth2UsernameType) isValid() bool {
switch username {
case OAuth2UsernameUserid, OAuth2UsernameNickname, OAuth2UsernameEmail:
case OAuth2UsernameUserid, OAuth2UsernameNickname, OAuth2UsernameEmail, OAuth2UsernamePreferredUsername:
return true
}
return false

View file

@ -401,6 +401,12 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
func getUserName(gothUser *goth.User) (string, error) {
switch setting.OAuth2Client.Username {
case setting.OAuth2UsernamePreferredUsername:
username := gothUser.RawData["preferred_username"].(string)
if strings.Contains(username, "@") {
return user_model.NormalizeUserName(strings.Split(username, "@")[0])
}
return user_model.NormalizeUserName(username)
case setting.OAuth2UsernameEmail:
return user_model.NormalizeUserName(strings.Split(gothUser.Email, "@")[0])
case setting.OAuth2UsernameNickname:

View file

@ -1117,6 +1117,8 @@ func SignInOAuthCallback(ctx *context.Context) {
}
if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
missingFields = append(missingFields, "nickname")
} else if setting.OAuth2Client.Username == setting.OAuth2UsernamePreferredUsername && (gothUser.RawData["preferred_username"] == nil || gothUser.RawData["preferred_username"].(string) == "") {
missingFields = append(missingFields, "preferred_username")
}
if len(missingFields) > 0 {
// we don't have enough information to create an account automatically,

View file

@ -2127,3 +2127,205 @@ func TestSignInOAuthCallbackSignInRetrieveError(t *testing.T) {
assert.NotNil(t, flashCookie)
assert.Equal(t, "error%3DOAuth2%2BRetrieveError%253A%2Boauth2%253A%2Bcannot%2Bfetch%2Btoken%253A%2B404%2BNot%2BFound%250AResponse%253A%2Bcooked", flashCookie.Value)
}
func TestSignUpViaOAuthWithMissingNickname(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "nickname" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "nickname")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User doesn't contain a nickname, redirected to link account
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
RawData: map[string]any{
"preferred_username": "preferred_username",
},
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
}
func TestSignUpViaOAuthWithMissingUsername(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "preferred_username" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "preferred_username")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User doesn't contain preferred_username, redirected to link account
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
NickName: "nickname",
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
}
func TestSignUpViaOAuthWithNicknameAsUsername(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "nickname" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "nickname")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User contains the nickname that'll be used as the Forgejo user's username
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
NickName: "nickname",
RawData: map[string]any{
"preferred_username": "preferred_username",
},
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/", test.RedirectURL(resp))
userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: userGitLabUserID})
assert.Equal(t, "nickname", userAfterLogin.Name)
}
func TestSignUpViaOAuthWithUserIdAsUsername(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "userid" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "userid")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User's UserID will be used as the Forgejo user's username
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
NickName: "nickname",
RawData: map[string]any{
"preferred_username": "preferred_username",
},
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/", test.RedirectURL(resp))
userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: userGitLabUserID})
assert.Equal(t, userGitLabUserID, userAfterLogin.Name)
}
func TestSignUpViaOAuthWithEmailAsUsername(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "email" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "email")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User's email local part will be used as the Forgejo user's username
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
NickName: "nickname",
RawData: map[string]any{
"preferred_username": "preferred_username",
},
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/", test.RedirectURL(resp))
userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: userGitLabUserID})
assert.Equal(t, "gitlabuser", userAfterLogin.Name)
}
func TestSignUpViaOAuthWithPreferredUsernameAsUsername(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "preferred_username" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "preferred_username")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User's preferred_username claim will be used as the Forgejo user's username
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
NickName: "nickname",
RawData: map[string]any{
"preferred_username": "preferred_username",
},
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/", test.RedirectURL(resp))
userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: userGitLabUserID})
assert.Equal(t, "preferred_username", userAfterLogin.Name)
}
func TestSignUpViaOAuthWithPreferredUsernameProcessesEmail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 with "preferred_username" username
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, "preferred_username")()
// OAuth2 authentication source GitLab
gitlabName := "gitlab"
addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
userGitLabUserID := "5678"
// The Goth User's preferred_username claim contains an "@" suffix that will be stripped
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: gitlabName,
UserID: userGitLabUserID,
Email: "gitlabuser@example.com",
NickName: "nickname",
RawData: map[string]any{
"preferred_username": "someotheremail@gmail.com",
},
}, nil
})()
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
resp := MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/", test.RedirectURL(resp))
userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: userGitLabUserID})
assert.Equal(t, "someotheremail", userAfterLogin.Name)
}