From 0981ec30c3d5218939d44fc2f40725b0b4a03684 Mon Sep 17 00:00:00 2001
From: zeripath <art27@cantab.net>
Date: Tue, 14 Dec 2021 08:37:11 +0000
Subject: [PATCH] Add Option to synchronize Admin & Restricted states from
 OIDC/OAuth2 along with Setting Scopes (#16766)

* Add setting to OAuth handlers to override local 2FA settings

This PR adds a setting to OAuth and OpenID login sources to allow the source to
override local 2FA requirements.

Fix #13939

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Fix regression from #16544

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Add scopes settings

Signed-off-by: Andrew Thornton <art27@cantab.net>

* fix trace logging in auth_openid

Signed-off-by: Andrew Thornton <art27@cantab.net>

* add required claim options

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Move UpdateExternalUser to externalaccount

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Allow OAuth2/OIDC to set Admin/Restricted status

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Allow use of the same group claim name for the prohibit login value

Signed-off-by: Andrew Thornton <art27@cantab.net>

* fixup! Move UpdateExternalUser to externalaccount

* as per wxiaoguang

Signed-off-by: Andrew Thornton <art27@cantab.net>

* add label back in

Signed-off-by: Andrew Thornton <art27@cantab.net>

* adjust localisation

Signed-off-by: Andrew Thornton <art27@cantab.net>

* placate lint

Signed-off-by: Andrew Thornton <art27@cantab.net>

Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
---
 cmd/admin.go                                  |  58 +++++++++
 docs/content/doc/usage/command-line.en-us.md  |  14 ++
 models/user/external_login_user.go            |  38 +-----
 modules/templates/helper.go                   |   1 +
 options/locale/locale_en-US.ini               |   5 +
 routers/web/admin/auths.go                    |   6 +-
 routers/web/user/auth.go                      | 123 ++++++++++++++----
 routers/web/user/auth_openid.go               |  14 +-
 .../auth/source/oauth2/providers_custom.go    |  32 +++--
 .../auth/source/oauth2/providers_openid.go    |   7 +-
 .../auth/source/oauth2/providers_simple.go    |   5 +-
 services/auth/source/oauth2/source.go         |   9 +-
 services/externalaccount/link.go              |  29 +++++
 services/externalaccount/user.go              |  26 +++-
 services/forms/auth_form.go                   |   6 +
 templates/admin/auth/edit.tmpl                |  32 ++++-
 templates/admin/auth/source/oauth.tmpl        |  27 ++++
 17 files changed, 344 insertions(+), 88 deletions(-)
 create mode 100644 services/externalaccount/link.go

diff --git a/cmd/admin.go b/cmd/admin.go
index f36e9f5de7..65a0bfb7bf 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -299,6 +299,36 @@ var (
 			Name:  "skip-local-2fa",
 			Usage: "Set to true to skip local 2fa for users authenticated by this source",
 		},
+		cli.StringSliceFlag{
+			Name:  "scopes",
+			Value: nil,
+			Usage: "Scopes to request when to authenticate against this OAuth2 source",
+		},
+		cli.StringFlag{
+			Name:  "required-claim-name",
+			Value: "",
+			Usage: "Claim name that has to be set to allow users to login with this source",
+		},
+		cli.StringFlag{
+			Name:  "required-claim-value",
+			Value: "",
+			Usage: "Claim value that has to be set to allow users to login with this source",
+		},
+		cli.StringFlag{
+			Name:  "group-claim-name",
+			Value: "",
+			Usage: "Claim name providing group names for this source",
+		},
+		cli.StringFlag{
+			Name:  "admin-group",
+			Value: "",
+			Usage: "Group Claim value for administrator users",
+		},
+		cli.StringFlag{
+			Name:  "restricted-group",
+			Value: "",
+			Usage: "Group Claim value for restricted users",
+		},
 	}
 
 	microcmdAuthUpdateOauth = cli.Command{
@@ -649,6 +679,12 @@ func parseOAuth2Config(c *cli.Context) *oauth2.Source {
 		CustomURLMapping:              customURLMapping,
 		IconURL:                       c.String("icon-url"),
 		SkipLocalTwoFA:                c.Bool("skip-local-2fa"),
+		Scopes:                        c.StringSlice("scopes"),
+		RequiredClaimName:             c.String("required-claim-name"),
+		RequiredClaimValue:            c.String("required-claim-value"),
+		GroupClaimName:                c.String("group-claim-name"),
+		AdminGroup:                    c.String("admin-group"),
+		RestrictedGroup:               c.String("restricted-group"),
 	}
 }
 
@@ -711,6 +747,28 @@ func runUpdateOauth(c *cli.Context) error {
 		oAuth2Config.IconURL = c.String("icon-url")
 	}
 
+	if c.IsSet("scopes") {
+		oAuth2Config.Scopes = c.StringSlice("scopes")
+	}
+
+	if c.IsSet("required-claim-name") {
+		oAuth2Config.RequiredClaimName = c.String("required-claim-name")
+
+	}
+	if c.IsSet("required-claim-value") {
+		oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
+	}
+
+	if c.IsSet("group-claim-name") {
+		oAuth2Config.GroupClaimName = c.String("group-claim-name")
+	}
+	if c.IsSet("admin-group") {
+		oAuth2Config.AdminGroup = c.String("admin-group")
+	}
+	if c.IsSet("restricted-group") {
+		oAuth2Config.RestrictedGroup = c.String("restricted-group")
+	}
+
 	// update custom URL mapping
 	var customURLMapping = &oauth2.CustomURLMapping{}
 
diff --git a/docs/content/doc/usage/command-line.en-us.md b/docs/content/doc/usage/command-line.en-us.md
index 296f87b51c..5e794b56c2 100644
--- a/docs/content/doc/usage/command-line.en-us.md
+++ b/docs/content/doc/usage/command-line.en-us.md
@@ -129,6 +129,13 @@ Admin operations:
         - `--custom-profile-url`: Use a custom Profile URL (option for GitLab/GitHub).
         - `--custom-email-url`: Use a custom Email URL (option for GitHub).
         - `--icon-url`: Custom icon URL for OAuth2 login source.
+        - `--override-local-2fa`: Allow source to override local 2fa. (Optional)
+        - `--scopes`: Addtional scopes to request for this OAuth2 source. (Optional)
+        - `--required-claim-name`: Claim name that has to be set to allow users to login with this source. (Optional)
+        - `--required-claim-value`: Claim value that has to be set to allow users to login with this source. (Optional)
+        - `--group-claim-name`: Claim name providing group names for this source. (Optional)
+        - `--admin-group`: Group Claim value for administrator users. (Optional)
+        - `--restricted-group`: Group Claim value for restricted users. (Optional)
       - Examples:
         - `gitea admin auth add-oauth --name external-github --provider github --key OBTAIN_FROM_SOURCE --secret OBTAIN_FROM_SOURCE`
     - `update-oauth`:
@@ -145,6 +152,13 @@ Admin operations:
         - `--custom-profile-url`: Use a custom Profile URL (option for GitLab/GitHub).
         - `--custom-email-url`: Use a custom Email URL (option for GitHub).
         - `--icon-url`: Custom icon URL for OAuth2 login source.
+        - `--override-local-2fa`: Allow source to override local 2fa. (Optional)
+        - `--scopes`: Addtional scopes to request for this OAuth2 source.
+        - `--required-claim-name`: Claim name that has to be set to allow users to login with this source. (Optional)
+        - `--required-claim-value`: Claim value that has to be set to allow users to login with this source. (Optional)
+        - `--group-claim-name`: Claim name providing group names for this source. (Optional)
+        - `--admin-group`: Group Claim value for administrator users. (Optional)
+        - `--restricted-group`: Group Claim value for restricted users. (Optional)
       - Examples:
         - `gitea admin auth update-oauth --id 1 --name external-github-updated`
     - `add-ldap`: Add new LDAP (via Bind DN) authentication source
diff --git a/models/user/external_login_user.go b/models/user/external_login_user.go
index 354dee04a9..8cf7c652b1 100644
--- a/models/user/external_login_user.go
+++ b/models/user/external_login_user.go
@@ -10,9 +10,7 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/models/login"
 
-	"github.com/markbates/goth"
 	"xorm.io/builder"
 )
 
@@ -139,42 +137,18 @@ func GetUserIDByExternalUserID(provider, userID string) (int64, error) {
 	return id, nil
 }
 
-// UpdateExternalUser updates external user's information
-func UpdateExternalUser(user *User, gothUser goth.User) error {
-	loginSource, err := login.GetActiveOAuth2LoginSourceByName(gothUser.Provider)
-	if err != nil {
-		return err
-	}
-	externalLoginUser := &ExternalLoginUser{
-		ExternalID:        gothUser.UserID,
-		UserID:            user.ID,
-		LoginSourceID:     loginSource.ID,
-		RawData:           gothUser.RawData,
-		Provider:          gothUser.Provider,
-		Email:             gothUser.Email,
-		Name:              gothUser.Name,
-		FirstName:         gothUser.FirstName,
-		LastName:          gothUser.LastName,
-		NickName:          gothUser.NickName,
-		Description:       gothUser.Description,
-		AvatarURL:         gothUser.AvatarURL,
-		Location:          gothUser.Location,
-		AccessToken:       gothUser.AccessToken,
-		AccessTokenSecret: gothUser.AccessTokenSecret,
-		RefreshToken:      gothUser.RefreshToken,
-		ExpiresAt:         gothUser.ExpiresAt,
-	}
-
-	has, err := db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", gothUser.UserID, loginSource.ID).
+// UpdateExternalUserByExternalID updates an external user's information
+func UpdateExternalUserByExternalID(external *ExternalLoginUser) error {
+	has, err := db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).
 		NoAutoCondition().
-		Exist(externalLoginUser)
+		Exist(external)
 	if err != nil {
 		return err
 	} else if !has {
-		return ErrExternalLoginUserNotExist{user.ID, loginSource.ID}
+		return ErrExternalLoginUserNotExist{external.UserID, external.LoginSourceID}
 	}
 
-	_, err = db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", gothUser.UserID, loginSource.ID).AllCols().Update(externalLoginUser)
+	_, err = db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external)
 	return err
 }
 
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index f4529503d8..1841ad2732 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -377,6 +377,7 @@ func NewFuncMap() []template.FuncMap {
 		"MermaidMaxSourceCharacters": func() int {
 			return setting.MermaidMaxSourceCharacters
 		},
+		"Join":        strings.Join,
 		"QueryEscape": url.QueryEscape,
 	}}
 }
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 2819223103..3f180ddbec 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2521,6 +2521,11 @@ auths.oauth2_emailURL = Email URL
 auths.skip_local_two_fa = Skip local 2FA
 auths.skip_local_two_fa_helper = Leaving unset means local users with 2FA set will still have to pass 2FA to log on
 auths.oauth2_tenant = Tenant
+auths.oauth2_scopes = Additional Scopes
+auths.oauth2_required_claim_name = Required Claim Name
+auths.oauth2_required_claim_name_helper = Set this name to restrict login from this source to users with a claim with this name
+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.enable_auto_register = Enable Auto Registration
 auths.sspi_auto_create_users = Automatically create users
 auths.sspi_auto_create_users_helper = Allow SSPI auth method to automatically create new accounts for users that login for the first time
diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go
index 5fd15b5c5a..b288273871 100644
--- a/routers/web/admin/auths.go
+++ b/routers/web/admin/auths.go
@@ -11,6 +11,7 @@ import (
 	"net/url"
 	"regexp"
 	"strconv"
+	"strings"
 
 	"code.gitea.io/gitea/models/login"
 	"code.gitea.io/gitea/modules/auth/pam"
@@ -187,6 +188,9 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
 		OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL,
 		CustomURLMapping:              customURLMapping,
 		IconURL:                       form.Oauth2IconURL,
+		Scopes:                        strings.Split(form.Oauth2Scopes, ","),
+		RequiredClaimName:             form.Oauth2RequiredClaimName,
+		RequiredClaimValue:            form.Oauth2RequiredClaimValue,
 		SkipLocalTwoFA:                form.SkipLocalTwoFA,
 	}
 }
@@ -329,8 +333,8 @@ func EditAuthSource(ctx *context.Context) {
 				break
 			}
 		}
-
 	}
+
 	ctx.HTML(http.StatusOK, tplAuthEdit)
 }
 
diff --git a/routers/web/user/auth.go b/routers/web/user/auth.go
index 42cd977b54..55a4b11007 100644
--- a/routers/web/user/auth.go
+++ b/routers/web/user/auth.go
@@ -320,16 +320,8 @@ func TwoFactorPost(ctx *context.Context) {
 		}
 
 		if ctx.Session.Get("linkAccount") != nil {
-			gothUser := ctx.Session.Get("linkAccountGothUser")
-			if gothUser == nil {
-				ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
-				return
-			}
-
-			err = externalaccount.LinkAccountToUser(u, gothUser.(goth.User))
-			if err != nil {
+			if err := externalaccount.LinkAccountFromStore(ctx.Session, u); err != nil {
 				ctx.ServerError("UserSignIn", err)
-				return
 			}
 		}
 
@@ -506,16 +498,8 @@ func U2FSign(ctx *context.Context) {
 			}
 
 			if ctx.Session.Get("linkAccount") != nil {
-				gothUser := ctx.Session.Get("linkAccountGothUser")
-				if gothUser == nil {
-					ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
-					return
-				}
-
-				err = externalaccount.LinkAccountToUser(user, gothUser.(goth.User))
-				if err != nil {
+				if err := externalaccount.LinkAccountFromStore(ctx.Session, user); err != nil {
 					ctx.ServerError("UserSignIn", err)
-					return
 				}
 			}
 			redirect := handleSignInFull(ctx, user, remember, false)
@@ -653,6 +637,13 @@ func SignInOAuthCallback(ctx *context.Context) {
 	u, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp)
 
 	if err != nil {
+		if user_model.IsErrUserProhibitLogin(err) {
+			uplerr := err.(*user_model.ErrUserProhibitLogin)
+			log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
+			ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+			ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+			return
+		}
 		ctx.ServerError("UserSignIn", err)
 		return
 	}
@@ -690,6 +681,8 @@ func SignInOAuthCallback(ctx *context.Context) {
 				IsRestricted: setting.Service.DefaultUserIsRestricted,
 			}
 
+			setUserGroupClaims(loginSource, u, &gothUser)
+
 			if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
 				// error already handled
 				return
@@ -704,6 +697,53 @@ func SignInOAuthCallback(ctx *context.Context) {
 	handleOAuth2SignIn(ctx, loginSource, u, gothUser)
 }
 
+func claimValueToStringSlice(claimValue interface{}) []string {
+	var groups []string
+
+	switch rawGroup := claimValue.(type) {
+	case []string:
+		groups = rawGroup
+	default:
+		str := fmt.Sprintf("%s", rawGroup)
+		groups = strings.Split(str, ",")
+	}
+	return groups
+}
+
+func setUserGroupClaims(loginSource *login.Source, u *user_model.User, gothUser *goth.User) bool {
+
+	source := loginSource.Cfg.(*oauth2.Source)
+	if source.GroupClaimName == "" || (source.AdminGroup == "" && source.RestrictedGroup == "") {
+		return false
+	}
+
+	groupClaims, has := gothUser.RawData[source.GroupClaimName]
+	if !has {
+		return false
+	}
+
+	groups := claimValueToStringSlice(groupClaims)
+
+	wasAdmin, wasRestricted := u.IsAdmin, u.IsRestricted
+
+	if source.AdminGroup != "" {
+		u.IsAdmin = false
+	}
+	if source.RestrictedGroup != "" {
+		u.IsRestricted = false
+	}
+
+	for _, g := range groups {
+		if source.AdminGroup != "" && g == source.AdminGroup {
+			u.IsAdmin = true
+		} else if source.RestrictedGroup != "" && g == source.RestrictedGroup {
+			u.IsRestricted = true
+		}
+	}
+
+	return wasAdmin != u.IsAdmin || wasRestricted != u.IsRestricted
+}
+
 func getUserName(gothUser *goth.User) string {
 	switch setting.OAuth2Client.Username {
 	case setting.OAuth2UsernameEmail:
@@ -774,13 +814,21 @@ func handleOAuth2SignIn(ctx *context.Context, source *login.Source, u *user_mode
 
 		// Register last login
 		u.SetLastLogin()
-		if err := user_model.UpdateUserCols(db.DefaultContext, u, "last_login_unix"); err != nil {
+
+		// Update GroupClaims
+		changed := setUserGroupClaims(source, u, &gothUser)
+		cols := []string{"last_login_unix"}
+		if changed {
+			cols = append(cols, "is_admin", "is_restricted")
+		}
+
+		if err := user_model.UpdateUserCols(db.DefaultContext, u, cols...); err != nil {
 			ctx.ServerError("UpdateUserCols", err)
 			return
 		}
 
 		// update external user information
-		if err := user_model.UpdateExternalUser(u, gothUser); err != nil {
+		if err := externalaccount.UpdateExternalUser(u, gothUser); err != nil {
 			log.Error("UpdateExternalUser failed: %v", err)
 		}
 
@@ -794,6 +842,14 @@ func handleOAuth2SignIn(ctx *context.Context, source *login.Source, u *user_mode
 		return
 	}
 
+	changed := setUserGroupClaims(source, u, &gothUser)
+	if changed {
+		if err := user_model.UpdateUserCols(db.DefaultContext, u, "is_admin", "is_restricted"); err != nil {
+			ctx.ServerError("UpdateUserCols", err)
+			return
+		}
+	}
+
 	// User needs to use 2FA, save data and redirect to 2FA page.
 	if err := ctx.Session.Set("twofaUid", u.ID); err != nil {
 		log.Error("Error setting twofaUid in session: %v", err)
@@ -818,7 +874,9 @@ func handleOAuth2SignIn(ctx *context.Context, source *login.Source, u *user_mode
 // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
 // login the user
 func oAuth2UserLoginCallback(loginSource *login.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
-	gothUser, err := loginSource.Cfg.(*oauth2.Source).Callback(request, response)
+	oauth2Source := loginSource.Cfg.(*oauth2.Source)
+
+	gothUser, err := oauth2Source.Callback(request, response)
 	if err != nil {
 		if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
 			log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength)
@@ -827,6 +885,27 @@ func oAuth2UserLoginCallback(loginSource *login.Source, request *http.Request, r
 		return nil, goth.User{}, err
 	}
 
+	if oauth2Source.RequiredClaimName != "" {
+		claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
+		if !has {
+			return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+		}
+
+		if oauth2Source.RequiredClaimValue != "" {
+			groups := claimValueToStringSlice(claimInterface)
+			found := false
+			for _, group := range groups {
+				if group == oauth2Source.RequiredClaimValue {
+					found = true
+					break
+				}
+			}
+			if !found {
+				return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+			}
+		}
+	}
+
 	user := &user_model.User{
 		LoginName:   gothUser.UserID,
 		LoginType:   login.OAuth2,
@@ -1354,7 +1433,7 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
 
 	// update external user information
 	if gothUser != nil {
-		if err := user_model.UpdateExternalUser(u, *gothUser); err != nil {
+		if err := externalaccount.UpdateExternalUser(u, *gothUser); err != nil {
 			log.Error("UpdateExternalUser failed: %v", err)
 		}
 	}
diff --git a/routers/web/user/auth_openid.go b/routers/web/user/auth_openid.go
index 68e166d12c..884ec29c21 100644
--- a/routers/web/user/auth_openid.go
+++ b/routers/web/user/auth_openid.go
@@ -144,10 +144,10 @@ func SignInOpenIDPost(ctx *context.Context) {
 // signInOpenIDVerify handles response from OpenID provider
 func signInOpenIDVerify(ctx *context.Context) {
 
-	log.Trace("Incoming call to: " + ctx.Req.URL.String())
+	log.Trace("Incoming call to: %s", ctx.Req.URL.String())
 
 	fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
-	log.Trace("Full URL: " + fullURL)
+	log.Trace("Full URL: %s", fullURL)
 
 	var id, err = openid.Verify(fullURL)
 	if err != nil {
@@ -157,7 +157,7 @@ func signInOpenIDVerify(ctx *context.Context) {
 		return
 	}
 
-	log.Trace("Verified ID: " + id)
+	log.Trace("Verified ID: %s", id)
 
 	/* Now we should seek for the user and log him in, or prompt
 	 * to register if not found */
@@ -180,7 +180,7 @@ func signInOpenIDVerify(ctx *context.Context) {
 		return
 	}
 
-	log.Trace("User with openid " + id + " does not exist, should connect or register")
+	log.Trace("User with openid: %s does not exist, should connect or register", id)
 
 	parsedURL, err := url.Parse(fullURL)
 	if err != nil {
@@ -199,7 +199,7 @@ func signInOpenIDVerify(ctx *context.Context) {
 	email := values.Get("openid.sreg.email")
 	nickname := values.Get("openid.sreg.nickname")
 
-	log.Trace("User has email=" + email + " and nickname=" + nickname)
+	log.Trace("User has email=%s and nickname=%s", email, nickname)
 
 	if email != "" {
 		u, err = user_model.GetUserByEmail(email)
@@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
 			log.Error("signInOpenIDVerify: %v", err)
 		}
 		if u != nil {
-			log.Trace("Local user " + u.LowerName + " has OpenID provided email " + email)
+			log.Trace("Local user %s has OpenID provided email %s", u.LowerName, email)
 		}
 	}
 
@@ -228,7 +228,7 @@ func signInOpenIDVerify(ctx *context.Context) {
 			}
 		}
 		if u != nil {
-			log.Trace("Local user " + u.LowerName + " has OpenID provided nickname " + nickname)
+			log.Trace("Local user %s has OpenID provided nickname %s", u.LowerName, nickname)
 		}
 	}
 
diff --git a/services/auth/source/oauth2/providers_custom.go b/services/auth/source/oauth2/providers_custom.go
index f2cff131f4..c3ebdf9df0 100644
--- a/services/auth/source/oauth2/providers_custom.go
+++ b/services/auth/source/oauth2/providers_custom.go
@@ -17,7 +17,7 @@ import (
 )
 
 // CustomProviderNewFn creates a goth.Provider using a custom url mapping
-type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error)
+type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error)
 
 // CustomProvider is a GothProvider that has CustomURL features
 type CustomProvider struct {
@@ -35,7 +35,7 @@ func (c *CustomProvider) CustomURLSettings() *CustomURLSettings {
 func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
 	custom := c.customURLSettings.OverrideWith(source.CustomURLMapping)
 
-	return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom)
+	return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom, source.Scopes)
 }
 
 // NewCustomProvider is a constructor function for custom providers
@@ -60,8 +60,7 @@ func init() {
 			ProfileURL: availableAttribute(github.ProfileURL),
 			EmailURL:   availableAttribute(github.EmailURL),
 		},
-		func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) {
-			scopes := []string{}
+		func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
 			if setting.OAuth2Client.EnableAutoRegistration {
 				scopes = append(scopes, "user:email")
 			}
@@ -73,8 +72,9 @@ func init() {
 			AuthURL:    availableAttribute(gitlab.AuthURL),
 			TokenURL:   availableAttribute(gitlab.TokenURL),
 			ProfileURL: availableAttribute(gitlab.ProfileURL),
-		}, func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) {
-			return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, "read_user"), nil
+		}, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+			scopes = append(scopes, "read_user")
+			return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
 		}))
 
 	RegisterGothProvider(NewCustomProvider(
@@ -83,8 +83,8 @@ func init() {
 			AuthURL:    requiredAttribute(gitea.AuthURL),
 			ProfileURL: requiredAttribute(gitea.ProfileURL),
 		},
-		func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) {
-			return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL), nil
+		func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+			return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
 		}))
 
 	RegisterGothProvider(NewCustomProvider(
@@ -93,25 +93,31 @@ func init() {
 			AuthURL:    requiredAttribute(nextcloud.AuthURL),
 			ProfileURL: requiredAttribute(nextcloud.ProfileURL),
 		},
-		func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) {
-			return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL), nil
+		func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+			return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
 		}))
 
 	RegisterGothProvider(NewCustomProvider(
 		"mastodon", "Mastodon", &CustomURLSettings{
 			AuthURL: requiredAttribute(mastodon.InstanceURL),
 		},
-		func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) {
-			return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL), nil
+		func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+			return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil
 		}))
 
 	RegisterGothProvider(NewCustomProvider(
 		"azureadv2", "Azure AD v2", &CustomURLSettings{
 			Tenant: requiredAttribute("organizations"),
 		},
-		func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) {
+		func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+			azureScopes := make([]azureadv2.ScopeType, len(scopes))
+			for i, scope := range scopes {
+				azureScopes[i] = azureadv2.ScopeType(scope)
+			}
+
 			return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{
 				Tenant: azureadv2.TenantType(custom.Tenant),
+				Scopes: azureScopes,
 			}), nil
 		},
 	))
diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go
index 7c3836503c..838311b4a1 100644
--- a/services/auth/source/oauth2/providers_openid.go
+++ b/services/auth/source/oauth2/providers_openid.go
@@ -33,7 +33,12 @@ func (o *OpenIDProvider) Image() string {
 
 // CreateGothProvider creates a GothProvider from this Provider
 func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
-	provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, setting.OAuth2Client.OpenIDConnectScopes...)
+	scopes := setting.OAuth2Client.OpenIDConnectScopes
+	if len(scopes) == 0 {
+		scopes = append(scopes, source.Scopes...)
+	}
+
+	provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
 	if err != nil {
 		log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
 	}
diff --git a/services/auth/source/oauth2/providers_simple.go b/services/auth/source/oauth2/providers_simple.go
index 5a7062e6c3..a4d61eb2f3 100644
--- a/services/auth/source/oauth2/providers_simple.go
+++ b/services/auth/source/oauth2/providers_simple.go
@@ -31,7 +31,10 @@ type SimpleProvider struct {
 
 // CreateGothProvider creates a GothProvider from this Provider
 func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
-	return c.newFn(source.ClientID, source.ClientSecret, callbackURL, c.scopes...), nil
+	scopes := make([]string, len(c.scopes)+len(source.Scopes))
+	copy(scopes, c.scopes)
+	copy(scopes[len(c.scopes):], source.Scopes)
+	return c.newFn(source.ClientID, source.ClientSecret, callbackURL, scopes...), nil
 }
 
 // NewSimpleProvider is a constructor function for simple providers
diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go
index bedaed7ef3..68ff08d1ee 100644
--- a/services/auth/source/oauth2/source.go
+++ b/services/auth/source/oauth2/source.go
@@ -24,7 +24,14 @@ type Source struct {
 	OpenIDConnectAutoDiscoveryURL string
 	CustomURLMapping              *CustomURLMapping
 	IconURL                       string
-	SkipLocalTwoFA                bool `json:",omitempty"`
+
+	Scopes             []string
+	RequiredClaimName  string
+	RequiredClaimValue string
+	GroupClaimName     string
+	AdminGroup         string
+	RestrictedGroup    string
+	SkipLocalTwoFA     bool `json:",omitempty"`
 
 	// reference to the loginSource
 	loginSource *login.Source
diff --git a/services/externalaccount/link.go b/services/externalaccount/link.go
new file mode 100644
index 0000000000..e71a37090f
--- /dev/null
+++ b/services/externalaccount/link.go
@@ -0,0 +1,29 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+	"fmt"
+
+	user_model "code.gitea.io/gitea/models/user"
+	"github.com/markbates/goth"
+)
+
+// Store represents a thing that stores things
+type Store interface {
+	Get(interface{}) interface{}
+	Set(interface{}, interface{}) error
+	Release() error
+}
+
+// LinkAccountFromStore links the provided user with a stored external user
+func LinkAccountFromStore(store Store, user *user_model.User) error {
+	gothUser := store.Get("linkAccountGothUser")
+	if gothUser == nil {
+		return fmt.Errorf("not in LinkAccount session")
+	}
+
+	return LinkAccountToUser(user, gothUser.(goth.User))
+}
diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go
index f7280e90e4..8fd0680a1f 100644
--- a/services/externalaccount/user.go
+++ b/services/externalaccount/user.go
@@ -15,14 +15,12 @@ import (
 	"github.com/markbates/goth"
 )
 
-// LinkAccountToUser link the gothUser to the user
-func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
+func toExternalLoginUser(user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) {
 	loginSource, err := login.GetActiveOAuth2LoginSourceByName(gothUser.Provider)
 	if err != nil {
-		return err
+		return nil, err
 	}
-
-	externalLoginUser := &user_model.ExternalLoginUser{
+	return &user_model.ExternalLoginUser{
 		ExternalID:        gothUser.UserID,
 		UserID:            user.ID,
 		LoginSourceID:     loginSource.ID,
@@ -40,6 +38,14 @@ func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
 		AccessTokenSecret: gothUser.AccessTokenSecret,
 		RefreshToken:      gothUser.RefreshToken,
 		ExpiresAt:         gothUser.ExpiresAt,
+	}, nil
+}
+
+// LinkAccountToUser link the gothUser to the user
+func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
+	externalLoginUser, err := toExternalLoginUser(user, gothUser)
+	if err != nil {
+		return err
 	}
 
 	if err := user_model.LinkExternalToUser(user, externalLoginUser); err != nil {
@@ -62,3 +68,13 @@ func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
 
 	return nil
 }
+
+// UpdateExternalUser updates external user's information
+func UpdateExternalUser(user *user_model.User, gothUser goth.User) error {
+	externalLoginUser, err := toExternalLoginUser(user, gothUser)
+	if err != nil {
+		return err
+	}
+
+	return user_model.UpdateExternalUserByExternalID(externalLoginUser)
+}
diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go
index 2c6966d266..d096292601 100644
--- a/services/forms/auth_form.go
+++ b/services/forms/auth_form.go
@@ -67,6 +67,12 @@ type AuthenticationForm struct {
 	Oauth2EmailURL                string
 	Oauth2IconURL                 string
 	Oauth2Tenant                  string
+	Oauth2Scopes                  string
+	Oauth2RequiredClaimName       string
+	Oauth2RequiredClaimValue      string
+	Oauth2GroupClaimName          string
+	Oauth2AdminGroup              string
+	Oauth2RestrictedGroup         string
 	SkipLocalTwoFA                bool
 	SSPIAutoCreateUsers           bool
 	SSPIAutoActivateUsers         bool
diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl
index e7215e2e1a..a9942354c0 100644
--- a/templates/admin/auth/edit.tmpl
+++ b/templates/admin/auth/edit.tmpl
@@ -286,11 +286,6 @@
 							<input id="skip_local_two_fa" name="skip_local_two_fa" type="checkbox" {{if $cfg.SkipLocalTwoFA}}checked{{end}}>
 							<p class="help">{{.i18n.Tr "admin.auths.skip_local_two_fa_helper"}}</p>
 						</div>
-					</div>
-
-					<div class="oauth2_use_custom_url inline field">
-						<div class="ui checkbox">
-							<label><strong>{{.i18n.Tr "admin.auths.oauth2_use_custom_url"}}</strong></label>
 							<input id="oauth2_use_custom_url" name="oauth2_use_custom_url" type="checkbox" {{if $cfg.CustomURLMapping}}checked{{end}}>
 						</div>
 					</div>
@@ -323,6 +318,33 @@
 						<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}}
+
+					<div class="field">
+						<label for="oauth2_scopes">{{.i18n.Tr "admin.auths.oauth2_scopes"}}</label>
+						<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{Join $cfg.Scopes "," }}{{end}}">
+					</div>
+					<div class="field">
+						<label for="oauth2_required_claim_name">{{.i18n.Tr "admin.auths.oauth2_required_claim_name"}}</label>
+						<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" values="{{$cfg.RequiredClaimName}}">
+						<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_name_helper"}}</p>
+					</div>
+					<div class="field">
+						<label for="oauth2_required_claim_value">{{.i18n.Tr "admin.auths.oauth2_required_claim_value"}}</label>
+						<input id="oauth2_required_claim_value" name="oauth2_required_claim_value" values="{{$cfg.RequiredClaimValue}}">
+						<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_value_helper"}}</p>
+					</div>
+					<div class="field">
+						<label for="oauth2_group_claim_name">{{.i18n.Tr "admin.auths.oauth2_group_claim_name"}}</label>
+						<input id="oauth2_group_claim_name" name="oauth2_group_claim_name" value="{{$cfg.GroupClaimName}}">
+					</div>
+					<div class="field">
+						<label for="oauth2_admin_group">{{.i18n.Tr "admin.auths.oauth2_admin_group"}}</label>
+						<input id="oauth2_admin_group" name="oauth2_admin_group" value="{{$cfg.AdminGroup}}">
+					</div>
+					<div class="field">
+						<label for="oauth2_restricted_group">{{.i18n.Tr "admin.auths.oauth2_restricted_group"}}</label>
+						<input id="oauth2_restricted_group" name="oauth2_restricted_group" value="{{$cfg.RestrictedGroup}}">
+					</div>
 				{{end}}
 
 				<!-- SSPI -->
diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl
index 6e91da14e2..85c7cb6166 100644
--- a/templates/admin/auth/source/oauth.tmpl
+++ b/templates/admin/auth/source/oauth.tmpl
@@ -71,4 +71,31 @@
 		<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}}
+
+	<div class="field">
+		<label for="oauth2_scopes">{{.i18n.Tr "admin.auths.oauth2_scopes"}}</label>
+		<input id="oauth2_scopes" name="oauth2_scopes" values="{{.oauth2_scopes}}">
+	</div>
+	<div class="field">
+		<label for="oauth2_required_claim_name">{{.i18n.Tr "admin.auths.oauth2_required_claim_name"}}</label>
+		<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" values="{{.oauth2_required_claim_name}}">
+		<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_name_helper"}}</p>
+	</div>
+	<div class="field">
+		<label for="oauth2_required_claim_value">{{.i18n.Tr "admin.auths.oauth2_required_claim_value"}}</label>
+		<input id="oauth2_required_claim_value" name="oauth2_required_claim_value" values="{{.oauth2_required_claim_value}}">
+		<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_value_helper"}}</p>
+	</div>
+	<div class="field">
+		<label for="oauth2_group_claim_name">{{.i18n.Tr "admin.auths.oauth2_group_claim_name"}}</label>
+		<input id="oauth2_group_claim_name" name="oauth2_group_claim_name" value="{{.oauth2_group_claim_name}}">
+	</div>
+	<div class="field">
+		<label for="oauth2_admin_group">{{.i18n.Tr "admin.auths.oauth2_admin_group"}}</label>
+		<input id="oauth2_admin_group" name="oauth2_admin_group" value="{{.oauth2_group_claim_name}}">
+	</div>
+	<div class="field">
+		<label for="oauth2_restricted_group">{{.i18n.Tr "admin.auths.oauth2_restricted_group"}}</label>
+		<input id="oauth2_restricted_group" name="oauth2_restricted_group" value="{{.oauth2_group_claim_name}}">
+	</div>
 </div>