Pre-register OAuth2 applications for git credential helpers (#26291)

This PR is an extended implementation of #25189 and builds upon the
proposal by @hickford in #25653, utilizing some ideas proposed
internally by @wxiaoguang.

Mainly, this PR consists of a mechanism to pre-register OAuth2
applications on startup, which can be enabled or disabled by modifying
the `[oauth2].DEFAULT_APPLICATIONS` parameter in app.ini. The OAuth2
applications registered this way are being marked as "locked" and
neither be deleted nor edited over UI to prevent confusing/unexpected
behavior. Instead, they're being removed if no longer enabled in config.


![grafik](https://github.com/go-gitea/gitea/assets/47871822/81a78b1c-4b68-40a7-9e99-c272ebb8f62e)

The implemented mechanism can also be used to pre-register other OAuth2
applications in the future, if wanted.

Co-authored-by: hickford <mirth.hickford@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>

---------

Co-authored-by: M Hickford <mirth.hickford@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Denys Konovalov 2023-08-09 14:24:07 +02:00 committed by GitHub
parent d41aee1d1e
commit 63ab92d797
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 131 additions and 12 deletions

View file

@ -544,6 +544,11 @@ ENABLE = true
;; ;;
;; Maximum length of oauth2 token/cookie stored on server ;; Maximum length of oauth2 token/cookie stored on server
;MAX_TOKEN_LENGTH = 32767 ;MAX_TOKEN_LENGTH = 32767
;;
;; Pre-register OAuth2 applications for some universally useful services
;; * https://github.com/hickford/git-credential-oauth
;; * https://github.com/git-ecosystem/git-credential-manager
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -1100,6 +1100,7 @@ This section only does "set" config, a removed config key from this section won'
- `JWT_SECRET_URI`: **_empty_**: Instead of defining JWT_SECRET in the configuration, this configuration option can be used to give Gitea a path to a file that contains the secret (example value: `file:/etc/gitea/oauth2_jwt_secret`) - `JWT_SECRET_URI`: **_empty_**: Instead of defining JWT_SECRET in the configuration, this configuration option can be used to give Gitea a path to a file that contains the secret (example value: `file:/etc/gitea/oauth2_jwt_secret`)
- `JWT_SIGNING_PRIVATE_KEY_FILE`: **jwt/private.pem**: Private key file path used to sign OAuth2 tokens. The path is relative to `APP_DATA_PATH`. This setting is only needed if `JWT_SIGNING_ALGORITHM` is set to `RS256`, `RS384`, `RS512`, `ES256`, `ES384` or `ES512`. The file must contain a RSA or ECDSA private key in the PKCS8 format. If no key exists a 4096 bit key will be created for you. - `JWT_SIGNING_PRIVATE_KEY_FILE`: **jwt/private.pem**: Private key file path used to sign OAuth2 tokens. The path is relative to `APP_DATA_PATH`. This setting is only needed if `JWT_SIGNING_ALGORITHM` is set to `RS256`, `RS384`, `RS512`, `ES256`, `ES384` or `ES512`. The file must contain a RSA or ECDSA private key in the PKCS8 format. If no key exists a 4096 bit key will be created for you.
- `MAX_TOKEN_LENGTH`: **32767**: Maximum length of token/cookie to accept from OAuth2 provider - `MAX_TOKEN_LENGTH`: **32767**: Maximum length of token/cookie to accept from OAuth2 provider
- `DEFAULT_APPLICATIONS`: **git-credential-oauth, git-credential-manager**: Pre-register OAuth applications for some services on startup. See the [OAuth2 documentation](/development/oauth2-provider.md) for the list of available options.
## i18n (`i18n`) ## i18n (`i18n`)

View file

@ -78,6 +78,17 @@ Gitea token scopes are as follows:
| &nbsp;&nbsp;&nbsp; **read:user** | Grants read access to user operations, such as getting user repo subscriptions and user settings. | | &nbsp;&nbsp;&nbsp; **read:user** | Grants read access to user operations, such as getting user repo subscriptions and user settings. |
| &nbsp;&nbsp;&nbsp; **write:user** | Grants read/write/delete access to user operations, such as updating user repo subscriptions, followed users, and user settings. | | &nbsp;&nbsp;&nbsp; **write:user** | Grants read/write/delete access to user operations, such as updating user repo subscriptions, followed users, and user settings. |
## Pre-configured Applications
Gitea creates OAuth applications for the following services by default on startup, as we assume that these are universally useful.
|Application|Description|Client ID|
|-----------|-----------|---------|
|[git-credential-oauth](https://github.com/hickford/git-credential-oauth)|Git credential helper|`a4792ccc-144e-407e-86c9-5e7d8d9c3269`|
|[Git Credential Manager](https://github.com/git-ecosystem/git-credential-manager)|Git credential helper|`e90ee53c-94e2-48ac-9358-a874fb9e0662`|
To prevent unexpected behavior, they are being displayed as locked in the UI and their creation can instead be controlled by the `DEFAULT_APPLICATIONS` parameter in `app.ini`.
## Client types ## Client types
Gitea supports both confidential and public client types, [as defined by RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1). Gitea supports both confidential and public client types, [as defined by RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1).

View file

@ -13,6 +13,8 @@ import (
"strings" "strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -46,6 +48,83 @@ func init() {
db.RegisterModel(new(OAuth2Grant)) db.RegisterModel(new(OAuth2Grant))
} }
type BuiltinOAuth2Application struct {
ConfigName string
DisplayName string
RedirectURIs []string
}
func BuiltinApplications() map[string]*BuiltinOAuth2Application {
m := make(map[string]*BuiltinOAuth2Application)
m["a4792ccc-144e-407e-86c9-5e7d8d9c3269"] = &BuiltinOAuth2Application{
ConfigName: "git-credential-oauth",
DisplayName: "git-credential-oauth",
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
}
m["e90ee53c-94e2-48ac-9358-a874fb9e0662"] = &BuiltinOAuth2Application{
ConfigName: "git-credential-manager",
DisplayName: "Git Credential Manager",
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
}
return m
}
func Init(ctx context.Context) error {
builtinApps := BuiltinApplications()
var builtinAllClientIDs []string
for clientID := range builtinApps {
builtinAllClientIDs = append(builtinAllClientIDs, clientID)
}
var registeredApps []*OAuth2Application
if err := db.GetEngine(ctx).In("client_id", builtinAllClientIDs).Find(&registeredApps); err != nil {
return err
}
clientIDsToAdd := container.Set[string]{}
for _, configName := range setting.OAuth2.DefaultApplications {
found := false
for clientID, builtinApp := range builtinApps {
if builtinApp.ConfigName == configName {
clientIDsToAdd.Add(clientID) // add all user-configured apps to the "add" list
found = true
}
}
if !found {
return fmt.Errorf("unknown oauth2 application: %q", configName)
}
}
clientIDsToDelete := container.Set[string]{}
for _, app := range registeredApps {
if !clientIDsToAdd.Contains(app.ClientID) {
clientIDsToDelete.Add(app.ClientID) // if a registered app is not in the "add" list, it should be deleted
}
}
for _, app := range registeredApps {
clientIDsToAdd.Remove(app.ClientID) // no need to re-add existing (registered) apps, so remove them from the set
}
for _, app := range registeredApps {
if clientIDsToDelete.Contains(app.ClientID) {
if err := deleteOAuth2Application(ctx, app.ID, 0); err != nil {
return err
}
}
}
for clientID := range clientIDsToAdd {
builtinApp := builtinApps[clientID]
if err := db.Insert(ctx, &OAuth2Application{
Name: builtinApp.DisplayName,
ClientID: clientID,
RedirectURIs: builtinApp.RedirectURIs,
}); err != nil {
return err
}
}
return nil
}
// TableName sets the table name to `oauth2_application` // TableName sets the table name to `oauth2_application`
func (app *OAuth2Application) TableName() string { func (app *OAuth2Application) TableName() string {
return "oauth2_application" return "oauth2_application"
@ -205,6 +284,10 @@ func UpdateOAuth2Application(opts UpdateOAuth2ApplicationOptions) (*OAuth2Applic
if app.UID != opts.UserID { if app.UID != opts.UserID {
return nil, fmt.Errorf("UID mismatch") return nil, fmt.Errorf("UID mismatch")
} }
builtinApps := BuiltinApplications()
if _, builtin := builtinApps[app.ClientID]; builtin {
return nil, fmt.Errorf("failed to edit OAuth2 application: application is locked: %s", app.ClientID)
}
app.Name = opts.Name app.Name = opts.Name
app.RedirectURIs = opts.RedirectURIs app.RedirectURIs = opts.RedirectURIs
@ -261,6 +344,14 @@ func DeleteOAuth2Application(id, userid int64) error {
return err return err
} }
defer committer.Close() defer committer.Close()
app, err := GetOAuth2ApplicationByID(ctx, id)
if err != nil {
return err
}
builtinApps := BuiltinApplications()
if _, builtin := builtinApps[app.ClientID]; builtin {
return fmt.Errorf("failed to delete OAuth2 application: application is locked: %s", app.ClientID)
}
if err := deleteOAuth2Application(ctx, id, userid); err != nil { if err := deleteOAuth2Application(ctx, id, userid); err != nil {
return err return err
} }

View file

@ -100,6 +100,7 @@ var OAuth2 = struct {
JWTSecretBase64 string `ini:"JWT_SECRET"` JWTSecretBase64 string `ini:"JWT_SECRET"`
JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
MaxTokenLength int MaxTokenLength int
DefaultApplications []string
}{ }{
Enable: true, Enable: true,
AccessTokenExpirationTime: 3600, AccessTokenExpirationTime: 3600,
@ -108,6 +109,7 @@ var OAuth2 = struct {
JWTSigningAlgorithm: "RS256", JWTSigningAlgorithm: "RS256",
JWTSigningPrivateKeyFile: "jwt/private.pem", JWTSigningPrivateKeyFile: "jwt/private.pem",
MaxTokenLength: math.MaxInt16, MaxTokenLength: math.MaxInt16,
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager"},
} }
func loadOAuth2From(rootCfg ConfigProvider) { func loadOAuth2From(rootCfg ConfigProvider) {

View file

@ -93,6 +93,7 @@ edit = Edit
enabled = Enabled enabled = Enabled
disabled = Disabled disabled = Disabled
locked = Locked
copy = Copy copy = Copy
copy_url = Copy URL copy_url = Copy URL
@ -850,6 +851,7 @@ oauth2_client_secret_hint = The secret will not be shown again after you leave o
oauth2_application_edit = Edit oauth2_application_edit = Edit
oauth2_application_create_description = OAuth2 applications gives your third-party application access to user accounts on this instance. oauth2_application_create_description = OAuth2 applications gives your third-party application access to user accounts on this instance.
oauth2_application_remove_description = Removing an OAuth2 application will prevent it from accessing authorized user accounts on this instance. Continue? oauth2_application_remove_description = Removing an OAuth2 application will prevent it from accessing authorized user accounts on this instance. Continue?
oauth2_application_locked = Gitea pre-registers some OAuth2 applications on startup if enabled in config. To prevent unexpected bahavior, these can neither be edited nor removed. Please refer to the OAuth2 documentation for more information.
authorized_oauth2_applications = Authorized OAuth2 Applications authorized_oauth2_applications = Authorized OAuth2 Applications
authorized_oauth2_applications_description = You have granted access to your personal Gitea account to these third party applications. Please revoke access for applications you no longer need. authorized_oauth2_applications_description = You have granted access to your personal Gitea account to these third party applications. Please revoke access for applications you no longer need.

View file

@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
asymkey_model "code.gitea.io/gitea/models/asymkey" asymkey_model "code.gitea.io/gitea/models/asymkey"
authmodel "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
@ -138,6 +139,7 @@ func InitWebInstalled(ctx context.Context) {
mustInit(oauth2.Init) mustInit(oauth2.Init)
mustInitCtx(ctx, models.Init) mustInitCtx(ctx, models.Init)
mustInitCtx(ctx, authmodel.Init)
mustInit(repo_service.Init) mustInit(repo_service.Init)
// Booting long running goroutines. // Booting long running goroutines.

View file

@ -39,7 +39,7 @@ func Applications(ctx *context.Context) {
return return
} }
ctx.Data["Applications"] = apps ctx.Data["Applications"] = apps
ctx.Data["BuiltinApplications"] = auth.BuiltinApplications()
ctx.HTML(http.StatusOK, tplSettingsApplications) ctx.HTML(http.StatusOK, tplSettingsApplications)
} }

View file

@ -147,7 +147,7 @@ func httpBase(ctx *context.Context) *serviceHandler {
// rely on the results of Contexter // rely on the results of Contexter
if !ctx.IsSigned { if !ctx.IsSigned {
// TODO: support digit auth - which would be Authorization header with digit // TODO: support digit auth - which would be Authorization header with digit
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"") ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`)
ctx.Error(http.StatusUnauthorized) ctx.Error(http.StatusUnauthorized)
return nil return nil
} }

View file

@ -4,7 +4,7 @@
{{.locale.Tr "settings.oauth2_application_create_description"}} {{.locale.Tr "settings.oauth2_application_create_description"}}
</div> </div>
{{range .Applications}} {{range .Applications}}
<div class="flex-item"> <div class="flex-item flex-item-center">
<div class="flex-item-leading"> <div class="flex-item-leading">
{{svg "octicon-apps" 32}} {{svg "octicon-apps" 32}}
</div> </div>
@ -15,7 +15,11 @@
<span class="ui label">{{.ClientID}}</span> <span class="ui label">{{.ClientID}}</span>
</div> </div>
</div> </div>
{{$isBuiltin := and $.BuiltinApplications (index $.BuiltinApplications .ClientID)}}
<div class="flex-item-trailing"> <div class="flex-item-trailing">
{{if $isBuiltin}}
<span class="ui basic label" data-tooltip-content="{{$.locale.Tr "settings.oauth2_application_locked"}}">{{ctx.Locale.Tr "locked"}}</span>
{{else}}
<a href="{{$.Link}}/oauth2/{{.ID}}" class="ui primary tiny button"> <a href="{{$.Link}}/oauth2/{{.ID}}" class="ui primary tiny button">
{{svg "octicon-pencil" 16 "gt-mr-2"}} {{svg "octicon-pencil" 16 "gt-mr-2"}}
{{$.locale.Tr "settings.oauth2_application_edit"}} {{$.locale.Tr "settings.oauth2_application_edit"}}
@ -25,6 +29,7 @@
{{svg "octicon-trash" 16 "gt-mr-2"}} {{svg "octicon-trash" 16 "gt-mr-2"}}
{{$.locale.Tr "settings.delete_key"}} {{$.locale.Tr "settings.delete_key"}}
</button> </button>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}