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>
This commit is contained in:
parent
e1ce45eabf
commit
191a74d622
22 changed files with 226 additions and 60 deletions
|
@ -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.
|
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
|
## Example
|
||||||
|
|
||||||
**Note:** This example does not use PKCE.
|
**Note:** This example does not use PKCE.
|
||||||
|
|
|
@ -31,9 +31,14 @@ type OAuth2Application struct {
|
||||||
Name string
|
Name string
|
||||||
ClientID string `xorm:"unique"`
|
ClientID string `xorm:"unique"`
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
RedirectURIs []string `xorm:"redirect_uris JSON TEXT"`
|
// OAuth defines both Confidential and Public client types
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
// "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() {
|
func init() {
|
||||||
|
@ -57,15 +62,17 @@ func (app *OAuth2Application) PrimaryRedirectURI() string {
|
||||||
|
|
||||||
// ContainsRedirectURI checks if redirectURI is allowed for app
|
// ContainsRedirectURI checks if redirectURI is allowed for app
|
||||||
func (app *OAuth2Application) ContainsRedirectURI(redirectURI string) bool {
|
func (app *OAuth2Application) ContainsRedirectURI(redirectURI string) bool {
|
||||||
uri, err := url.Parse(redirectURI)
|
if !app.ConfidentialClient {
|
||||||
// ignore port for http loopback uris following https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
uri, err := url.Parse(redirectURI)
|
||||||
if err == nil && uri.Scheme == "http" && uri.Port() != "" {
|
// ignore port for http loopback uris following https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
||||||
ip := net.ParseIP(uri.Hostname())
|
if err == nil && uri.Scheme == "http" && uri.Port() != "" {
|
||||||
if ip != nil && ip.IsLoopback() {
|
ip := net.ParseIP(uri.Hostname())
|
||||||
// strip port
|
if ip != nil && ip.IsLoopback() {
|
||||||
uri.Host = uri.Hostname()
|
// strip port
|
||||||
if util.IsStringInSlice(uri.String(), app.RedirectURIs, true) {
|
uri.Host = uri.Hostname()
|
||||||
return true
|
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
|
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
||||||
type CreateOAuth2ApplicationOptions struct {
|
type CreateOAuth2ApplicationOptions struct {
|
||||||
Name string
|
Name string
|
||||||
UserID int64
|
UserID int64
|
||||||
RedirectURIs []string
|
ConfidentialClient bool
|
||||||
|
RedirectURIs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOAuth2Application inserts a new oauth2 application
|
// CreateOAuth2Application inserts a new oauth2 application
|
||||||
func CreateOAuth2Application(ctx context.Context, opts CreateOAuth2ApplicationOptions) (*OAuth2Application, error) {
|
func CreateOAuth2Application(ctx context.Context, opts CreateOAuth2ApplicationOptions) (*OAuth2Application, error) {
|
||||||
clientID := uuid.New().String()
|
clientID := uuid.New().String()
|
||||||
app := &OAuth2Application{
|
app := &OAuth2Application{
|
||||||
UID: opts.UserID,
|
UID: opts.UserID,
|
||||||
Name: opts.Name,
|
Name: opts.Name,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
RedirectURIs: opts.RedirectURIs,
|
RedirectURIs: opts.RedirectURIs,
|
||||||
|
ConfidentialClient: opts.ConfidentialClient,
|
||||||
}
|
}
|
||||||
if err := db.Insert(ctx, app); err != nil {
|
if err := db.Insert(ctx, app); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -183,10 +192,11 @@ func CreateOAuth2Application(ctx context.Context, opts CreateOAuth2ApplicationOp
|
||||||
|
|
||||||
// UpdateOAuth2ApplicationOptions holds options to update an oauth2 application
|
// UpdateOAuth2ApplicationOptions holds options to update an oauth2 application
|
||||||
type UpdateOAuth2ApplicationOptions struct {
|
type UpdateOAuth2ApplicationOptions struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
UserID int64
|
UserID int64
|
||||||
RedirectURIs []string
|
ConfidentialClient bool
|
||||||
|
RedirectURIs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateOAuth2Application updates an oauth2 application
|
// UpdateOAuth2Application updates an oauth2 application
|
||||||
|
@ -207,6 +217,7 @@ func UpdateOAuth2Application(opts UpdateOAuth2ApplicationOptions) (*OAuth2Applic
|
||||||
|
|
||||||
app.Name = opts.Name
|
app.Name = opts.Name
|
||||||
app.RedirectURIs = opts.RedirectURIs
|
app.RedirectURIs = opts.RedirectURIs
|
||||||
|
app.ConfidentialClient = opts.ConfidentialClient
|
||||||
|
|
||||||
if err = updateOAuth2Application(ctx, app); err != nil {
|
if err = updateOAuth2Application(ctx, app); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -217,7 +228,7 @@ func UpdateOAuth2Application(opts UpdateOAuth2ApplicationOptions) (*OAuth2Applic
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateOAuth2Application(ctx context.Context, app *OAuth2Application) error {
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -45,7 +45,8 @@ func TestOAuth2Application_ContainsRedirectURI(t *testing.T) {
|
||||||
|
|
||||||
func TestOAuth2Application_ContainsRedirectURI_WithPort(t *testing.T) {
|
func TestOAuth2Application_ContainsRedirectURI_WithPort(t *testing.T) {
|
||||||
app := &auth_model.OAuth2Application{
|
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
|
// http loopback uris should ignore port
|
||||||
|
|
|
@ -7,3 +7,14 @@
|
||||||
redirect_uris: '["a", "https://example.com/xyzzy"]'
|
redirect_uris: '["a", "https://example.com/xyzzy"]'
|
||||||
created_unix: 1546869730
|
created_unix: 1546869730
|
||||||
updated_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
|
||||||
|
|
|
@ -6,3 +6,10 @@
|
||||||
redirect_uri: "a"
|
redirect_uri: "a"
|
||||||
valid_until: 3546869730
|
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
|
||||||
|
|
|
@ -20,4 +20,12 @@
|
||||||
counter: 1
|
counter: 1
|
||||||
scope: "openid profile email"
|
scope: "openid profile email"
|
||||||
created_unix: 1546869730
|
created_unix: 1546869730
|
||||||
updated_unix: 1546869730
|
updated_unix: 1546869730
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
user_id: 99
|
||||||
|
application_id: 2
|
||||||
|
counter: 1
|
||||||
|
scope: "whatever"
|
||||||
|
created_unix: 1546869730
|
||||||
|
updated_unix: 1546869730
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-
|
||||||
|
id: 1
|
|
@ -421,6 +421,8 @@ var migrations = []Migration{
|
||||||
NewMigration("Add TeamInvite table", addTeamInviteTable),
|
NewMigration("Add TeamInvite table", addTeamInviteTable),
|
||||||
// v229 -> v230
|
// v229 -> v230
|
||||||
NewMigration("Update counts of all open milestones", updateOpenMilestoneCounts),
|
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
|
// GetCurrentDBVersion returns the current db version
|
||||||
|
|
18
models/migrations/v230.go
Normal file
18
models/migrations/v230.go
Normal file
|
@ -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))
|
||||||
|
}
|
46
models/migrations/v230_test.go
Normal file
46
models/migrations/v230_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -392,12 +392,13 @@ func ToTopicResponse(topic *repo_model.Topic) *api.TopicResponse {
|
||||||
// ToOAuth2Application convert from auth.OAuth2Application to api.OAuth2Application
|
// ToOAuth2Application convert from auth.OAuth2Application to api.OAuth2Application
|
||||||
func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {
|
func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {
|
||||||
return &api.OAuth2Application{
|
return &api.OAuth2Application{
|
||||||
ID: app.ID,
|
ID: app.ID,
|
||||||
Name: app.Name,
|
Name: app.Name,
|
||||||
ClientID: app.ClientID,
|
ClientID: app.ClientID,
|
||||||
ClientSecret: app.ClientSecret,
|
ClientSecret: app.ClientSecret,
|
||||||
RedirectURIs: app.RedirectURIs,
|
ConfidentialClient: app.ConfidentialClient,
|
||||||
Created: app.CreatedUnix.AsTime(),
|
RedirectURIs: app.RedirectURIs,
|
||||||
|
Created: app.CreatedUnix.AsTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,19 +30,21 @@ type CreateAccessTokenOption struct {
|
||||||
|
|
||||||
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
||||||
type CreateOAuth2ApplicationOptions struct {
|
type CreateOAuth2ApplicationOptions struct {
|
||||||
Name string `json:"name" binding:"Required"`
|
Name string `json:"name" binding:"Required"`
|
||||||
RedirectURIs []string `json:"redirect_uris" binding:"Required"`
|
ConfidentialClient bool `json:"confidential_client"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris" binding:"Required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2Application represents an OAuth2 application.
|
// OAuth2Application represents an OAuth2 application.
|
||||||
// swagger:response OAuth2Application
|
// swagger:response OAuth2Application
|
||||||
type OAuth2Application struct {
|
type OAuth2Application struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientSecret string `json:"client_secret"`
|
ClientSecret string `json:"client_secret"`
|
||||||
RedirectURIs []string `json:"redirect_uris"`
|
ConfidentialClient bool `json:"confidential_client"`
|
||||||
Created time.Time `json:"created"`
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2ApplicationList represents a list of OAuth2 applications.
|
// OAuth2ApplicationList represents a list of OAuth2 applications.
|
||||||
|
|
|
@ -749,9 +749,7 @@ create_oauth2_application_button = Create Application
|
||||||
create_oauth2_application_success = You've successfully created a new OAuth2 application.
|
create_oauth2_application_success = You've successfully created a new OAuth2 application.
|
||||||
update_oauth2_application_success = You've successfully updated the OAuth2 application.
|
update_oauth2_application_success = You've successfully updated the OAuth2 application.
|
||||||
oauth2_application_name = Application Name
|
oauth2_application_name = Application Name
|
||||||
oauth2_select_type = Which application type fits?
|
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_type_web = Web (e.g. Node.JS, Tomcat, Go)
|
|
||||||
oauth2_type_native = Native (e.g. Mobile, Desktop, Browser)
|
|
||||||
oauth2_redirect_uri = Redirect URI
|
oauth2_redirect_uri = Redirect URI
|
||||||
save_application = Save
|
save_application = Save
|
||||||
oauth2_client_id = Client ID
|
oauth2_client_id = Client ID
|
||||||
|
|
|
@ -213,9 +213,10 @@ func CreateOauth2Application(ctx *context.APIContext) {
|
||||||
data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions)
|
data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions)
|
||||||
|
|
||||||
app, err := auth_model.CreateOAuth2Application(ctx, auth_model.CreateOAuth2ApplicationOptions{
|
app, err := auth_model.CreateOAuth2Application(ctx, auth_model.CreateOAuth2ApplicationOptions{
|
||||||
Name: data.Name,
|
Name: data.Name,
|
||||||
UserID: ctx.Doer.ID,
|
UserID: ctx.Doer.ID,
|
||||||
RedirectURIs: data.RedirectURIs,
|
RedirectURIs: data.RedirectURIs,
|
||||||
|
ConfidentialClient: data.ConfidentialClient,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Error(http.StatusBadRequest, "", "error creating oauth2 application")
|
ctx.Error(http.StatusBadRequest, "", "error creating oauth2 application")
|
||||||
|
@ -363,10 +364,11 @@ func UpdateOauth2Application(ctx *context.APIContext) {
|
||||||
data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions)
|
data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions)
|
||||||
|
|
||||||
app, err := auth_model.UpdateOAuth2Application(auth_model.UpdateOAuth2ApplicationOptions{
|
app, err := auth_model.UpdateOAuth2Application(auth_model.UpdateOAuth2ApplicationOptions{
|
||||||
Name: data.Name,
|
Name: data.Name,
|
||||||
UserID: ctx.Doer.ID,
|
UserID: ctx.Doer.ID,
|
||||||
ID: appID,
|
ID: appID,
|
||||||
RedirectURIs: data.RedirectURIs,
|
RedirectURIs: data.RedirectURIs,
|
||||||
|
ConfidentialClient: data.ConfidentialClient,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if auth_model.IsErrOauthClientIDInvalid(err) || auth_model.IsErrOAuthApplicationNotFound(err) {
|
if auth_model.IsErrOauthClientIDInvalid(err) || auth_model.IsErrOAuthApplicationNotFound(err) {
|
||||||
|
|
|
@ -438,8 +438,21 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||||
log.Error("Unable to save changes to the session: %v", err)
|
log.Error("Unable to save changes to the session: %v", err)
|
||||||
}
|
}
|
||||||
case "":
|
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:
|
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{
|
handleAuthorizeError(ctx, AuthorizeError{
|
||||||
ErrorCode: ErrorCodeInvalidRequest,
|
ErrorCode: ErrorCodeInvalidRequest,
|
||||||
ErrorDescription: "unsupported code challenge method",
|
ErrorDescription: "unsupported code challenge method",
|
||||||
|
|
|
@ -39,9 +39,10 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
|
||||||
|
|
||||||
// TODO validate redirect URI
|
// TODO validate redirect URI
|
||||||
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
|
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
|
||||||
Name: form.Name,
|
Name: form.Name,
|
||||||
RedirectURIs: []string{form.RedirectURI},
|
RedirectURIs: []string{form.RedirectURI},
|
||||||
UserID: oa.OwnerID,
|
UserID: oa.OwnerID,
|
||||||
|
ConfidentialClient: form.ConfidentialClient,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("CreateOAuth2Application", err)
|
ctx.ServerError("CreateOAuth2Application", err)
|
||||||
|
@ -90,10 +91,11 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
|
||||||
// TODO validate redirect URI
|
// TODO validate redirect URI
|
||||||
var err error
|
var err error
|
||||||
if ctx.Data["App"], err = auth.UpdateOAuth2Application(auth.UpdateOAuth2ApplicationOptions{
|
if ctx.Data["App"], err = auth.UpdateOAuth2Application(auth.UpdateOAuth2ApplicationOptions{
|
||||||
ID: ctx.ParamsInt64("id"),
|
ID: ctx.ParamsInt64("id"),
|
||||||
Name: form.Name,
|
Name: form.Name,
|
||||||
RedirectURIs: []string{form.RedirectURI},
|
RedirectURIs: []string{form.RedirectURI},
|
||||||
UserID: oa.OwnerID,
|
UserID: oa.OwnerID,
|
||||||
|
ConfidentialClient: form.ConfidentialClient,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
ctx.ServerError("UpdateOAuth2Application", err)
|
ctx.ServerError("UpdateOAuth2Application", err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -379,8 +379,9 @@ func (f *NewAccessTokenForm) Validate(req *http.Request, errs binding.Errors) bi
|
||||||
|
|
||||||
// EditOAuth2ApplicationForm form for editing oauth2 applications
|
// EditOAuth2ApplicationForm form for editing oauth2 applications
|
||||||
type EditOAuth2ApplicationForm struct {
|
type EditOAuth2ApplicationForm struct {
|
||||||
Name string `binding:"Required;MaxSize(255)" form:"application_name"`
|
Name string `binding:"Required;MaxSize(255)" form:"application_name"`
|
||||||
RedirectURI string `binding:"Required" form:"redirect_uri"`
|
RedirectURI string `binding:"Required" form:"redirect_uri"`
|
||||||
|
ConfidentialClient bool `form:"confidential_client"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the fields
|
// Validate validates the fields
|
||||||
|
|
|
@ -14645,6 +14645,10 @@
|
||||||
"description": "CreateOAuth2ApplicationOptions holds options to create an oauth2 application",
|
"description": "CreateOAuth2ApplicationOptions holds options to create an oauth2 application",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"confidential_client": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "ConfidentialClient"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Name"
|
"x-go-name": "Name"
|
||||||
|
@ -17306,6 +17310,10 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "ClientSecret"
|
"x-go-name": "ClientSecret"
|
||||||
},
|
},
|
||||||
|
"confidential_client": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "ConfidentialClient"
|
||||||
|
},
|
||||||
"created": {
|
"created": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
|
|
|
@ -43,6 +43,10 @@
|
||||||
<label for="redirect-uri">{{.locale.Tr "settings.oauth2_redirect_uri"}}</label>
|
<label for="redirect-uri">{{.locale.Tr "settings.oauth2_redirect_uri"}}</label>
|
||||||
<input type="url" name="redirect_uri" value="{{.App.PrimaryRedirectURI}}" id="redirect-uri">
|
<input type="url" name="redirect_uri" value="{{.App.PrimaryRedirectURI}}" id="redirect-uri">
|
||||||
</div>
|
</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">
|
<button class="ui green button">
|
||||||
{{.locale.Tr "settings.save_application"}}
|
{{.locale.Tr "settings.save_application"}}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -37,6 +37,10 @@
|
||||||
<label for="redirect-uri">{{.locale.Tr "settings.oauth2_redirect_uri"}}</label>
|
<label for="redirect-uri">{{.locale.Tr "settings.oauth2_redirect_uri"}}</label>
|
||||||
<input type="url" name="redirect_uri" id="redirect-uri">
|
<input type="url" name="redirect_uri" id="redirect-uri">
|
||||||
</div>
|
</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">
|
<button class="ui green button">
|
||||||
{{.locale.Tr "settings.create_oauth2_application_button"}}
|
{{.locale.Tr "settings.create_oauth2_application_button"}}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -34,6 +34,7 @@ func testAPICreateOAuth2Application(t *testing.T) {
|
||||||
RedirectURIs: []string{
|
RedirectURIs: []string{
|
||||||
"http://www.google.com",
|
"http://www.google.com",
|
||||||
},
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody)
|
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.EqualValues(t, appBody.Name, createdApp.Name)
|
||||||
assert.Len(t, createdApp.ClientSecret, 56)
|
assert.Len(t, createdApp.ClientSecret, 56)
|
||||||
assert.Len(t, createdApp.ClientID, 36)
|
assert.Len(t, createdApp.ClientID, 36)
|
||||||
|
assert.True(t, createdApp.ConfidentialClient)
|
||||||
assert.NotEmpty(t, createdApp.Created)
|
assert.NotEmpty(t, createdApp.Created)
|
||||||
assert.EqualValues(t, appBody.RedirectURIs[0], createdApp.RedirectURIs[0])
|
assert.EqualValues(t, appBody.RedirectURIs[0], createdApp.RedirectURIs[0])
|
||||||
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{UID: user.ID, Name: createdApp.Name})
|
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{UID: user.ID, Name: createdApp.Name})
|
||||||
|
@ -62,6 +64,7 @@ func testAPIListOAuth2Applications(t *testing.T) {
|
||||||
RedirectURIs: []string{
|
RedirectURIs: []string{
|
||||||
"http://www.google.com",
|
"http://www.google.com",
|
||||||
},
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2?token=%s", token)
|
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.Name, expectedApp.Name)
|
||||||
assert.EqualValues(t, existApp.ClientID, expectedApp.ClientID)
|
assert.EqualValues(t, existApp.ClientID, expectedApp.ClientID)
|
||||||
|
assert.Equal(t, existApp.ConfidentialClient, expectedApp.ConfidentialClient)
|
||||||
assert.Len(t, expectedApp.ClientID, 36)
|
assert.Len(t, expectedApp.ClientID, 36)
|
||||||
assert.Empty(t, expectedApp.ClientSecret)
|
assert.Empty(t, expectedApp.ClientSecret)
|
||||||
assert.EqualValues(t, existApp.RedirectURIs[0], expectedApp.RedirectURIs[0])
|
assert.EqualValues(t, existApp.RedirectURIs[0], expectedApp.RedirectURIs[0])
|
||||||
|
@ -112,6 +116,7 @@ func testAPIGetOAuth2Application(t *testing.T) {
|
||||||
RedirectURIs: []string{
|
RedirectURIs: []string{
|
||||||
"http://www.google.com",
|
"http://www.google.com",
|
||||||
},
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d?token=%s", existApp.ID, token)
|
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.Name, expectedApp.Name)
|
||||||
assert.EqualValues(t, existApp.ClientID, expectedApp.ClientID)
|
assert.EqualValues(t, existApp.ClientID, expectedApp.ClientID)
|
||||||
|
assert.Equal(t, existApp.ConfidentialClient, expectedApp.ConfidentialClient)
|
||||||
assert.Len(t, expectedApp.ClientID, 36)
|
assert.Len(t, expectedApp.ClientID, 36)
|
||||||
assert.Empty(t, expectedApp.ClientSecret)
|
assert.Empty(t, expectedApp.ClientSecret)
|
||||||
assert.Len(t, expectedApp.RedirectURIs, 1)
|
assert.Len(t, expectedApp.RedirectURIs, 1)
|
||||||
|
@ -148,6 +154,7 @@ func testAPIUpdateOAuth2Application(t *testing.T) {
|
||||||
"http://www.google.com/",
|
"http://www.google.com/",
|
||||||
"http://www.github.com/",
|
"http://www.github.com/",
|
||||||
},
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d", existApp.ID)
|
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.Len(t, expectedApp.RedirectURIs, 2)
|
||||||
assert.EqualValues(t, expectedApp.RedirectURIs[0], appBody.RedirectURIs[0])
|
assert.EqualValues(t, expectedApp.RedirectURIs[0], appBody.RedirectURIs[0])
|
||||||
assert.EqualValues(t, expectedApp.RedirectURIs[1], appBody.RedirectURIs[1])
|
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})
|
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name})
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,6 +86,17 @@ func TestAuthorizeRedirectWithExistingGrant(t *testing.T) {
|
||||||
assert.Equal(t, "https://example.com/xyzzy", u.String())
|
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) {
|
func TestAccessTokenExchange(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
||||||
|
|
Loading…
Reference in a new issue