aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorwxiaoguang <wxiaoguang@gmail.com>2025-07-11 02:35:59 +0800
committerGitHub <noreply@github.com>2025-07-10 18:35:59 +0000
commita5a3d9b10177e0266a0877936c517f54102b3f91 (patch)
treeec9d2010914393d63029beb41cf34024982251be
parent6ab6d4e17ff61b4eaf1a88e3d167f0d504b8390e (diff)
downloadgitea-a5a3d9b10177e0266a0877936c517f54102b3f91.tar.gz
gitea-a5a3d9b10177e0266a0877936c517f54102b3f91.zip
Refactor OpenIDConnect to support SSH/FullName sync (#34978)
* Fix #26585 * Fix #28327 * Fix #34932
-rw-r--r--cmd/admin_auth_oauth.go16
-rw-r--r--cmd/admin_auth_oauth_test.go64
-rw-r--r--models/asymkey/ssh_key.go4
-rw-r--r--models/auth/oauth2.go4
-rw-r--r--models/auth/source.go2
-rw-r--r--modules/setting/oauth2.go2
-rw-r--r--options/locale/locale_en-US.ini2
-rw-r--r--routers/web/admin/auths.go3
-rw-r--r--routers/web/auth/2fa.go3
-rw-r--r--routers/web/auth/auth.go27
-rw-r--r--routers/web/auth/linkaccount.go66
-rw-r--r--routers/web/auth/oauth.go56
-rw-r--r--routers/web/auth/oauth_signin_sync.go88
-rw-r--r--routers/web/auth/openid.go2
-rw-r--r--routers/web/auth/webauthn.go5
-rw-r--r--services/auth/source/oauth2/providers.go1
-rw-r--r--services/auth/source/oauth2/providers_base.go7
-rw-r--r--services/auth/source/oauth2/providers_openid.go4
-rw-r--r--services/auth/source/oauth2/source.go3
-rw-r--r--services/externalaccount/link.go30
-rw-r--r--services/externalaccount/user.go26
-rw-r--r--services/forms/auth_form.go101
-rw-r--r--templates/admin/auth/edit.tmpl15
-rw-r--r--templates/admin/auth/source/oauth.tmpl16
-rw-r--r--tests/integration/oauth_test.go110
-rw-r--r--tests/integration/signin_test.go5
-rw-r--r--web_src/js/features/admin/common.ts3
27 files changed, 459 insertions, 206 deletions
diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go
index d1aa753500..8848c94fc5 100644
--- a/cmd/admin_auth_oauth.go
+++ b/cmd/admin_auth_oauth.go
@@ -88,6 +88,14 @@ func oauthCLIFlags() []cli.Flag {
Usage: "Scopes to request when to authenticate against this OAuth2 source",
},
&cli.StringFlag{
+ Name: "ssh-public-key-claim-name",
+ Usage: "Claim name that provides SSH public keys",
+ },
+ &cli.StringFlag{
+ Name: "full-name-claim-name",
+ Usage: "Claim name that provides user's full name",
+ },
+ &cli.StringFlag{
Name: "required-claim-name",
Value: "",
Usage: "Claim name that has to be set to allow users to login with this source",
@@ -177,6 +185,8 @@ func parseOAuth2Config(c *cli.Command) *oauth2.Source {
RestrictedGroup: c.String("restricted-group"),
GroupTeamMap: c.String("group-team-map"),
GroupTeamMapRemoval: c.Bool("group-team-map-removal"),
+ SSHPublicKeyClaimName: c.String("ssh-public-key-claim-name"),
+ FullNameClaimName: c.String("full-name-claim-name"),
}
}
@@ -268,6 +278,12 @@ func (a *authService) runUpdateOauth(ctx context.Context, c *cli.Command) error
if c.IsSet("group-team-map-removal") {
oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
}
+ if c.IsSet("ssh-public-key-claim-name") {
+ oAuth2Config.SSHPublicKeyClaimName = c.String("ssh-public-key-claim-name")
+ }
+ if c.IsSet("full-name-claim-name") {
+ oAuth2Config.FullNameClaimName = c.String("full-name-claim-name")
+ }
// update custom URL mapping
customURLMapping := &oauth2.CustomURLMapping{}
diff --git a/cmd/admin_auth_oauth_test.go b/cmd/admin_auth_oauth_test.go
index df1bd9c1a6..bb9da667fd 100644
--- a/cmd/admin_auth_oauth_test.go
+++ b/cmd/admin_auth_oauth_test.go
@@ -88,6 +88,8 @@ func TestAddOauth(t *testing.T) {
"--restricted-group", "restricted",
"--group-team-map", `{"group1": [1,2]}`,
"--group-team-map-removal=true",
+ "--ssh-public-key-claim-name", "attr_ssh_pub_key",
+ "--full-name-claim-name", "attr_full_name",
},
source: &auth_model.Source{
Type: auth_model.OAuth2,
@@ -104,15 +106,17 @@ func TestAddOauth(t *testing.T) {
EmailURL: "https://example.com/email",
Tenant: "some_tenant",
},
- IconURL: "https://example.com/icon",
- Scopes: []string{"scope1", "scope2"},
- RequiredClaimName: "claim_name",
- RequiredClaimValue: "claim_value",
- GroupClaimName: "group_name",
- AdminGroup: "admin",
- RestrictedGroup: "restricted",
- GroupTeamMap: `{"group1": [1,2]}`,
- GroupTeamMapRemoval: true,
+ IconURL: "https://example.com/icon",
+ Scopes: []string{"scope1", "scope2"},
+ RequiredClaimName: "claim_name",
+ RequiredClaimValue: "claim_value",
+ GroupClaimName: "group_name",
+ AdminGroup: "admin",
+ RestrictedGroup: "restricted",
+ GroupTeamMap: `{"group1": [1,2]}`,
+ GroupTeamMapRemoval: true,
+ SSHPublicKeyClaimName: "attr_ssh_pub_key",
+ FullNameClaimName: "attr_full_name",
},
TwoFactorPolicy: "skip",
},
@@ -223,15 +227,17 @@ func TestUpdateOauth(t *testing.T) {
EmailURL: "https://old.example.com/email",
Tenant: "old_tenant",
},
- IconURL: "https://old.example.com/icon",
- Scopes: []string{"old_scope1", "old_scope2"},
- RequiredClaimName: "old_claim_name",
- RequiredClaimValue: "old_claim_value",
- GroupClaimName: "old_group_name",
- AdminGroup: "old_admin",
- RestrictedGroup: "old_restricted",
- GroupTeamMap: `{"old_group1": [1,2]}`,
- GroupTeamMapRemoval: true,
+ IconURL: "https://old.example.com/icon",
+ Scopes: []string{"old_scope1", "old_scope2"},
+ RequiredClaimName: "old_claim_name",
+ RequiredClaimValue: "old_claim_value",
+ GroupClaimName: "old_group_name",
+ AdminGroup: "old_admin",
+ RestrictedGroup: "old_restricted",
+ GroupTeamMap: `{"old_group1": [1,2]}`,
+ GroupTeamMapRemoval: true,
+ SSHPublicKeyClaimName: "old_ssh_pub_key",
+ FullNameClaimName: "old_full_name",
},
TwoFactorPolicy: "",
},
@@ -257,6 +263,8 @@ func TestUpdateOauth(t *testing.T) {
"--restricted-group", "restricted",
"--group-team-map", `{"group1": [1,2]}`,
"--group-team-map-removal=false",
+ "--ssh-public-key-claim-name", "new_ssh_pub_key",
+ "--full-name-claim-name", "new_full_name",
},
authSource: &auth_model.Source{
ID: 1,
@@ -274,15 +282,17 @@ func TestUpdateOauth(t *testing.T) {
EmailURL: "https://example.com/email",
Tenant: "new_tenant",
},
- IconURL: "https://example.com/icon",
- Scopes: []string{"scope1", "scope2"},
- RequiredClaimName: "claim_name",
- RequiredClaimValue: "claim_value",
- GroupClaimName: "group_name",
- AdminGroup: "admin",
- RestrictedGroup: "restricted",
- GroupTeamMap: `{"group1": [1,2]}`,
- GroupTeamMapRemoval: false,
+ IconURL: "https://example.com/icon",
+ Scopes: []string{"scope1", "scope2"},
+ RequiredClaimName: "claim_name",
+ RequiredClaimValue: "claim_value",
+ GroupClaimName: "group_name",
+ AdminGroup: "admin",
+ RestrictedGroup: "restricted",
+ GroupTeamMap: `{"group1": [1,2]}`,
+ GroupTeamMapRemoval: false,
+ SSHPublicKeyClaimName: "new_ssh_pub_key",
+ FullNameClaimName: "new_full_name",
},
TwoFactorPolicy: "skip",
},
diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go
index 7a18732c32..dd94070fb9 100644
--- a/models/asymkey/ssh_key.go
+++ b/models/asymkey/ssh_key.go
@@ -355,13 +355,13 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So
return sshKeysNeedUpdate
}
-// SynchronizePublicKeys updates a users public keys. Returns true if there are changes.
+// SynchronizePublicKeys updates a user's public keys. Returns true if there are changes.
func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool {
var sshKeysNeedUpdate bool
log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name)
- // Get Public Keys from DB with current LDAP source
+ // Get Public Keys from DB with the current auth source
var giteaKeys []string
keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{
OwnerID: usr.ID,
diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go
index c2b6690116..55af4e9036 100644
--- a/models/auth/oauth2.go
+++ b/models/auth/oauth2.go
@@ -612,8 +612,8 @@ func (err ErrOAuthApplicationNotFound) Unwrap() error {
return util.ErrNotExist
}
-// GetActiveOAuth2SourceByName returns a OAuth2 AuthSource based on the given name
-func GetActiveOAuth2SourceByName(ctx context.Context, name string) (*Source, error) {
+// GetActiveOAuth2SourceByAuthName returns a OAuth2 AuthSource based on the given name
+func GetActiveOAuth2SourceByAuthName(ctx context.Context, name string) (*Source, error) {
authSource := new(Source)
has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, OAuth2, true).Get(authSource)
if err != nil {
diff --git a/models/auth/source.go b/models/auth/source.go
index 7d7bc0f03c..08cfc9615b 100644
--- a/models/auth/source.go
+++ b/models/auth/source.go
@@ -334,7 +334,7 @@ func UpdateSource(ctx context.Context, source *Source) error {
err = registerableSource.RegisterSource()
if err != nil {
- // restore original values since we cannot update the provider it self
+ // restore original values since we cannot update the provider itself
if _, err := db.GetEngine(ctx).ID(source.ID).AllCols().Update(originalSource); err != nil {
log.Error("UpdateSource: Error while wrapOpenIDConnectInitializeError: %v", err)
}
diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go
index 0d3e63e0b4..1a88f3cb08 100644
--- a/modules/setting/oauth2.go
+++ b/modules/setting/oauth2.go
@@ -12,7 +12,7 @@ import (
"code.gitea.io/gitea/modules/log"
)
-// OAuth2UsernameType is enum describing the way gitea 'name' should be generated from oauth2 data
+// OAuth2UsernameType is enum describing the way gitea generates its 'username' from oauth2 data
type OAuth2UsernameType string
const (
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 90cc164a60..ff32c94ff9 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3251,6 +3251,8 @@ auths.oauth2_required_claim_name_helper = Set this name to restrict login from t
auths.oauth2_required_claim_value = Required Claim Value
auths.oauth2_required_claim_value_helper = Set this value to restrict login from this source to users with a claim with this name and value
auths.oauth2_group_claim_name = Claim name providing group names for this source. (Optional)
+auths.oauth2_full_name_claim_name = Full Name Claim Name. (Optional, if set, the user's full name will always be synchronized with this claim)
+auths.oauth2_ssh_public_key_claim_name = SSH Public Key Claim Name
auths.oauth2_admin_group = Group Claim value for administrator users. (Optional - requires claim name above)
auths.oauth2_restricted_group = Group Claim value for restricted users. (Optional - requires claim name above)
auths.oauth2_map_group_to_team = Map claimed groups to Organization teams. (Optional - requires claim name above)
diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go
index 0f6f31b884..56c384b970 100644
--- a/routers/web/admin/auths.go
+++ b/routers/web/admin/auths.go
@@ -199,6 +199,9 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
AdminGroup: form.Oauth2AdminGroup,
GroupTeamMap: form.Oauth2GroupTeamMap,
GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
+
+ SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
+ FullNameClaimName: form.Oauth2FullNameClaimName,
}
}
diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go
index d15d33dfd4..1f087a7897 100644
--- a/routers/web/auth/2fa.go
+++ b/routers/web/auth/2fa.go
@@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
- "code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms"
)
@@ -75,7 +74,7 @@ func TwoFactorPost(ctx *context.Context) {
}
if ctx.Session.Get("linkAccount") != nil {
- err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u)
+ err = linkAccountFromContext(ctx, u)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index 94f75f69ff..13cd083771 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -329,6 +329,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
"twofaUid",
"twofaRemember",
"linkAccount",
+ "linkAccountData",
}, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
@@ -519,7 +520,7 @@ func SignUpPost(ctx *context.Context) {
Passwd: form.Password,
}
- if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) {
+ if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) {
// error already handled
return
}
@@ -530,22 +531,22 @@ func SignUpPost(ctx *context.Context) {
// createAndHandleCreatedUser calls createUserInContext and
// then handleUserCreated.
-func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool {
- if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) {
+func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool {
+ if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) {
return false
}
- return handleUserCreated(ctx, u, gothUser)
+ return handleUserCreated(ctx, u, possibleLinkAccountData)
}
// createUserInContext creates a user and handles errors within a given context.
-// Optionally a template can be specified.
-func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) {
+// Optionally, a template can be specified.
+func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) {
meta := &user_model.Meta{
InitialIP: ctx.RemoteAddr(),
InitialUserAgent: ctx.Req.UserAgent(),
}
if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil {
- if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
+ if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
switch setting.OAuth2Client.AccountLinking {
case setting.OAuth2AccountLinkingAuto:
var user *user_model.User
@@ -561,15 +562,15 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any,
}
// TODO: probably we should respect 'remember' user's choice...
- linkAccount(ctx, user, *gothUser, true)
+ oauth2LinkAccount(ctx, user, possibleLinkAccountData, true)
return false // user is already created here, all redirects are handled
case setting.OAuth2AccountLinkingLogin:
- showLinkingLogin(ctx, *gothUser)
+ showLinkingLogin(ctx, &possibleLinkAccountData.AuthSource, possibleLinkAccountData.GothUser)
return false // user will be created only after linking login
}
}
- // handle error without template
+ // handle error without a template
if len(tpl) == 0 {
ctx.ServerError("CreateUser", err)
return false
@@ -610,7 +611,7 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any,
// handleUserCreated does additional steps after a new user is created.
// It auto-sets admin for the only user, updates the optional external user and
// sends a confirmation email if required.
-func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) {
+func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAccountData *LinkAccountData) (ok bool) {
// Auto-set admin for the only user.
hasUsers, err := user_model.HasUsers(ctx)
if err != nil {
@@ -631,8 +632,8 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
}
// update external user information
- if gothUser != nil {
- if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil {
+ if possibleLinkAccountData != nil {
+ if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSource.ID, u, possibleLinkAccountData.GothUser); err != nil {
log.Error("EnsureLinkExternalToUser failed: %v", err)
}
}
diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go
index b3c61946b9..cf1aa302c4 100644
--- a/routers/web/auth/linkaccount.go
+++ b/routers/web/auth/linkaccount.go
@@ -5,7 +5,6 @@ package auth
import (
"errors"
- "fmt"
"net/http"
"strings"
@@ -21,8 +20,6 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms"
-
- "github.com/markbates/goth"
)
var tplLinkAccount templates.TplName = "user/auth/link_account"
@@ -52,28 +49,28 @@ func LinkAccount(ctx *context.Context) {
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
- gothUser, ok := ctx.Session.Get("linkAccountGothUser").(goth.User)
+ linkAccountData := oauth2GetLinkAccountData(ctx)
// If you'd like to quickly debug the "link account" page layout, just uncomment the blow line
// Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign)
- // gothUser, ok = goth.User{Email: "invalid-email", Name: "."}, true // intentionally use invalid data to avoid pass the registration check
+ // linkAccountData = &LinkAccountData{authSource, gothUser} // intentionally use invalid data to avoid pass the registration check
- if !ok {
+ if linkAccountData == nil {
// no account in session, so just redirect to the login page, then the user could restart the process
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
- if missingFields, ok := gothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
- ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", gothUser.Provider, strings.Join(missingFields, ","))
+ if missingFields, ok := linkAccountData.GothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
+ ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", linkAccountData.GothUser.Provider, strings.Join(missingFields, ","))
}
- uname, err := extractUserNameFromOAuth2(&gothUser)
+ uname, err := extractUserNameFromOAuth2(&linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
- email := gothUser.Email
+ email := linkAccountData.GothUser.Email
ctx.Data["user_name"] = uname
ctx.Data["email"] = email
@@ -152,8 +149,8 @@ func LinkAccountPostSignIn(ctx *context.Context) {
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
- gothUser := ctx.Session.Get("linkAccountGothUser")
- if gothUser == nil {
+ linkAccountData := oauth2GetLinkAccountData(ctx)
+ if linkAccountData == nil {
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
return
}
@@ -169,11 +166,14 @@ func LinkAccountPostSignIn(ctx *context.Context) {
return
}
- linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember)
+ oauth2LinkAccount(ctx, u, linkAccountData, signInForm.Remember)
}
-func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) {
- updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
+func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData *LinkAccountData, remember bool) {
+ oauth2SignInSync(ctx, &linkAccountData.AuthSource, u, linkAccountData.GothUser)
+ if ctx.Written() {
+ return
+ }
// If this user is enrolled in 2FA, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page.
@@ -185,7 +185,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r
return
}
- err = externalaccount.LinkAccountToUser(ctx, u, gothUser)
+ err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSource.ID, u, linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserLinkAccount", err)
return
@@ -243,17 +243,11 @@ func LinkAccountPostRegister(ctx *context.Context) {
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
- gothUserInterface := ctx.Session.Get("linkAccountGothUser")
- if gothUserInterface == nil {
+ linkAccountData := oauth2GetLinkAccountData(ctx)
+ if linkAccountData == nil {
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
return
}
- gothUser, ok := gothUserInterface.(goth.User)
- if !ok {
- ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface))
- return
- }
-
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplLinkAccount)
return
@@ -296,31 +290,33 @@ func LinkAccountPostRegister(ctx *context.Context) {
}
}
- authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
- if err != nil {
- ctx.ServerError("CreateUser", err)
- return
- }
-
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: form.Password,
LoginType: auth.OAuth2,
- LoginSource: authSource.ID,
- LoginName: gothUser.UserID,
+ LoginSource: linkAccountData.AuthSource.ID,
+ LoginName: linkAccountData.GothUser.UserID,
}
- if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) {
+ if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, linkAccountData) {
// error already handled
return
}
- source := authSource.Cfg.(*oauth2.Source)
- if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
+ source := linkAccountData.AuthSource.Cfg.(*oauth2.Source)
+ if err := syncGroupsToTeams(ctx, source, &linkAccountData.GothUser, u); err != nil {
ctx.ServerError("SyncGroupsToTeams", err)
return
}
handleSignIn(ctx, u, false)
}
+
+func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
+ linkAccountData := oauth2GetLinkAccountData(ctx)
+ if linkAccountData == nil {
+ return errors.New("not in LinkAccount session")
+ }
+ return externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSource.ID, user, linkAccountData.GothUser)
+}
diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go
index a13b987aab..3df2734bb6 100644
--- a/routers/web/auth/oauth.go
+++ b/routers/web/auth/oauth.go
@@ -20,7 +20,6 @@ import (
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2"
@@ -35,9 +34,8 @@ import (
// SignInOAuth handles the OAuth2 login buttons
func SignInOAuth(ctx *context.Context) {
- provider := ctx.PathParam("provider")
-
- authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ authName := ctx.PathParam("provider")
+ authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil {
ctx.ServerError("SignIn", err)
return
@@ -74,8 +72,6 @@ func SignInOAuth(ctx *context.Context) {
// SignInOAuthCallback handles the callback from the given provider
func SignInOAuthCallback(ctx *context.Context) {
- provider := ctx.PathParam("provider")
-
if ctx.Req.FormValue("error") != "" {
var errorKeyValues []string
for k, vv := range ctx.Req.Form {
@@ -88,7 +84,8 @@ func SignInOAuthCallback(ctx *context.Context) {
}
// first look if the provider is still active
- authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ authName := ctx.PathParam("provider")
+ authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil {
ctx.ServerError("SignIn", err)
return
@@ -133,7 +130,7 @@ func SignInOAuthCallback(ctx *context.Context) {
if u == nil {
if ctx.Doer != nil {
// attach user to the current signed-in user
- err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser)
+ err = externalaccount.LinkAccountToUser(ctx, authSource.ID, ctx.Doer, gothUser)
if err != nil {
ctx.ServerError("UserLinkAccount", err)
return
@@ -174,12 +171,11 @@ func SignInOAuthCallback(ctx *context.Context) {
gothUser.RawData = make(map[string]any)
}
gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
- showLinkingLogin(ctx, gothUser)
+ showLinkingLogin(ctx, authSource, gothUser)
return
}
u = &user_model.User{
Name: uname,
- FullName: gothUser.Name,
Email: gothUser.Email,
LoginType: auth.OAuth2,
LoginSource: authSource.ID,
@@ -196,7 +192,11 @@ func SignInOAuthCallback(ctx *context.Context) {
u.IsAdmin = isAdmin.ValueOrDefault(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue
u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
- if !createAndHandleCreatedUser(ctx, templates.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
+ linkAccountData := &LinkAccountData{*authSource, gothUser}
+ if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled {
+ linkAccountData = nil
+ }
+ if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) {
// error already handled
return
}
@@ -207,7 +207,7 @@ func SignInOAuthCallback(ctx *context.Context) {
}
} else {
// no existing user is found, request attach or new account
- showLinkingLogin(ctx, gothUser)
+ showLinkingLogin(ctx, authSource, gothUser)
return
}
}
@@ -271,9 +271,22 @@ func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *g
return isAdmin, isRestricted
}
-func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
+type LinkAccountData struct {
+ AuthSource auth.Source
+ GothUser goth.User
+}
+
+func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
+ v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData)
+ if !ok {
+ return nil
+ }
+ return &v
+}
+
+func showLinkingLogin(ctx *context.Context, authSource *auth.Source, gothUser goth.User) {
if err := updateSession(ctx, nil, map[string]any{
- "linkAccountGothUser": gothUser,
+ "linkAccountData": LinkAccountData{*authSource, gothUser},
}); err != nil {
ctx.ServerError("updateSession", err)
return
@@ -281,7 +294,7 @@ func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
-func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
+func oauth2UpdateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
resp, err := http.Get(url)
if err == nil {
@@ -299,11 +312,14 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
}
}
-func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
- updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
+func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
+ oauth2SignInSync(ctx, authSource, u, gothUser)
+ if ctx.Written() {
+ return
+ }
needs2FA := false
- if !source.TwoFactorShouldSkip() {
+ if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserSignIn", err)
@@ -312,7 +328,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
needs2FA = err == nil
}
- oauth2Source := source.Cfg.(*oauth2.Source)
+ oauth2Source := authSource.Cfg.(*oauth2.Source)
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
if err != nil {
ctx.ServerError("UnmarshalGroupTeamMapping", err)
@@ -338,7 +354,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
}
}
- if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil {
+ if err := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil {
ctx.ServerError("EnsureLinkExternalToUser", err)
return
}
diff --git a/routers/web/auth/oauth_signin_sync.go b/routers/web/auth/oauth_signin_sync.go
new file mode 100644
index 0000000000..787ea9223c
--- /dev/null
+++ b/routers/web/auth/oauth_signin_sync.go
@@ -0,0 +1,88 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "fmt"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+ asymkey_service "code.gitea.io/gitea/services/asymkey"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/context"
+
+ "github.com/markbates/goth"
+)
+
+func oauth2SignInSync(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
+ oauth2UpdateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
+
+ oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
+ if !authSource.IsOAuth2() || oauth2Source == nil {
+ ctx.ServerError("oauth2SignInSync", fmt.Errorf("source %s is not an OAuth2 source", gothUser.Provider))
+ return
+ }
+
+ // sync full name
+ fullNameKey := util.IfZero(oauth2Source.FullNameClaimName, "name")
+ fullName, _ := gothUser.RawData[fullNameKey].(string)
+ fullName = util.IfZero(fullName, gothUser.Name)
+
+ // need to update if the user has no full name set
+ shouldUpdateFullName := u.FullName == ""
+ // force to update if the attribute is set
+ shouldUpdateFullName = shouldUpdateFullName || oauth2Source.FullNameClaimName != ""
+ // only update if the full name is different
+ shouldUpdateFullName = shouldUpdateFullName && u.FullName != fullName
+ if shouldUpdateFullName {
+ u.FullName = fullName
+ if err := user_model.UpdateUserCols(ctx, u, "full_name"); err != nil {
+ log.Error("Unable to sync OAuth2 user full name %s: %v", gothUser.Provider, err)
+ }
+ }
+
+ err := oauth2UpdateSSHPubIfNeed(ctx, authSource, &gothUser, u)
+ if err != nil {
+ log.Error("Unable to sync OAuth2 SSH public key %s: %v", gothUser.Provider, err)
+ }
+}
+
+func oauth2SyncGetSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) {
+ value, exists := gothUser.RawData[source.SSHPublicKeyClaimName]
+ if !exists {
+ return []string{}, nil
+ }
+ rawSlice, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("invalid SSH public key value type: %T", value)
+ }
+
+ sshKeys := make([]string, 0, len(rawSlice))
+ for _, v := range rawSlice {
+ str, ok := v.(string)
+ if !ok {
+ return nil, fmt.Errorf("invalid SSH public key value item type: %T", v)
+ }
+ sshKeys = append(sshKeys, str)
+ }
+ return sshKeys, nil
+}
+
+func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, user *user_model.User) error {
+ oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
+ if oauth2Source == nil || oauth2Source.SSHPublicKeyClaimName == "" {
+ return nil
+ }
+ sshKeys, err := oauth2SyncGetSSHKeys(oauth2Source, gothUser)
+ if err != nil {
+ return err
+ }
+ if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) {
+ return nil
+ }
+ return asymkey_service.RewriteAllPublicKeys(ctx)
+}
diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go
index 2ef4a86022..4ef4c96ccc 100644
--- a/routers/web/auth/openid.go
+++ b/routers/web/auth/openid.go
@@ -361,7 +361,7 @@ func RegisterOpenIDPost(ctx *context.Context) {
Email: form.Email,
Passwd: password,
}
- if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) {
+ if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil) {
// error already handled
return
}
diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go
index 78f6c3b58e..dacb6be225 100644
--- a/routers/web/auth/webauthn.go
+++ b/routers/web/auth/webauthn.go
@@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
- "code.gitea.io/gitea/services/externalaccount"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@@ -150,7 +149,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
- if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil {
+ if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
@@ -268,7 +267,7 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
- if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil {
+ if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go
index f2c1bb4894..75ed41ba66 100644
--- a/services/auth/source/oauth2/providers.go
+++ b/services/auth/source/oauth2/providers.go
@@ -27,6 +27,7 @@ type Provider interface {
DisplayName() string
IconHTML(size int) template.HTML
CustomURLSettings() *CustomURLSettings
+ SupportSSHPublicKey() bool
}
// GothProviderCreator provides a function to create a goth.Provider
diff --git a/services/auth/source/oauth2/providers_base.go b/services/auth/source/oauth2/providers_base.go
index 9d4ab106e5..d34597d6d9 100644
--- a/services/auth/source/oauth2/providers_base.go
+++ b/services/auth/source/oauth2/providers_base.go
@@ -14,6 +14,13 @@ import (
type BaseProvider struct {
name string
displayName string
+
+ // TODO: maybe some providers also support SSH public keys, then they can set this to true
+ supportSSHPublicKey bool
+}
+
+func (b *BaseProvider) SupportSSHPublicKey() bool {
+ return b.supportSSHPublicKey
}
// Name provides the technical name for this provider
diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go
index 285876d5ac..e86dc48232 100644
--- a/services/auth/source/oauth2/providers_openid.go
+++ b/services/auth/source/oauth2/providers_openid.go
@@ -17,6 +17,10 @@ import (
// OpenIDProvider is a GothProvider for OpenID
type OpenIDProvider struct{}
+func (o *OpenIDProvider) SupportSSHPublicKey() bool {
+ return true
+}
+
// Name provides the technical name for this provider
func (o *OpenIDProvider) Name() string {
return "openidConnect"
diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go
index 08837de377..00d89b3481 100644
--- a/services/auth/source/oauth2/source.go
+++ b/services/auth/source/oauth2/source.go
@@ -27,6 +27,9 @@ type Source struct {
GroupTeamMap string
GroupTeamMapRemoval bool
RestrictedGroup string
+
+ SSHPublicKeyClaimName string
+ FullNameClaimName string
}
// FromDB fills up an OAuth2Config from serialized format.
diff --git a/services/externalaccount/link.go b/services/externalaccount/link.go
deleted file mode 100644
index ab853140cb..0000000000
--- a/services/externalaccount/link.go
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright 2021 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package externalaccount
-
-import (
- "context"
- "errors"
-
- user_model "code.gitea.io/gitea/models/user"
-
- "github.com/markbates/goth"
-)
-
-// Store represents a thing that stores things
-type Store interface {
- Get(any) any
- Set(any, any) error
- Release() error
-}
-
-// LinkAccountFromStore links the provided user with a stored external user
-func LinkAccountFromStore(ctx context.Context, store Store, user *user_model.User) error {
- gothUser := store.Get("linkAccountGothUser")
- if gothUser == nil {
- return errors.New("not in LinkAccount session")
- }
-
- return LinkAccountToUser(ctx, user, gothUser.(goth.User))
-}
diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go
index b53e33654a..1eddc4a5df 100644
--- a/services/externalaccount/user.go
+++ b/services/externalaccount/user.go
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
- "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@@ -17,15 +16,11 @@ import (
"github.com/markbates/goth"
)
-func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) {
- authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
- if err != nil {
- return nil, err
- }
+func toExternalLoginUser(authSourceID int64, user *user_model.User, gothUser goth.User) *user_model.ExternalLoginUser {
return &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
UserID: user.ID,
- LoginSourceID: authSource.ID,
+ LoginSourceID: authSourceID,
RawData: gothUser.RawData,
Provider: gothUser.Provider,
Email: gothUser.Email,
@@ -40,15 +35,12 @@ func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser go
AccessTokenSecret: gothUser.AccessTokenSecret,
RefreshToken: gothUser.RefreshToken,
ExpiresAt: gothUser.ExpiresAt,
- }, nil
+ }
}
// LinkAccountToUser link the gothUser to the user
-func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error {
- externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser)
- if err != nil {
- return err
- }
+func LinkAccountToUser(ctx context.Context, authSourceID int64, user *user_model.User, gothUser goth.User) error {
+ externalLoginUser := toExternalLoginUser(authSourceID, user, gothUser)
if err := user_model.LinkExternalToUser(ctx, user, externalLoginUser); err != nil {
return err
@@ -72,12 +64,8 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth
}
// EnsureLinkExternalToUser link the gothUser to the user
-func EnsureLinkExternalToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error {
- externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser)
- if err != nil {
- return err
- }
-
+func EnsureLinkExternalToUser(ctx context.Context, authSourceID int64, user *user_model.User, gothUser goth.User) error {
+ externalLoginUser := toExternalLoginUser(authSourceID, user, gothUser)
return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser)
}
diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go
index a8f97572b1..886110236c 100644
--- a/services/forms/auth_form.go
+++ b/services/forms/auth_form.go
@@ -18,45 +18,54 @@ type AuthenticationForm struct {
Type int `binding:"Range(2,7)"`
Name string `binding:"Required;MaxSize(30)"`
TwoFactorPolicy string
+ IsActive bool
+ IsSyncEnabled bool
- Host string
- Port int
- BindDN string
- BindPassword string
- UserBase string
- UserDN string
- AttributeUsername string
- AttributeName string
- AttributeSurname string
- AttributeMail string
- AttributeSSHPublicKey string
- AttributeAvatar string
- AttributesInBind bool
- UsePagedSearch bool
- SearchPageSize int
- Filter string
- AdminFilter string
- GroupsEnabled bool
- GroupDN string
- GroupFilter string
- GroupMemberUID string
- UserUID string
- RestrictedFilter string
- AllowDeactivateAll bool
- IsActive bool
- IsSyncEnabled bool
- SMTPAuth string
- SMTPHost string
- SMTPPort int
- AllowedDomains string
- SecurityProtocol int `binding:"Range(0,2)"`
- TLS bool
- SkipVerify bool
- HeloHostname string
- DisableHelo bool
- ForceSMTPS bool
- PAMServiceName string
- PAMEmailDomain string
+ // LDAP
+ Host string
+ Port int
+ BindDN string
+ BindPassword string
+ UserBase string
+ UserDN string
+ AttributeUsername string
+ AttributeName string
+ AttributeSurname string
+ AttributeMail string
+ AttributeSSHPublicKey string
+ AttributeAvatar string
+ AttributesInBind bool
+ UsePagedSearch bool
+ SearchPageSize int
+ Filter string
+ AdminFilter string
+ GroupsEnabled bool
+ GroupDN string
+ GroupFilter string
+ GroupMemberUID string
+ UserUID string
+ RestrictedFilter string
+ AllowDeactivateAll bool
+ GroupTeamMap string `binding:"ValidGroupTeamMap"`
+ GroupTeamMapRemoval bool
+
+ // SMTP
+ SMTPAuth string
+ SMTPHost string
+ SMTPPort int
+ AllowedDomains string
+ SecurityProtocol int `binding:"Range(0,2)"`
+ TLS bool
+ SkipVerify bool
+ HeloHostname string
+ DisableHelo bool
+ ForceSMTPS bool
+
+ // PAM
+ PAMServiceName string
+ PAMEmailDomain string
+
+ // Oauth2 & OIDC
Oauth2Provider string
Oauth2Key string
Oauth2Secret string
@@ -76,13 +85,15 @@ type AuthenticationForm struct {
Oauth2RestrictedGroup string
Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"`
Oauth2GroupTeamMapRemoval bool
- SSPIAutoCreateUsers bool
- SSPIAutoActivateUsers bool
- SSPIStripDomainNames bool
- SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"`
- SSPIDefaultLanguage string
- GroupTeamMap string `binding:"ValidGroupTeamMap"`
- GroupTeamMapRemoval bool
+ Oauth2SSHPublicKeyClaimName string
+ Oauth2FullNameClaimName string
+
+ // SSPI
+ SSPIAutoCreateUsers bool
+ SSPIAutoActivateUsers bool
+ SSPIStripDomainNames bool
+ SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"`
+ SSPIDefaultLanguage string
}
// Validate validates fields
diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl
index 781f514af4..7b96b4e94f 100644
--- a/templates/admin/auth/edit.tmpl
+++ b/templates/admin/auth/edit.tmpl
@@ -301,20 +301,31 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{if $cfg.CustomURLMapping}}{{$cfg.CustomURLMapping.Tenant}}{{end}}">
</div>
- {{range .OAuth2Providers}}{{if .CustomURLSettings}}
+ {{range .OAuth2Providers}}
+ <input id="{{.Name}}_SupportSSHPublicKey" value="{{.SupportSSHPublicKey}}" type="hidden">
+ {{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden">
<input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden">
<input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden">
<input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden">
<input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden">
- {{end}}{{end}}
+ {{end}}
+ {{end}}
<div class="field">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{StringUtils.Join $cfg.Scopes ","}}{{end}}">
</div>
<div class="field">
+ <label>{{ctx.Locale.Tr "admin.auths.oauth2_full_name_claim_name"}}</label>
+ <input name="oauth2_full_name_claim_name" value="{{$cfg.FullNameClaimName}}" placeholder="name">
+ </div>
+ <div class="field oauth2_ssh_public_key_claim_name">
+ <label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
+ <input name="oauth2_ssh_public_key_claim_name" value="{{$cfg.SSHPublicKeyClaimName}}" placeholder="sshpubkey">
+ </div>
+ <div class="field">
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}">
<p class="help">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name_helper"}}</p>
diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl
index f02c5bdf30..69590635e4 100644
--- a/templates/admin/auth/source/oauth.tmpl
+++ b/templates/admin/auth/source/oauth.tmpl
@@ -63,19 +63,31 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{.oauth2_tenant}}">
</div>
- {{range .OAuth2Providers}}{{if .CustomURLSettings}}
+ {{range .OAuth2Providers}}
+ <input id="{{.Name}}_SupportSSHPublicKey" value="{{.SupportSSHPublicKey}}" type="hidden">
+ {{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden">
<input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden">
<input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden">
<input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden">
<input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden">
- {{end}}{{end}}
+ {{end}}
+ {{end}}
<div class="field">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{.oauth2_scopes}}">
</div>
+
+ <div class="field">
+ <label>{{ctx.Locale.Tr "admin.auths.oauth2_full_name_claim_name"}}</label>
+ <input name="oauth2_full_name_claim_name" value="{{.oauth2_full_name_claim_name}}" placeholder="name">
+ </div>
+ <div class="field oauth2_ssh_public_key_claim_name">
+ <label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
+ <input name="oauth2_ssh_public_key_claim_name" value="{{.oauth2_ssh_public_key_claim_name}}" placeholder="sshpubkey">
+ </div>
<div class="field">
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}">
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go
index f8bc33c32a..a2247801f7 100644
--- a/tests/integration/oauth_test.go
+++ b/tests/integration/oauth_test.go
@@ -9,9 +9,11 @@ import (
"fmt"
"io"
"net/http"
+ "net/http/httptest"
"strings"
"testing"
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
@@ -20,9 +22,13 @@ import (
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/oauth2_provider"
"code.gitea.io/gitea/tests"
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/gothic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -931,3 +937,107 @@ func testOAuth2WellKnown(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.Enabled, false)()
MakeRequest(t, NewRequest(t, "GET", urlOpenidConfiguration), http.StatusNotFound)
}
+
+func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
+ cfg.Provider = util.IfZero(cfg.Provider, "gitea")
+ err := auth_model.CreateSource(db.DefaultContext, &auth_model.Source{
+ Type: auth_model.OAuth2,
+ Name: authName,
+ IsActive: true,
+ Cfg: &cfg,
+ })
+ require.NoError(t, err)
+}
+
+func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
+ defer tests.PrepareTestEnv(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"
+ }`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer mockServer.Close()
+
+ ctx := t.Context()
+ oauth2Source := oauth2.Source{
+ Provider: "openidConnect",
+ ClientID: "test-client-id",
+ SSHPublicKeyClaimName: "sshpubkey",
+ FullNameClaimName: "name",
+ OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
+ }
+ addOAuth2Source(t, "test-oidc-source", oauth2Source)
+ authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(ctx, "test-oidc-source")
+ require.NoError(t, err)
+
+ sshKey1 := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf"
+ sshKey2 := "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo="
+ sshKey3 := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9"
+ cases := []struct {
+ testName string
+ mockFullName string
+ mockRawData map[string]any
+ expectedSSHPubKeys []string
+ }{
+ {
+ testName: "Login1",
+ mockFullName: "FullName1",
+ mockRawData: map[string]any{"sshpubkey": []any{sshKey1 + " any-comment"}},
+ expectedSSHPubKeys: []string{sshKey1},
+ },
+ {
+ testName: "Login2",
+ mockFullName: "FullName2",
+ mockRawData: map[string]any{"sshpubkey": []any{sshKey2 + " any-comment", sshKey3}},
+ expectedSSHPubKeys: []string{sshKey2, sshKey3},
+ },
+ {
+ testName: "Login3",
+ mockFullName: "FullName3",
+ mockRawData: map[string]any{},
+ expectedSSHPubKeys: []string{},
+ },
+ }
+
+ session := emptyTestSession(t)
+ for _, c := range cases {
+ t.Run(c.testName, func(t *testing.T) {
+ defer test.MockVariableValue(&setting.OAuth2Client.Username, "")()
+ defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
+ defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ return goth.User{
+ Provider: authSource.Cfg.(*oauth2.Source).Provider,
+ UserID: "oidc-userid",
+ Email: "oidc-email@example.com",
+ RawData: c.mockRawData,
+ Name: c.mockFullName,
+ }, nil
+ })()
+ req := NewRequest(t, "GET", "/user/oauth2/test-oidc-source/callback?code=XYZ&state=XYZ")
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "oidc-userid"})
+ keys, _, err := db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
+ ListOptions: db.ListOptionsAll,
+ OwnerID: user.ID,
+ LoginSourceID: authSource.ID,
+ })
+ require.NoError(t, err)
+ var sshPubKeys []string
+ for _, key := range keys {
+ sshPubKeys = append(sshPubKeys, key.Content)
+ }
+ assert.ElementsMatch(t, c.expectedSSHPubKeys, sshPubKeys)
+ assert.Equal(t, c.mockFullName, user.FullName)
+ })
+ }
+}
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
index 67af5b5877..aa1571c163 100644
--- a/tests/integration/signin_test.go
+++ b/tests/integration/signin_test.go
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
+ auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@@ -17,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/routers/web/auth"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/tests"
@@ -103,8 +105,9 @@ func TestEnablePasswordSignInFormAndEnablePasskeyAuth(t *testing.T) {
defer tests.PrepareTestEnv(t)()
mockLinkAccount := func(ctx *context.Context) {
+ authSource := auth_model.Source{ID: 1}
gothUser := goth.User{Email: "invalid-email", Name: "."}
- _ = ctx.Session.Set("linkAccountGothUser", gothUser)
+ _ = ctx.Session.Set("linkAccountData", auth.LinkAccountData{AuthSource: authSource, GothUser: gothUser})
}
t.Run("EnablePasswordSignInForm=false", func(t *testing.T) {
diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts
index 4ed5d62eee..dd5b1f464d 100644
--- a/web_src/js/features/admin/common.ts
+++ b/web_src/js/features/admin/common.ts
@@ -102,6 +102,9 @@ function initAdminAuthentication() {
break;
}
}
+
+ const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
+ toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
onOAuth2UseCustomURLChange(applyDefaultValues);
}