From 191a74d62254ca00be2ccdf7e3afe69c0f9d6c12 Mon Sep 17 00:00:00 2001
From: M Hickford <mirth.hickford@gmail.com>
Date: Mon, 24 Oct 2022 09:59:24 +0200
Subject: [PATCH] Record OAuth client type at registration (#21316)

The OAuth spec [defines two types of
client](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1),
confidential and public. Previously Gitea assumed all clients to be
confidential.

> OAuth defines two client types, based on their ability to authenticate
securely with the authorization server (i.e., ability to
>   maintain the confidentiality of their client credentials):
>
>   confidential
> Clients capable of maintaining the confidentiality of their
credentials (e.g., client implemented on a secure server with
> restricted access to the client credentials), or capable of secure
client authentication using other means.
>
>   **public
> Clients incapable of maintaining the confidentiality of their
credentials (e.g., clients executing on the device used by the resource
owner, such as an installed native application or a web browser-based
application), and incapable of secure client authentication via any
other means.**
>
> The client type designation is based on the authorization server's
definition of secure authentication and its acceptable exposure levels
of client credentials. The authorization server SHOULD NOT make
assumptions about the client type.

 https://datatracker.ietf.org/doc/html/rfc8252#section-8.4

> Authorization servers MUST record the client type in the client
registration details in order to identify and process requests
accordingly.

Require PKCE for public clients:
https://datatracker.ietf.org/doc/html/rfc8252#section-8.1

> Authorization servers SHOULD reject authorization requests from native
apps that don't use PKCE by returning an error message

Fixes #21299

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
---
 .../content/doc/developers/oauth2-provider.md |  6 ++
 models/auth/oauth2.go                         | 59 +++++++++++--------
 models/auth/oauth2_test.go                    |  3 +-
 models/fixtures/oauth2_application.yml        | 11 ++++
 models/fixtures/oauth2_authorization_code.yml |  7 +++
 models/fixtures/oauth2_grant.yml              | 10 +++-
 .../o_auth2_application.yml                   |  2 +
 models/migrations/migrations.go               |  2 +
 models/migrations/v230.go                     | 18 ++++++
 models/migrations/v230_test.go                | 46 +++++++++++++++
 modules/convert/convert.go                    | 13 ++--
 modules/structs/user_app.go                   | 18 +++---
 options/locale/locale_en-US.ini               |  4 +-
 routers/api/v1/user/app.go                    | 16 ++---
 routers/web/auth/oauth.go                     | 15 ++++-
 routers/web/user/setting/oauth2_common.go     | 16 ++---
 services/forms/user_form.go                   |  5 +-
 templates/swagger/v1_json.tmpl                |  8 +++
 .../applications_oauth2_edit_form.tmpl        |  4 ++
 .../settings/applications_oauth2_list.tmpl    |  4 ++
 tests/integration/api_oauth2_apps_test.go     |  8 +++
 tests/integration/oauth_test.go               | 11 ++++
 22 files changed, 226 insertions(+), 60 deletions(-)
 create mode 100644 models/migrations/fixtures/Test_addConfidentialClientColumnToOAuth2ApplicationTable/o_auth2_application.yml
 create mode 100644 models/migrations/v230.go
 create mode 100644 models/migrations/v230_test.go

diff --git a/docs/content/doc/developers/oauth2-provider.md b/docs/content/doc/developers/oauth2-provider.md
index 9e6ab11742..c6765f19e7 100644
--- a/docs/content/doc/developers/oauth2-provider.md
+++ b/docs/content/doc/developers/oauth2-provider.md
@@ -44,6 +44,12 @@ To use the Authorization Code Grant as a third party application it is required
 
 Currently Gitea does not support scopes (see [#4300](https://github.com/go-gitea/gitea/issues/4300)) and all third party applications will be granted access to all resources of the user and their organizations.
 
+## 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).
+
+For public clients, a redirect URI of a loopback IP address such as `http://127.0.0.1/` allows any port. Avoid using `localhost`, [as recommended by RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252#section-8.3).
+
 ## Example
 
 **Note:** This example does not use PKCE.
diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go
index 9fdce24253..e42084c086 100644
--- a/models/auth/oauth2.go
+++ b/models/auth/oauth2.go
@@ -31,9 +31,14 @@ type OAuth2Application struct {
 	Name         string
 	ClientID     string `xorm:"unique"`
 	ClientSecret string
-	RedirectURIs []string           `xorm:"redirect_uris JSON TEXT"`
-	CreatedUnix  timeutil.TimeStamp `xorm:"INDEX created"`
-	UpdatedUnix  timeutil.TimeStamp `xorm:"INDEX updated"`
+	// OAuth defines both Confidential and Public client types
+	// https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
+	// "Authorization servers MUST record the client type in the client registration details"
+	// https://datatracker.ietf.org/doc/html/rfc8252#section-8.4
+	ConfidentialClient bool               `xorm:"NOT NULL DEFAULT TRUE"`
+	RedirectURIs       []string           `xorm:"redirect_uris JSON TEXT"`
+	CreatedUnix        timeutil.TimeStamp `xorm:"INDEX created"`
+	UpdatedUnix        timeutil.TimeStamp `xorm:"INDEX updated"`
 }
 
 func init() {
@@ -57,15 +62,17 @@ func (app *OAuth2Application) PrimaryRedirectURI() string {
 
 // ContainsRedirectURI checks if redirectURI is allowed for app
 func (app *OAuth2Application) ContainsRedirectURI(redirectURI string) bool {
-	uri, err := url.Parse(redirectURI)
-	// ignore port for http loopback uris following https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
-	if err == nil && uri.Scheme == "http" && uri.Port() != "" {
-		ip := net.ParseIP(uri.Hostname())
-		if ip != nil && ip.IsLoopback() {
-			// strip port
-			uri.Host = uri.Hostname()
-			if util.IsStringInSlice(uri.String(), app.RedirectURIs, true) {
-				return true
+	if !app.ConfidentialClient {
+		uri, err := url.Parse(redirectURI)
+		// ignore port for http loopback uris following https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
+		if err == nil && uri.Scheme == "http" && uri.Port() != "" {
+			ip := net.ParseIP(uri.Hostname())
+			if ip != nil && ip.IsLoopback() {
+				// strip port
+				uri.Host = uri.Hostname()
+				if util.IsStringInSlice(uri.String(), app.RedirectURIs, true) {
+					return true
+				}
 			}
 		}
 	}
@@ -161,19 +168,21 @@ func GetOAuth2ApplicationsByUserID(ctx context.Context, userID int64) (apps []*O
 
 // CreateOAuth2ApplicationOptions holds options to create an oauth2 application
 type CreateOAuth2ApplicationOptions struct {
-	Name         string
-	UserID       int64
-	RedirectURIs []string
+	Name               string
+	UserID             int64
+	ConfidentialClient bool
+	RedirectURIs       []string
 }
 
 // CreateOAuth2Application inserts a new oauth2 application
 func CreateOAuth2Application(ctx context.Context, opts CreateOAuth2ApplicationOptions) (*OAuth2Application, error) {
 	clientID := uuid.New().String()
 	app := &OAuth2Application{
-		UID:          opts.UserID,
-		Name:         opts.Name,
-		ClientID:     clientID,
-		RedirectURIs: opts.RedirectURIs,
+		UID:                opts.UserID,
+		Name:               opts.Name,
+		ClientID:           clientID,
+		RedirectURIs:       opts.RedirectURIs,
+		ConfidentialClient: opts.ConfidentialClient,
 	}
 	if err := db.Insert(ctx, app); err != nil {
 		return nil, err
@@ -183,10 +192,11 @@ func CreateOAuth2Application(ctx context.Context, opts CreateOAuth2ApplicationOp
 
 // UpdateOAuth2ApplicationOptions holds options to update an oauth2 application
 type UpdateOAuth2ApplicationOptions struct {
-	ID           int64
-	Name         string
-	UserID       int64
-	RedirectURIs []string
+	ID                 int64
+	Name               string
+	UserID             int64
+	ConfidentialClient bool
+	RedirectURIs       []string
 }
 
 // UpdateOAuth2Application updates an oauth2 application
@@ -207,6 +217,7 @@ func UpdateOAuth2Application(opts UpdateOAuth2ApplicationOptions) (*OAuth2Applic
 
 	app.Name = opts.Name
 	app.RedirectURIs = opts.RedirectURIs
+	app.ConfidentialClient = opts.ConfidentialClient
 
 	if err = updateOAuth2Application(ctx, app); err != nil {
 		return nil, err
@@ -217,7 +228,7 @@ func UpdateOAuth2Application(opts UpdateOAuth2ApplicationOptions) (*OAuth2Applic
 }
 
 func updateOAuth2Application(ctx context.Context, app *OAuth2Application) error {
-	if _, err := db.GetEngine(ctx).ID(app.ID).Update(app); err != nil {
+	if _, err := db.GetEngine(ctx).ID(app.ID).UseBool("confidential_client").Update(app); err != nil {
 		return err
 	}
 	return nil
diff --git a/models/auth/oauth2_test.go b/models/auth/oauth2_test.go
index 3815cb3b2c..7a4df6b9ac 100644
--- a/models/auth/oauth2_test.go
+++ b/models/auth/oauth2_test.go
@@ -45,7 +45,8 @@ func TestOAuth2Application_ContainsRedirectURI(t *testing.T) {
 
 func TestOAuth2Application_ContainsRedirectURI_WithPort(t *testing.T) {
 	app := &auth_model.OAuth2Application{
-		RedirectURIs: []string{"http://127.0.0.1/", "http://::1/", "http://192.168.0.1/", "http://intranet/", "https://127.0.0.1/"},
+		RedirectURIs:       []string{"http://127.0.0.1/", "http://::1/", "http://192.168.0.1/", "http://intranet/", "https://127.0.0.1/"},
+		ConfidentialClient: false,
 	}
 
 	// http loopback uris should ignore port
diff --git a/models/fixtures/oauth2_application.yml b/models/fixtures/oauth2_application.yml
index 34d5a88777..2f38cb58b6 100644
--- a/models/fixtures/oauth2_application.yml
+++ b/models/fixtures/oauth2_application.yml
@@ -7,3 +7,14 @@
   redirect_uris: '["a", "https://example.com/xyzzy"]'
   created_unix: 1546869730
   updated_unix: 1546869730
+  confidential_client: true
+-
+  id: 2
+  uid: 2
+  name: "Test native app"
+  client_id: "ce5a1322-42a7-11ed-b878-0242ac120002"
+  client_secret: "$2a$10$UYRgUSgekzBp6hYe8pAdc.cgB4Gn06QRKsORUnIYTYQADs.YR/uvi" # bcrypt of "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=
+  redirect_uris: '["http://127.0.0.1"]'
+  created_unix: 1546869730
+  updated_unix: 1546869730
+  confidential_client: false
diff --git a/models/fixtures/oauth2_authorization_code.yml b/models/fixtures/oauth2_authorization_code.yml
index 2abce16354..d29502164e 100644
--- a/models/fixtures/oauth2_authorization_code.yml
+++ b/models/fixtures/oauth2_authorization_code.yml
@@ -6,3 +6,10 @@
   redirect_uri: "a"
   valid_until: 3546869730
 
+- id: 2
+  grant_id: 4
+  code: "authcodepublic"
+  code_challenge: "CjvyTLSdR47G5zYenDA-eDWW4lRrO8yvjcWwbD_deOg" # Code Verifier: N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt
+  code_challenge_method: "S256"
+  redirect_uri: "http://127.0.0.1/"
+  valid_until: 3546869730
diff --git a/models/fixtures/oauth2_grant.yml b/models/fixtures/oauth2_grant.yml
index e52a2bce95..e63286878b 100644
--- a/models/fixtures/oauth2_grant.yml
+++ b/models/fixtures/oauth2_grant.yml
@@ -20,4 +20,12 @@
   counter: 1
   scope: "openid profile email"
   created_unix: 1546869730
-  updated_unix: 1546869730
\ No newline at end of file
+  updated_unix: 1546869730
+
+- id: 4
+  user_id: 99
+  application_id: 2
+  counter: 1
+  scope: "whatever"
+  created_unix: 1546869730
+  updated_unix: 1546869730
diff --git a/models/migrations/fixtures/Test_addConfidentialClientColumnToOAuth2ApplicationTable/o_auth2_application.yml b/models/migrations/fixtures/Test_addConfidentialClientColumnToOAuth2ApplicationTable/o_auth2_application.yml
new file mode 100644
index 0000000000..a88c2ef89f
--- /dev/null
+++ b/models/migrations/fixtures/Test_addConfidentialClientColumnToOAuth2ApplicationTable/o_auth2_application.yml
@@ -0,0 +1,2 @@
+-
+  id: 1
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index cca6c52d42..a6201c1090 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -421,6 +421,8 @@ var migrations = []Migration{
 	NewMigration("Add TeamInvite table", addTeamInviteTable),
 	// v229 -> v230
 	NewMigration("Update counts of all open milestones", updateOpenMilestoneCounts),
+	// v230 -> v231
+	NewMigration("Add ConfidentialClient column (default true) to OAuth2Application table", addConfidentialClientColumnToOAuth2ApplicationTable),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v230.go b/models/migrations/v230.go
new file mode 100644
index 0000000000..f08e6a3764
--- /dev/null
+++ b/models/migrations/v230.go
@@ -0,0 +1,18 @@
+// Copyright 2022 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 migrations
+
+import (
+	"xorm.io/xorm"
+)
+
+// addConfidentialColumnToOAuth2ApplicationTable: add ConfidentialClient column, setting existing rows to true
+func addConfidentialClientColumnToOAuth2ApplicationTable(x *xorm.Engine) error {
+	type OAuth2Application struct {
+		ConfidentialClient bool `xorm:"NOT NULL DEFAULT TRUE"`
+	}
+
+	return x.Sync(new(OAuth2Application))
+}
diff --git a/models/migrations/v230_test.go b/models/migrations/v230_test.go
new file mode 100644
index 0000000000..98ba3f5d97
--- /dev/null
+++ b/models/migrations/v230_test.go
@@ -0,0 +1,46 @@
+// Copyright 2022 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 migrations
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_addConfidentialClientColumnToOAuth2ApplicationTable(t *testing.T) {
+	// premigration
+	type OAuth2Application struct {
+		ID int64
+	}
+
+	// Prepare and load the testing database
+	x, deferable := prepareTestEnv(t, 0, new(OAuth2Application))
+	defer deferable()
+	if x == nil || t.Failed() {
+		return
+	}
+
+	if err := addConfidentialClientColumnToOAuth2ApplicationTable(x); err != nil {
+		assert.NoError(t, err)
+		return
+	}
+
+	// postmigration
+	type ExpectedOAuth2Application struct {
+		ID                 int64
+		ConfidentialClient bool
+	}
+
+	got := []ExpectedOAuth2Application{}
+	if err := x.Table("o_auth2_application").Select("id, confidential_client").Find(&got); !assert.NoError(t, err) {
+		return
+	}
+
+	assert.NotEmpty(t, got)
+	for _, e := range got {
+		assert.True(t, e.ConfidentialClient)
+	}
+}
diff --git a/modules/convert/convert.go b/modules/convert/convert.go
index 187c67fa76..8c92bbb371 100644
--- a/modules/convert/convert.go
+++ b/modules/convert/convert.go
@@ -392,12 +392,13 @@ func ToTopicResponse(topic *repo_model.Topic) *api.TopicResponse {
 // ToOAuth2Application convert from auth.OAuth2Application to api.OAuth2Application
 func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {
 	return &api.OAuth2Application{
-		ID:           app.ID,
-		Name:         app.Name,
-		ClientID:     app.ClientID,
-		ClientSecret: app.ClientSecret,
-		RedirectURIs: app.RedirectURIs,
-		Created:      app.CreatedUnix.AsTime(),
+		ID:                 app.ID,
+		Name:               app.Name,
+		ClientID:           app.ClientID,
+		ClientSecret:       app.ClientSecret,
+		ConfidentialClient: app.ConfidentialClient,
+		RedirectURIs:       app.RedirectURIs,
+		Created:            app.CreatedUnix.AsTime(),
 	}
 }
 
diff --git a/modules/structs/user_app.go b/modules/structs/user_app.go
index 44df5a6a49..4cfa5538c8 100644
--- a/modules/structs/user_app.go
+++ b/modules/structs/user_app.go
@@ -30,19 +30,21 @@ type CreateAccessTokenOption struct {
 
 // CreateOAuth2ApplicationOptions holds options to create an oauth2 application
 type CreateOAuth2ApplicationOptions struct {
-	Name         string   `json:"name" binding:"Required"`
-	RedirectURIs []string `json:"redirect_uris" binding:"Required"`
+	Name               string   `json:"name" binding:"Required"`
+	ConfidentialClient bool     `json:"confidential_client"`
+	RedirectURIs       []string `json:"redirect_uris" binding:"Required"`
 }
 
 // OAuth2Application represents an OAuth2 application.
 // swagger:response OAuth2Application
 type OAuth2Application struct {
-	ID           int64     `json:"id"`
-	Name         string    `json:"name"`
-	ClientID     string    `json:"client_id"`
-	ClientSecret string    `json:"client_secret"`
-	RedirectURIs []string  `json:"redirect_uris"`
-	Created      time.Time `json:"created"`
+	ID                 int64     `json:"id"`
+	Name               string    `json:"name"`
+	ClientID           string    `json:"client_id"`
+	ClientSecret       string    `json:"client_secret"`
+	ConfidentialClient bool      `json:"confidential_client"`
+	RedirectURIs       []string  `json:"redirect_uris"`
+	Created            time.Time `json:"created"`
 }
 
 // OAuth2ApplicationList represents a list of OAuth2 applications.
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 3c2e70187c..1566dfc97d 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -749,9 +749,7 @@ create_oauth2_application_button = Create Application
 create_oauth2_application_success = You've successfully created a new OAuth2 application.
 update_oauth2_application_success = You've successfully updated the OAuth2 application.
 oauth2_application_name = Application Name
-oauth2_select_type = Which application type fits?
-oauth2_type_web = Web (e.g. Node.JS, Tomcat, Go)
-oauth2_type_native = Native (e.g. Mobile, Desktop, Browser)
+oauth2_confidential_client = Confidential Client. Select for apps that keep the secret confidential, such as web apps. Do not select for native apps including desktop and mobile apps.
 oauth2_redirect_uri = Redirect URI
 save_application = Save
 oauth2_client_id = Client ID
diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go
index a94db79239..14f1592591 100644
--- a/routers/api/v1/user/app.go
+++ b/routers/api/v1/user/app.go
@@ -213,9 +213,10 @@ func CreateOauth2Application(ctx *context.APIContext) {
 	data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions)
 
 	app, err := auth_model.CreateOAuth2Application(ctx, auth_model.CreateOAuth2ApplicationOptions{
-		Name:         data.Name,
-		UserID:       ctx.Doer.ID,
-		RedirectURIs: data.RedirectURIs,
+		Name:               data.Name,
+		UserID:             ctx.Doer.ID,
+		RedirectURIs:       data.RedirectURIs,
+		ConfidentialClient: data.ConfidentialClient,
 	})
 	if err != nil {
 		ctx.Error(http.StatusBadRequest, "", "error creating oauth2 application")
@@ -363,10 +364,11 @@ func UpdateOauth2Application(ctx *context.APIContext) {
 	data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions)
 
 	app, err := auth_model.UpdateOAuth2Application(auth_model.UpdateOAuth2ApplicationOptions{
-		Name:         data.Name,
-		UserID:       ctx.Doer.ID,
-		ID:           appID,
-		RedirectURIs: data.RedirectURIs,
+		Name:               data.Name,
+		UserID:             ctx.Doer.ID,
+		ID:                 appID,
+		RedirectURIs:       data.RedirectURIs,
+		ConfidentialClient: data.ConfidentialClient,
 	})
 	if err != nil {
 		if auth_model.IsErrOauthClientIDInvalid(err) || auth_model.IsErrOAuthApplicationNotFound(err) {
diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go
index c98385c8f6..9c929d990e 100644
--- a/routers/web/auth/oauth.go
+++ b/routers/web/auth/oauth.go
@@ -438,8 +438,21 @@ func AuthorizeOAuth(ctx *context.Context) {
 			log.Error("Unable to save changes to the session: %v", err)
 		}
 	case "":
-		break
+		// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
+		// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
+		if !app.ConfidentialClient {
+			// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
+			// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
+			handleAuthorizeError(ctx, AuthorizeError{
+				ErrorCode:        ErrorCodeInvalidRequest,
+				ErrorDescription: "PKCE is required for public clients",
+				State:            form.State,
+			}, form.RedirectURI)
+			return
+		}
 	default:
+		// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
+		// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
 		handleAuthorizeError(ctx, AuthorizeError{
 			ErrorCode:        ErrorCodeInvalidRequest,
 			ErrorDescription: "unsupported code challenge method",
diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go
index f02f6ab041..49ee5c7c2f 100644
--- a/routers/web/user/setting/oauth2_common.go
+++ b/routers/web/user/setting/oauth2_common.go
@@ -39,9 +39,10 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
 
 	// TODO validate redirect URI
 	app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
-		Name:         form.Name,
-		RedirectURIs: []string{form.RedirectURI},
-		UserID:       oa.OwnerID,
+		Name:               form.Name,
+		RedirectURIs:       []string{form.RedirectURI},
+		UserID:             oa.OwnerID,
+		ConfidentialClient: form.ConfidentialClient,
 	})
 	if err != nil {
 		ctx.ServerError("CreateOAuth2Application", err)
@@ -90,10 +91,11 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
 	// TODO validate redirect URI
 	var err error
 	if ctx.Data["App"], err = auth.UpdateOAuth2Application(auth.UpdateOAuth2ApplicationOptions{
-		ID:           ctx.ParamsInt64("id"),
-		Name:         form.Name,
-		RedirectURIs: []string{form.RedirectURI},
-		UserID:       oa.OwnerID,
+		ID:                 ctx.ParamsInt64("id"),
+		Name:               form.Name,
+		RedirectURIs:       []string{form.RedirectURI},
+		UserID:             oa.OwnerID,
+		ConfidentialClient: form.ConfidentialClient,
 	}); err != nil {
 		ctx.ServerError("UpdateOAuth2Application", err)
 		return
diff --git a/services/forms/user_form.go b/services/forms/user_form.go
index 8ce1d85c57..036c2ca3ec 100644
--- a/services/forms/user_form.go
+++ b/services/forms/user_form.go
@@ -379,8 +379,9 @@ func (f *NewAccessTokenForm) Validate(req *http.Request, errs binding.Errors) bi
 
 // EditOAuth2ApplicationForm form for editing oauth2 applications
 type EditOAuth2ApplicationForm struct {
-	Name        string `binding:"Required;MaxSize(255)" form:"application_name"`
-	RedirectURI string `binding:"Required" form:"redirect_uri"`
+	Name               string `binding:"Required;MaxSize(255)" form:"application_name"`
+	RedirectURI        string `binding:"Required" form:"redirect_uri"`
+	ConfidentialClient bool   `form:"confidential_client"`
 }
 
 // Validate validates the fields
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 728e88b734..94fb67ab44 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -14645,6 +14645,10 @@
       "description": "CreateOAuth2ApplicationOptions holds options to create an oauth2 application",
       "type": "object",
       "properties": {
+        "confidential_client": {
+          "type": "boolean",
+          "x-go-name": "ConfidentialClient"
+        },
         "name": {
           "type": "string",
           "x-go-name": "Name"
@@ -17306,6 +17310,10 @@
           "type": "string",
           "x-go-name": "ClientSecret"
         },
+        "confidential_client": {
+          "type": "boolean",
+          "x-go-name": "ConfidentialClient"
+        },
         "created": {
           "type": "string",
           "format": "date-time",
diff --git a/templates/user/settings/applications_oauth2_edit_form.tmpl b/templates/user/settings/applications_oauth2_edit_form.tmpl
index 60311983c9..9d7273fd6d 100644
--- a/templates/user/settings/applications_oauth2_edit_form.tmpl
+++ b/templates/user/settings/applications_oauth2_edit_form.tmpl
@@ -43,6 +43,10 @@
 				<label for="redirect-uri">{{.locale.Tr "settings.oauth2_redirect_uri"}}</label>
 				<input type="url" name="redirect_uri" value="{{.App.PrimaryRedirectURI}}" id="redirect-uri">
 			</div>
+			<div class="field ui checkbox {{if .Err_ConfidentialClient}}error{{end}}">
+				<label>{{.locale.Tr "settings.oauth2_confidential_client"}}</label>
+				<input type="checkbox" name="confidential_client" {{if .App.ConfidentialClient}}checked{{end}}>
+			</div>
 			<button class="ui green button">
 				{{.locale.Tr "settings.save_application"}}
 			</button>
diff --git a/templates/user/settings/applications_oauth2_list.tmpl b/templates/user/settings/applications_oauth2_list.tmpl
index 47d7ecfaa4..fbca5934cd 100644
--- a/templates/user/settings/applications_oauth2_list.tmpl
+++ b/templates/user/settings/applications_oauth2_list.tmpl
@@ -37,6 +37,10 @@
 			<label for="redirect-uri">{{.locale.Tr "settings.oauth2_redirect_uri"}}</label>
 			<input type="url" name="redirect_uri" id="redirect-uri">
 		</div>
+		<div class="field ui checkbox {{if .Err_ConfidentialClient}}error{{end}}">
+			<label>{{.locale.Tr "settings.oauth2_confidential_client"}}</label>
+			<input type="checkbox" name="confidential_client" checked>
+		</div>
 		<button class="ui green button">
 			{{.locale.Tr "settings.create_oauth2_application_button"}}
 		</button>
diff --git a/tests/integration/api_oauth2_apps_test.go b/tests/integration/api_oauth2_apps_test.go
index fe3525724e..6352449d6a 100644
--- a/tests/integration/api_oauth2_apps_test.go
+++ b/tests/integration/api_oauth2_apps_test.go
@@ -34,6 +34,7 @@ func testAPICreateOAuth2Application(t *testing.T) {
 		RedirectURIs: []string{
 			"http://www.google.com",
 		},
+		ConfidentialClient: true,
 	}
 
 	req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody)
@@ -46,6 +47,7 @@ func testAPICreateOAuth2Application(t *testing.T) {
 	assert.EqualValues(t, appBody.Name, createdApp.Name)
 	assert.Len(t, createdApp.ClientSecret, 56)
 	assert.Len(t, createdApp.ClientID, 36)
+	assert.True(t, createdApp.ConfidentialClient)
 	assert.NotEmpty(t, createdApp.Created)
 	assert.EqualValues(t, appBody.RedirectURIs[0], createdApp.RedirectURIs[0])
 	unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{UID: user.ID, Name: createdApp.Name})
@@ -62,6 +64,7 @@ func testAPIListOAuth2Applications(t *testing.T) {
 		RedirectURIs: []string{
 			"http://www.google.com",
 		},
+		ConfidentialClient: true,
 	})
 
 	urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2?token=%s", token)
@@ -74,6 +77,7 @@ func testAPIListOAuth2Applications(t *testing.T) {
 
 	assert.EqualValues(t, existApp.Name, expectedApp.Name)
 	assert.EqualValues(t, existApp.ClientID, expectedApp.ClientID)
+	assert.Equal(t, existApp.ConfidentialClient, expectedApp.ConfidentialClient)
 	assert.Len(t, expectedApp.ClientID, 36)
 	assert.Empty(t, expectedApp.ClientSecret)
 	assert.EqualValues(t, existApp.RedirectURIs[0], expectedApp.RedirectURIs[0])
@@ -112,6 +116,7 @@ func testAPIGetOAuth2Application(t *testing.T) {
 		RedirectURIs: []string{
 			"http://www.google.com",
 		},
+		ConfidentialClient: true,
 	})
 
 	urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d?token=%s", existApp.ID, token)
@@ -124,6 +129,7 @@ func testAPIGetOAuth2Application(t *testing.T) {
 
 	assert.EqualValues(t, existApp.Name, expectedApp.Name)
 	assert.EqualValues(t, existApp.ClientID, expectedApp.ClientID)
+	assert.Equal(t, existApp.ConfidentialClient, expectedApp.ConfidentialClient)
 	assert.Len(t, expectedApp.ClientID, 36)
 	assert.Empty(t, expectedApp.ClientSecret)
 	assert.Len(t, expectedApp.RedirectURIs, 1)
@@ -148,6 +154,7 @@ func testAPIUpdateOAuth2Application(t *testing.T) {
 			"http://www.google.com/",
 			"http://www.github.com/",
 		},
+		ConfidentialClient: true,
 	}
 
 	urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d", existApp.ID)
@@ -162,5 +169,6 @@ func testAPIUpdateOAuth2Application(t *testing.T) {
 	assert.Len(t, expectedApp.RedirectURIs, 2)
 	assert.EqualValues(t, expectedApp.RedirectURIs[0], appBody.RedirectURIs[0])
 	assert.EqualValues(t, expectedApp.RedirectURIs[1], appBody.RedirectURIs[1])
+	assert.Equal(t, expectedApp.ConfidentialClient, appBody.ConfidentialClient)
 	unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name})
 }
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go
index acd32e3625..7863313b14 100644
--- a/tests/integration/oauth_test.go
+++ b/tests/integration/oauth_test.go
@@ -86,6 +86,17 @@ func TestAuthorizeRedirectWithExistingGrant(t *testing.T) {
 	assert.Equal(t, "https://example.com/xyzzy", u.String())
 }
 
+func TestAuthorizePKCERequiredForPublicClient(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=http%3A%2F%2F127.0.0.1&response_type=code&state=thestate")
+	ctx := loginUser(t, "user1")
+	resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
+	u, err := resp.Result().Location()
+	assert.NoError(t, err)
+	assert.Equal(t, "invalid_request", u.Query().Get("error"))
+	assert.Equal(t, "PKCE is required for public clients", u.Query().Get("error_description"))
+}
+
 func TestAccessTokenExchange(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 	req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{