From bb4ae448060eff9542191ba30d8bc080d761a45e Mon Sep 17 00:00:00 2001 From: a1012112796 <1012112796@qq.com> Date: Fri, 22 Aug 2025 08:54:21 +0800 Subject: [PATCH 1/3] add oauth2 device flow support Signed-off-by: a1012112796 <1012112796@qq.com> --- models/auth/oauth2.go | 408 +++++++++++++++++- models/auth/oauth2_test.go | 47 ++ models/migrations/migrations.go | 1 + models/migrations/v1_25/v322.go | 54 +++ modules/setting/oauth2.go | 2 + options/locale/locale_en-US.ini | 10 + routers/web/auth/oauth2_provider.go | 243 ++++++++++- routers/web/user/setting/applications.go | 6 + routers/web/user/setting/oauth2.go | 5 + routers/web/user/setting/oauth2_common.go | 13 + routers/web/web.go | 8 + services/auth/oauth2.go | 13 +- services/forms/user_form.go | 34 ++ services/oauth2_provider/access_token.go | 36 +- templates/user/auth/device.tmpl | 20 + templates/user/auth/device_confirm.tmpl | 28 ++ .../applications_oauth2_edit_form.tmpl | 6 + .../settings/applications_oauth2_list.tmpl | 6 + templates/user/settings/grants_oauth2.tmpl | 29 ++ tests/integration/oauth_test.go | 132 ++++++ 20 files changed, 1067 insertions(+), 34 deletions(-) create mode 100644 models/migrations/v1_25/v322.go create mode 100644 templates/user/auth/device.tmpl create mode 100644 templates/user/auth/device_confirm.tmpl diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index d66484130695a..c528e4ee7ccb6 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -6,8 +6,10 @@ package auth import ( "context" "crypto/sha256" + "crypto/subtle" "encoding/base32" "encoding/base64" + "encoding/hex" "errors" "fmt" "net" @@ -41,6 +43,7 @@ type OAuth2Application struct { ConfidentialClient bool `xorm:"NOT NULL DEFAULT TRUE"` SkipSecondaryAuthorization bool `xorm:"NOT NULL DEFAULT FALSE"` RedirectURIs []string `xorm:"redirect_uris JSON TEXT"` + EnableDeviceFlow bool `xorm:"NOT NULL DEFAULT FALSE"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` } @@ -49,30 +52,36 @@ func init() { db.RegisterModel(new(OAuth2Application)) db.RegisterModel(new(OAuth2AuthorizationCode)) db.RegisterModel(new(OAuth2Grant)) + db.RegisterModel(new(OAuth2Device)) + db.RegisterModel(new(OAuth2DeviceGrant)) } type BuiltinOAuth2Application struct { - ConfigName string - DisplayName string - RedirectURIs []string + ConfigName string + DisplayName string + RedirectURIs []string + EnableDeviceFlow bool } 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"}, + ConfigName: "git-credential-oauth", + DisplayName: "git-credential-oauth", + RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"}, + EnableDeviceFlow: true, } 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"}, + ConfigName: "git-credential-manager", + DisplayName: "Git Credential Manager", + RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"}, + EnableDeviceFlow: true, } m["d57cb8c4-630c-4168-8324-ec79935e18d4"] = &BuiltinOAuth2Application{ - ConfigName: "tea", - DisplayName: "tea", - RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"}, + ConfigName: "tea", + DisplayName: "tea", + RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"}, + EnableDeviceFlow: true, } return m } @@ -117,14 +126,26 @@ func Init(ctx context.Context) error { if err := deleteOAuth2Application(ctx, app.ID, 0); err != nil { return err } + + continue + } + + app.EnableDeviceFlow = builtinApps[app.ClientID].EnableDeviceFlow + app.RedirectURIs = builtinApps[app.ClientID].RedirectURIs + app.Name = builtinApps[app.ClientID].DisplayName + + _, err := db.GetEngine(ctx).ID(app.ID).UseBool("enable_device_flow").Cols("name", "redirect_uris", "enable_device_flow").Update(app) + if 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, + Name: builtinApp.DisplayName, + ClientID: clientID, + RedirectURIs: builtinApp.RedirectURIs, + EnableDeviceFlow: builtinApp.EnableDeviceFlow, }); err != nil { return err } @@ -258,6 +279,7 @@ type CreateOAuth2ApplicationOptions struct { ConfidentialClient bool SkipSecondaryAuthorization bool RedirectURIs []string + EnableDeviceFlow bool } // CreateOAuth2Application inserts a new oauth2 application @@ -270,6 +292,7 @@ func CreateOAuth2Application(ctx context.Context, opts CreateOAuth2ApplicationOp RedirectURIs: opts.RedirectURIs, ConfidentialClient: opts.ConfidentialClient, SkipSecondaryAuthorization: opts.SkipSecondaryAuthorization, + EnableDeviceFlow: opts.EnableDeviceFlow, } if err := db.Insert(ctx, app); err != nil { return nil, err @@ -285,6 +308,7 @@ type UpdateOAuth2ApplicationOptions struct { ConfidentialClient bool SkipSecondaryAuthorization bool RedirectURIs []string + EnableDeviceFlow bool } // UpdateOAuth2Application updates an oauth2 application @@ -306,6 +330,7 @@ func UpdateOAuth2Application(ctx context.Context, opts UpdateOAuth2ApplicationOp app.RedirectURIs = opts.RedirectURIs app.ConfidentialClient = opts.ConfidentialClient app.SkipSecondaryAuthorization = opts.SkipSecondaryAuthorization + app.EnableDeviceFlow = opts.EnableDeviceFlow if err = updateOAuth2Application(ctx, app); err != nil { return nil, err @@ -317,7 +342,7 @@ func UpdateOAuth2Application(ctx context.Context, opts UpdateOAuth2ApplicationOp } func updateOAuth2Application(ctx context.Context, app *OAuth2Application) error { - if _, err := db.GetEngine(ctx).ID(app.ID).UseBool("confidential_client", "skip_secondary_authorization").Update(app); err != nil { + if _, err := db.GetEngine(ctx).ID(app.ID).UseBool("confidential_client", "skip_secondary_authorization", "enable_device_flow").Update(app); err != nil { return err } return nil @@ -444,6 +469,13 @@ func GetOAuth2AuthorizationByCode(ctx context.Context, code string) (auth *OAuth ////////////////////////////////////////////////////// +type OAuth2GrantType int64 + +const ( + OAuth2GrantTypeNormal OAuth2GrantType = iota + OAuth2GrantTypeDevice +) + // OAuth2Grant represents the permission of an user for a specific application to access resources type OAuth2Grant struct { ID int64 `xorm:"pk autoincr"` @@ -457,11 +489,46 @@ type OAuth2Grant struct { UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } +type IOAuth2Grant interface { + IncreaseCounter(ctx context.Context) error + GetID() int64 + GetCounter() int64 + ScopeContains(scope string) bool + GetApplicationID() int64 + GetUserID() int64 + GetNonce() string + GetScope() string +} + // TableName sets the table name to `oauth2_grant` func (grant *OAuth2Grant) TableName() string { return "oauth2_grant" } +func (grant *OAuth2Grant) GetApplicationID() int64 { + return grant.ApplicationID +} + +func (grant *OAuth2Grant) GetUserID() int64 { + return grant.UserID +} + +func (grant *OAuth2Grant) GetNonce() string { + return grant.Nonce +} + +func (grant *OAuth2Grant) GetID() int64 { + return grant.ID +} + +func (grant *OAuth2Grant) GetCounter() int64 { + return grant.Counter +} + +func (grant *OAuth2Grant) GetScope() string { + return grant.Scope +} + // GenerateNewAuthorizationCode generates a new authorization code for a grant and saves it to the database func (grant *OAuth2Grant) GenerateNewAuthorizationCode(ctx context.Context, redirectURI, codeChallenge, codeChallengeMethod string) (code *OAuth2AuthorizationCode, err error) { rBytes, err := util.CryptoRandomBytes(32) @@ -634,3 +701,312 @@ func DeleteOAuth2RelictsByUserID(ctx context.Context, userID int64) error { return nil } + +type OAuth2Device struct { + ID int64 `xorm:"pk autoincr"` + DeviceCode string `xorm:"-"` + DeviceCodeHash string `xorm:"UNIQUE"` // sha256 of device code + DeviceCodeSalt string + DeviceCodeID string `xorm:"INDEX"` + UserCode string `xorm:"INDEX VARCHAR(9)"` + Application *OAuth2Application `xorm:"-"` + ApplicationID int64 `xorm:"INDEX"` + Scope string `xorm:"TEXT"` + GrantID int64 + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + ExpiredUnix timeutil.TimeStamp +} + +// TableName sets the table name to `oauth2_device` +func (device *OAuth2Device) TableName() string { + return "oauth2_device" +} + +func (device *OAuth2Device) LoadApplication(ctx context.Context) error { + if device.Application != nil { + return nil + } + + application := new(OAuth2Application) + has, err := db.GetEngine(ctx).ID(device.ApplicationID).Get(application) + if err != nil { + return err + } + if !has { + return &ErrOAuthApplicationNotFound{ID: device.ApplicationID} + } + + device.Application = application + + return nil +} + +func generateUserCode() (string, error) { + rBytes, err := util.CryptoRandomBytes(8) + if err != nil { + return "", err + } + letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + code := make([]byte, 8) + for i := range code { + code[i] = letters[int(rBytes[i])%len(letters)] + } + return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil +} + +// CreateDevice generates a device for an user +func (app *OAuth2Application) CreateDevice(ctx context.Context, scope string) (*OAuth2Device, error) { + userCode, err := generateUserCode() + if err != nil { + return nil, err + } + + device := &OAuth2Device{ + ApplicationID: app.ID, + UserCode: userCode, + Scope: scope, + ExpiredUnix: timeutil.TimeStampNow().Add(setting.OAuth2.DeviceFlowExpirationTime), + } + + salt, err := util.CryptoRandomString(10) + if err != nil { + return nil, err + } + code, err := util.CryptoRandomBytes(20) + if err != nil { + return nil, err + } + + device.DeviceCode = hex.EncodeToString(code) + device.DeviceCodeSalt = salt + device.DeviceCodeID = device.DeviceCode[len(device.DeviceCode)-8:] + device.DeviceCodeHash = HashToken(device.DeviceCode, device.DeviceCodeSalt) + + err = db.Insert(ctx, device) + if err != nil { + return nil, err + } + + return device, nil +} + +func (app *OAuth2Application) GetDeviceByDeviceCode(ctx context.Context, deviceCode string) (*OAuth2Device, error) { + if len(deviceCode) != 40 { + return nil, &ErrOAuth2DeviceNotFound{UserCode: deviceCode} + } + for _, x := range []byte(deviceCode) { + if x < '0' || (x > '9' && x < 'a') || x > 'f' { + return nil, &ErrOAuth2DeviceNotFound{UserCode: deviceCode} + } + } + + deviceCodeID := deviceCode[len(deviceCode)-8:] + var deviceList []OAuth2Device + err := db.GetEngine(ctx).Table(&OAuth2Device{}).Where("device_code_id = ? AND application_id = ?", deviceCodeID, app.ID).Find(&deviceList) + if err != nil { + return nil, err + } else if len(deviceList) == 0 { + return nil, &ErrOAuth2DeviceNotFound{UserCode: deviceCode} + } + + for _, t := range deviceList { + tempHash := HashToken(deviceCode, t.DeviceCodeSalt) + if subtle.ConstantTimeCompare([]byte(t.DeviceCodeHash), []byte(tempHash)) == 1 { + return &t, nil + } + } + + return nil, &ErrOAuth2DeviceNotFound{UserCode: deviceCode} +} + +func (device *OAuth2Device) GetGrant(ctx context.Context) (*OAuth2DeviceGrant, error) { + if device.GrantID <= 0 { + return nil, fmt.Errorf("no grant found for device: %d", device.ID) + } + + grant := new(OAuth2DeviceGrant) + _, err := db.GetEngine(ctx).ID(device.GrantID).Get(grant) + + return grant, err +} + +type ErrOAuth2DeviceNotFound struct { + UserCode string + ID int64 +} + +func (err *ErrOAuth2DeviceNotFound) Error() string { + return fmt.Sprintf("oauth2 device not found: [user_code: %s. id: %d]", err.UserCode, err.ID) +} + +func IsErrOAuth2DeviceNotFound(err error) bool { + _, ok := err.(*ErrOAuth2DeviceNotFound) + return ok +} + +func GetDeviceByUserCode(ctx context.Context, userCode string) (*OAuth2Device, error) { + device := new(OAuth2Device) + + ok, err := db.GetEngine(ctx).Where("user_code = ? AND grant_id = ? AND expired_unix > ?", + userCode, 0, timeutil.TimeStampNow()).Get(device) + if err != nil { + return nil, err + } + if !ok { + return nil, &ErrOAuth2DeviceNotFound{UserCode: userCode} + } + + return device, nil +} + +func GetDeviceByID(ctx context.Context, id int64) (*OAuth2Device, error) { + device := new(OAuth2Device) + ok, err := db.GetEngine(ctx).ID(id).Get(device) + if err != nil { + return nil, err + } + if !ok { + return nil, &ErrOAuth2DeviceNotFound{ID: id} + } + + return device, nil +} + +type OAuth2DeviceGrant struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX"` + Application *OAuth2Application `xorm:"-"` + ApplicationID int64 `xorm:"INDEX"` + DeviceID int64 `xorm:"INDEX"` + Counter int64 `xorm:"NOT NULL DEFAULT 1"` + UserCode string `xorm:"VARCHAR(9)"` + Scope string `xorm:"TEXT"` + Nonce string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +// TableName sets theOAuth2DeviceGrant table name to `oauth2_device_grant` +func (grant *OAuth2DeviceGrant) TableName() string { + return "oauth2_device_grant" +} + +func (device *OAuth2Device) CreateGrant(ctx context.Context, userID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + grant := &OAuth2DeviceGrant{ + UserID: userID, + DeviceID: device.ID, + ApplicationID: device.ApplicationID, + Scope: device.Scope, + UserCode: device.UserCode, + } + err := db.Insert(ctx, grant) + if err != nil { + return err + } + + device.GrantID = grant.ID + _, err = db.GetEngine(ctx).ID(device.ID).Cols("grant_id").Update(device) + return err + }) +} + +// GetOAuth2GrantByID returns the grant with the given ID +func GetOAuth2DeviceGrantByID(ctx context.Context, id int64) (grant *OAuth2DeviceGrant, err error) { + grant = new(OAuth2DeviceGrant) + if has, err := db.GetEngine(ctx).ID(id).Get(grant); err != nil { + return nil, err + } else if !has { + return nil, nil + } + return grant, err +} + +func (grant *OAuth2DeviceGrant) IncreaseCounter(ctx context.Context) error { + _, err := db.GetEngine(ctx).ID(grant.ID).Incr("counter").Update(new(OAuth2Grant)) + if err != nil { + return err + } + updatedGrant, err := GetOAuth2DeviceGrantByID(ctx, grant.ID) + if err != nil { + return err + } + grant.Counter = updatedGrant.Counter + return nil +} + +func (grant *OAuth2DeviceGrant) GetID() int64 { + return -grant.ID +} + +func (grant *OAuth2DeviceGrant) GetCounter() int64 { + return grant.Counter +} + +func (grant *OAuth2DeviceGrant) ScopeContains(scope string) bool { + return slices.Contains(strings.Split(grant.Scope, " "), scope) +} + +func (grant *OAuth2DeviceGrant) GetApplicationID() int64 { + return grant.ApplicationID +} + +func (grant *OAuth2DeviceGrant) GetUserID() int64 { + return grant.UserID +} + +func (grant *OAuth2DeviceGrant) GetNonce() string { + return grant.Nonce +} + +func (grant *OAuth2DeviceGrant) GetScope() string { + return grant.Scope +} + +// GetOAuth2GrantsByUserID lists all grants of a certain user +func GetOAuth2DeviceGrantsByUserID(ctx context.Context, uid int64) ([]*OAuth2DeviceGrant, error) { + type joinedOAuth2DeviceGrant struct { + Grant *OAuth2DeviceGrant `xorm:"extends"` + Application *OAuth2Application `xorm:"extends"` + } + var results *xorm.Rows + var err error + if results, err = db.GetEngine(ctx). + Table("oauth2_device_grant"). + Where("user_id = ?", uid). + Join("INNER", "oauth2_application", "application_id = oauth2_application.id"). + Rows(new(joinedOAuth2DeviceGrant)); err != nil { + return nil, err + } + defer results.Close() + grants := make([]*OAuth2DeviceGrant, 0) + for results.Next() { + joinedGrant := new(joinedOAuth2DeviceGrant) + if err := results.Scan(joinedGrant); err != nil { + return nil, err + } + joinedGrant.Grant.Application = joinedGrant.Application + grants = append(grants, joinedGrant.Grant) + } + return grants, nil +} + +// RevokeOAuth2Grant deletes the device grant with grantID and userID +func RevokeOAuth2DeviceGrant(ctx context.Context, grantID, userID int64) error { + if grantID <= 0 { + return errors.New("invalid grant ID") + } + + return db.WithTx(ctx, func(ctx context.Context) error { + _, err := db.GetEngine(ctx).Where(builder.Eq{"grant_id": grantID}). + Delete(&OAuth2Device{}) + if err != nil { + return err + } + + _, err = db.GetEngine(ctx).Where(builder.Eq{"id": grantID, "user_id": userID}). + Delete(&OAuth2DeviceGrant{}) + return err + }) +} diff --git a/models/auth/oauth2_test.go b/models/auth/oauth2_test.go index 97f750755a20d..2323311b5a8c2 100644 --- a/models/auth/oauth2_test.go +++ b/models/auth/oauth2_test.go @@ -4,9 +4,11 @@ package auth_test import ( + "fmt" "testing" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "github.com/stretchr/testify/assert" @@ -262,3 +264,48 @@ func TestOAuth2AuthorizationCode_Invalidate(t *testing.T) { func TestOAuth2AuthorizationCode_TableName(t *testing.T) { assert.Equal(t, "oauth2_authorization_code", new(auth_model.OAuth2AuthorizationCode).TableName()) } + +func TestOAuth2Device_GetDeviceByDeviceCode(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + app := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: 1}) + + device, err := app.CreateDevice(t.Context(), "") + assert.NoError(t, err) + + device2, err := app.GetDeviceByDeviceCode(t.Context(), device.DeviceCode) + assert.NoError(t, err) + assert.Equal(t, device.ID, device2.ID) +} + +func TestOAuth2Device_GetDeviceByUserCode(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + app := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: 1}) + + device, err := app.CreateDevice(t.Context(), "") + assert.NoError(t, err) + + device2, err := auth_model.GetDeviceByUserCode(t.Context(), device.UserCode) + assert.NoError(t, err) + assert.Equal(t, device.ID, device2.ID) + + device2.CreateGrant(t.Context(), 1) + + _, err = auth_model.GetDeviceByUserCode(t.Context(), device.UserCode) + assert.EqualError(t, err, fmt.Sprintf("oauth2 device not found: [user_code: %s. id: 0]", device.UserCode)) +} + +func TestOAuth2Device_GetDeviceByUserCode_Expired(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + app := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: 1}) + + device, err := app.CreateDevice(t.Context(), "") + assert.NoError(t, err) + + db.GetEngine(t.Context()).ID(device.ID).Cols("expired_unix").Update(&auth_model.OAuth2Device{ExpiredUnix: 1}) + + _, err = auth_model.GetDeviceByUserCode(t.Context(), device.UserCode) + assert.EqualError(t, err, fmt.Sprintf("oauth2 device not found: [user_code: %s. id: 0]", device.UserCode)) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 88967a8b870d9..988fdad870f1f 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -393,6 +393,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.24.0 ends at database version 321 newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs), + newMigration(322, "Add OAuth2 Device Flow Support", v1_25.AddOAuth2DeviceFlowSupport), } return preparedMigrations } diff --git a/models/migrations/v1_25/v322.go b/models/migrations/v1_25/v322.go new file mode 100644 index 0000000000000..91157aa4a81ea --- /dev/null +++ b/models/migrations/v1_25/v322.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "code.gitea.io/gitea/modules/timeutil" + "xorm.io/xorm" +) + +func AddOAuth2DeviceFlowSupport(x *xorm.Engine) error { + type OAuth2Application struct { + EnableDeviceFlow bool `xorm:"NOT NULL DEFAULT FALSE"` + } + + if err := x.Sync2(new(OAuth2Application)); err != nil { + return err + } + + type OAuth2Device struct { + ID int64 `xorm:"pk autoincr"` + DeviceCode string `xorm:"-"` + DeviceCodeHash string `xorm:"UNIQUE"` // sha256 of device code + DeviceCodeSalt string + DeviceCodeID string `xorm:"INDEX"` + UserCode string `xorm:"INDEX VARCHAR(9)"` + Application *OAuth2Application `xorm:"-"` + ApplicationID int64 `xorm:"INDEX"` + Scope string `xorm:"TEXT"` + GrantID int64 + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + ExpiredUnix timeutil.TimeStamp + } + + if err := x.Sync2(new(OAuth2Device)); err != nil { + return err + } + + type OAuth2DeviceGrant struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX"` + Application *OAuth2Application `xorm:"-"` + ApplicationID int64 `xorm:"INDEX"` + DeviceID int64 `xorm:"INDEX"` + Counter int64 `xorm:"NOT NULL DEFAULT 1"` + UserCode string `xorm:"VARCHAR(9)"` + Scope string `xorm:"TEXT"` + Nonce string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + return x.Sync2(new(OAuth2DeviceGrant)) +} diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go index 1a88f3cb0825c..dbc863c8b2092 100644 --- a/modules/setting/oauth2.go +++ b/modules/setting/oauth2.go @@ -98,6 +98,7 @@ var OAuth2 = struct { JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` MaxTokenLength int DefaultApplications []string + DeviceFlowExpirationTime int64 }{ Enabled: true, AccessTokenExpirationTime: 3600, @@ -107,6 +108,7 @@ var OAuth2 = struct { JWTSigningPrivateKeyFile: "jwt/private.pem", MaxTokenLength: math.MaxInt16, DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"}, + DeviceFlowExpirationTime: 900, } func loadOAuth2From(rootCfg ConfigProvider) { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 315241a4174ab..e6c0b1d8f904e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -489,6 +489,13 @@ password_pwned_err = Could not complete request to HaveIBeenPwned last_admin = You cannot remove the last admin. There must be at least one admin. signin_passkey = Sign in with a passkey back_to_sign_in = Back to Sign In +authorize_device_title = Device Activation +authorize_device_description = Enter the code displayed on your device +device_not_found = Device not found +device_found = Device found: %s +device_expired = The code has been expired +device_granted = The code has been used +device_granted_success = Device Activation Successful (user code: %s) [mail] view_it_on = View it on %s @@ -962,13 +969,16 @@ oauth2_application_edit = Edit 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_locked = Gitea pre-registers some OAuth2 applications on startup if enabled in config. To prevent unexpected behavior, these can neither be edited nor removed. Please refer to the OAuth2 documentation for more information. +oauth2_enable_device_flow = Enable Device Flow 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_device_description = You have granted access to your personal Gitea account to these third-party applications using device flow. Please revoke access for applications you no longer need. revoke_key = Revoke revoke_oauth2_grant = Revoke Access revoke_oauth2_grant_description = Revoking access for this third-party application will prevent this application from accessing your data. Are you sure? revoke_oauth2_grant_success = Access revoked successfully. +authorized_oauth2_user_code = User code: %s twofa_desc = To protect your account against password theft, you can use a smartphone or another device for receiving time-based one-time passwords ("TOTP"). twofa_recovery_tip = If you lose your device, you will be able to use a single-use recovery key to regain access to your account. diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go index 79989d8fbe69a..d848237b57e3e 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" @@ -30,8 +31,10 @@ import ( ) const ( - tplGrantAccess templates.TplName = "user/auth/grant" - tplGrantError templates.TplName = "user/auth/grant_error" + tplGrantAccess templates.TplName = "user/auth/grant" + tplGrantError templates.TplName = "user/auth/grant_error" + tplDeviceAuth templates.TplName = "user/auth/device" + tplDeviceConfirm templates.TplName = "user/auth/device_confirm" ) // TODO move error and responses to SDK or models @@ -55,6 +58,8 @@ const ( ErrorCodeServerError AuthorizeErrorCode = "server_error" // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749 ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable" + // ErrorDeviceFlowDisabled represents the according error like github + ErrorDeviceFlowDisabled AuthorizeErrorCode = "device_flow_disabled" ) // AuthorizeError represents an error type specified in RFC 6749 @@ -511,6 +516,8 @@ func AccessTokenOAuth(ctx *context.Context) { handleRefreshToken(ctx, form, serverKey, clientKey) case "authorization_code": handleAuthorizationCode(ctx, form, serverKey, clientKey) + case "urn:ietf:params:oauth:grant-type:device_code": + handleDeviceAuthorizationCode(ctx, form, serverKey, clientKey) default: handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ ErrorCode: oauth2_provider.AccessTokenErrorCodeUnsupportedGrantType, @@ -552,8 +559,15 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server }) return } + + var grant auth.IOAuth2Grant + // get grant before increasing counter - grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if token.GrantID < 0 { + grant, err = auth.GetOAuth2DeviceGrantByID(ctx, -token.GrantID) + } else { + grant, err = auth.GetOAuth2GrantByID(ctx, token.GrantID) + } if err != nil || grant == nil { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant, @@ -563,12 +577,12 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server } // check if token got already used - if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) { + if setting.OAuth2.InvalidateRefreshTokens && (grant.GetCounter() != token.Counter || token.Counter == 0) { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient, ErrorDescription: "token was already used", }) - log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) + log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.GetID()) return } accessToken, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, grant, serverKey, clientKey) @@ -677,3 +691,222 @@ func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirect redirect.RawQuery = q.Encode() ctx.Redirect(redirect.String(), http.StatusSeeOther) } + +// AuthorizeOAuth manages device flow authorize requests +func AuthorizeDeviceOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AuthorizationDeviceForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + if auth.IsErrOauthClientIDInvalid(err) { + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient, + ErrorDescription: "Client ID not registered", + }) + return + } + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + + if !app.EnableDeviceFlow { + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorDeviceFlowDisabled, + ErrorDescription: "Device Flow must be explicitly enabled for this App", + }) + return + } + + code, err := app.CreateDevice(ctx, form.Scope) + if err != nil { + ctx.ServerError("CreateDevice", err) + return + } + + ctx.JSON(http.StatusOK, &oauth2_provider.DeviceAuthorizationResponse{ + DeviceCode: code.DeviceCode, + UserCode: code.UserCode, + ExpiresIn: setting.OAuth2.DeviceFlowExpirationTime, + VerificationURI: setting.AppURL + "login/device", + Interval: 5, + }) +} + +func handleDeviceAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID), + }) + return + } + + device, err := app.GetDeviceByDeviceCode(ctx, form.DeviceCode) + if err != nil { + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant, + ErrorDescription: fmt.Sprintf("cannot find device with device code: '%s'", form.DeviceCode), + }) + return + } + + if !app.EnableDeviceFlow { + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorDeviceFlowDisabled, + ErrorDescription: "Device Flow must be explicitly enabled for this App", + }) + return + } + + grant, err := device.GetGrant(ctx) + if err != nil { + if device.ExpiredUnix < timeutil.TimeStampNow() { + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorExpiredToken, + ErrorDescription: fmt.Sprintf("device code '%s' expired", form.DeviceCode), + }) + return + } + + handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ + ErrorCode: oauth2_provider.AccessTokenErrorCodeAuthorizationPending, + ErrorDescription: fmt.Sprintf("cannot find grant for device code: '%s'", form.DeviceCode), + }) + return + } + + resp, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + + // send successful response + ctx.JSON(http.StatusOK, resp) +} + +func AuthorizeOAuthDevice(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.authorize_device_title") + + ctx.HTML(http.StatusOK, tplDeviceAuth) +} + +func AuthorizeOAuthDevicePost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.authorize_device_title") + + form := web.GetForm(ctx).(*forms.Oauth2DeviceActivationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("Oauth2DeviceActivationForm: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + form.UserCode = strings.ToUpper(strings.TrimSpace(form.UserCode)) + + device, err := auth.GetDeviceByUserCode(ctx, form.UserCode) + if err != nil { + if !auth.IsErrOAuth2DeviceNotFound(err) { + ctx.ServerError("GetDeviceByUserCode", err) + return + } + + ctx.Data["Err_UserCode"] = true + ctx.Data["UserCode"] = form.UserCode + ctx.RenderWithErr(ctx.Tr("auth.device_not_found"), tplDeviceAuth, &form) + + return + } + + err = device.LoadApplication(ctx) + if err != nil { + ctx.ServerError("LoadApplication", err) + return + } + + ctx.Data["UserCode"] = form.UserCode + ctx.Data["DeviceID"] = device.ID + ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(device.Scope) != auth.AccessTokenScopeAll + + var user *user_model.User + if device.Application.UID != 0 { + user, err = user_model.GetUserByID(ctx, device.Application.UID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + } + + ctx.Data["Application"] = device.Application + ctx.Data["Scope"] = device.Scope + if user != nil { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name))) + } else { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName))) + } + + ctx.HTML(http.StatusOK, tplDeviceConfirm) +} + +func AuthorizeOAuthDeviceConfirm(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.authorize_device_title") + + form := web.GetForm(ctx).(*forms.Oauth2DeviceConfirmationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("Oauth2DeviceConfirmationForm: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + if !form.Granted { + ctx.Redirect(setting.AppSubURL+"/login/device", http.StatusSeeOther) + return + } + + device, err := auth.GetDeviceByID(ctx, form.DeviceID) + if err != nil && !auth.IsErrOAuth2DeviceNotFound(err) { + ctx.ServerError("GetDeviceByID", err) + return + } + + if err != nil || device.ExpiredUnix < timeutil.TimeStampNow() { + ctx.Flash.Error(ctx.Tr("auth.device_expired")) + ctx.Redirect(setting.AppSubURL+"/login/device", http.StatusSeeOther) + return + } + + if device.GrantID != 0 { + ctx.Flash.Error(ctx.Tr("auth.device_granted")) + ctx.Redirect(setting.AppSubURL+"/login/device", http.StatusSeeOther) + return + } + + err = device.CreateGrant(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CreateGrant", err) + return + } + + ctx.Flash.Success(ctx.Tr("auth.device_granted_success", device.UserCode)) + ctx.Redirect(setting.AppSubURL+"/login/device", http.StatusSeeOther) +} diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 9c43ddd3ea375..526137aab92d3 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -134,5 +134,11 @@ func loadApplicationsData(ctx *context.Context) { ctx.ServerError("GetOAuth2GrantsByUserID", err) return } + + ctx.Data["DeviceGrants"], err = auth_model.GetOAuth2DeviceGrantsByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOAuth2DeviceGrantsByUserID", err) + return + } } } diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go index d50728c24ecde..a76f768563b20 100644 --- a/routers/web/user/setting/oauth2.go +++ b/routers/web/user/setting/oauth2.go @@ -66,3 +66,8 @@ func RevokeOAuth2Grant(ctx *context.Context) { oa := newOAuth2CommonHandlers(ctx.Doer.ID) oa.RevokeGrant(ctx) } + +func RevokeOAuth2DeviceGrant(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.RevokeDeviceGrant(ctx) +} diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index f460acce10553..ccb96af919e24 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -53,6 +53,7 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { UserID: oa.OwnerID, ConfidentialClient: form.ConfidentialClient, SkipSecondaryAuthorization: form.SkipSecondaryAuthorization, + EnableDeviceFlow: form.EnableDeviceFlow, }) if err != nil { ctx.ServerError("CreateOAuth2Application", err) @@ -122,6 +123,7 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { UserID: oa.OwnerID, ConfidentialClient: form.ConfidentialClient, SkipSecondaryAuthorization: form.SkipSecondaryAuthorization, + EnableDeviceFlow: form.EnableDeviceFlow, }); err != nil { ctx.ServerError("UpdateOAuth2Application", err) return @@ -176,3 +178,14 @@ func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) ctx.JSONRedirect(oa.BasePathList) } + +// RevokeGrant revokes the grant +func (oa *OAuth2CommonHandlers) RevokeDeviceGrant(ctx *context.Context) { + if err := auth.RevokeOAuth2DeviceGrant(ctx, ctx.PathParamInt64("grantId"), oa.OwnerID); err != nil { + ctx.ServerError("RevokeOAuth2Grant", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) + ctx.JSONRedirect(oa.BasePathList) +} diff --git a/routers/web/web.go b/routers/web/web.go index 6b649dc1f56b2..abe2c82865d49 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -575,6 +575,13 @@ func registerWebRoutes(m *web.Router) { m.Methods("POST, OPTIONS", "/introspect", optionsCorsHandler(), web.Bind(forms.IntrospectTokenForm{}), optSignInIgnoreCsrf, auth.IntrospectOAuth) }, oauth2Enabled) + m.Post("/login/device/code", oauth2Enabled, optionsCorsHandler(), web.Bind(forms.AuthorizationDeviceForm{}), optSignInIgnoreCsrf, auth.AuthorizeDeviceOAuth) + m.Group("/login/device", func() { + m.Get("", auth.AuthorizeOAuthDevice) + m.Post("", web.Bind(forms.Oauth2DeviceActivationForm{}), auth.AuthorizeOAuthDevicePost) + m.Post("/confirmation", web.Bind(forms.Oauth2DeviceConfirmationForm{}), auth.AuthorizeOAuthDeviceConfirm) + }, reqSignIn, oauth2Enabled) + m.Group("/user/settings", func() { m.Get("", user_setting.Profile) m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost) @@ -630,6 +637,7 @@ func registerWebRoutes(m *web.Router) { m.Post("", web.Bind(forms.EditOAuth2ApplicationForm{}), user_setting.OAuthApplicationsPost) m.Post("/{id}/delete", user_setting.DeleteOAuth2Application) m.Post("/{id}/revoke/{grantId}", user_setting.RevokeOAuth2Grant) + m.Post("/{id}/revoke_device/{grantId}", user_setting.RevokeOAuth2DeviceGrant) }, oauth2Enabled) // access token applications diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 7df6f4638ea8e..8e7f0936f9c16 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -43,8 +43,13 @@ func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) log.Trace("oauth2.ParseToken: %v", err) return accessTokenScope, 0 } - var grant *auth_model.OAuth2Grant - if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil { + var grant auth_model.IOAuth2Grant + if token.GrantID < 0 { + grant, err = auth_model.GetOAuth2DeviceGrantByID(ctx, -token.GrantID) + } else { + grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID) + } + if err != nil || grant == nil { return accessTokenScope, 0 } if token.Kind != oauth2_provider.KindAccessToken { @@ -53,8 +58,8 @@ func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) { return accessTokenScope, 0 } - accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope) - return accessTokenScope, grant.UserID + accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.GetScope()) + return accessTokenScope, grant.GetUserID() } // CheckTaskIsRunning verifies that the TaskID corresponds to a running task diff --git a/services/forms/user_form.go b/services/forms/user_form.go index ddf2bd09b0bb6..b5660ef4c7a5e 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -180,6 +180,7 @@ type AccessTokenForm struct { RedirectURI string `json:"redirect_uri"` Code string `json:"code"` RefreshToken string `json:"refresh_token"` + DeviceCode string `json:"device_code"` // PKCE support CodeVerifier string `json:"code_verifier"` @@ -191,6 +192,38 @@ func (f *AccessTokenForm) Validate(req *http.Request, errs binding.Errors) bindi return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +type AuthorizationDeviceForm struct { + ClientID string `json:"client_id" binding:"Required"` + Scope string +} + +// Validate validates the fields +func (f *AuthorizationDeviceForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + +type Oauth2DeviceActivationForm struct { + UserCode string `json:"user_code" binding:"Required"` +} + +// Validate validates the fields +func (f *Oauth2DeviceActivationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + +type Oauth2DeviceConfirmationForm struct { + DeviceID int64 `json:"device_id" binding:"Required"` + Granted bool +} + +// Validate validates the fields +func (f *Oauth2DeviceConfirmationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // IntrospectTokenForm for introspecting tokens type IntrospectTokenForm struct { Token string `json:"token"` @@ -362,6 +395,7 @@ type EditOAuth2ApplicationForm struct { RedirectURIs string `binding:"Required;ValidUrlList" form:"redirect_uris"` ConfidentialClient bool `form:"confidential_client"` SkipSecondaryAuthorization bool `form:"skip_secondary_authorization"` + EnableDeviceFlow bool `form:"enable_device_flow"` } // Validate validates the fields diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go index dce4ac765b7a6..3f66e6cc21b2f 100644 --- a/services/oauth2_provider/access_token.go +++ b/services/oauth2_provider/access_token.go @@ -40,6 +40,14 @@ const ( AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type" // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749 AccessTokenErrorCodeInvalidScope = "invalid_scope" + // AccessTokenErrorCodeAuthorizationPending represents an error code specified in RFC 8628 + AccessTokenErrorCodeAuthorizationPending = "authorization_pending" + // AccessTokenErrorCodeAccessDenied represents an error code specified in RFC 8628 + AccessTokenErrorAccessDenied = "access_denied" + // AccessTokenErrorCodeExpiredToken represents an error code specified in RFC 8628 + AccessTokenErrorExpiredToken = "expired_token" + // AccessTokenErrorCodeDeviceFlowDisabled represents an error code like github + AccessTokenErrorDeviceFlowDisabled = "device_flow_disabled" ) // AccessTokenError represents an error response specified in RFC 6749 @@ -120,7 +128,7 @@ func NewJwtRegisteredClaimsFromUser(clientID string, grantUserID int64, exp *jwt } } -func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) { +func NewAccessTokenResponse(ctx context.Context, grant auth.IOAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) { if setting.OAuth2.InvalidateRefreshTokens { if err := grant.IncreaseCounter(ctx); err != nil { return nil, &AccessTokenError{ @@ -132,7 +140,7 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server // generate access token to access the API expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime) accessToken := &Token{ - GrantID: grant.ID, + GrantID: grant.GetID(), Kind: KindAccessToken, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), @@ -149,8 +157,8 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server // generate refresh token to request an access token after it expired later refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime() refreshToken := &Token{ - GrantID: grant.ID, - Counter: grant.Counter, + GrantID: grant.GetID(), + Counter: grant.GetCounter(), Kind: KindRefreshToken, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(refreshExpirationDate), @@ -167,14 +175,14 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server // generate OpenID Connect id_token signedIDToken := "" if grant.ScopeContains("openid") { - app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.GetApplicationID()) if err != nil { return nil, &AccessTokenError{ ErrorCode: AccessTokenErrorCodeInvalidRequest, ErrorDescription: "cannot find application", } } - user, err := user_model.GetUserByID(ctx, grant.UserID) + user, err := user_model.GetUserByID(ctx, grant.GetUserID()) if err != nil { if user_model.IsErrUserNotExist(err) { return nil, &AccessTokenError{ @@ -190,8 +198,8 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server } idToken := &OIDCToken{ - RegisteredClaims: NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, jwt.NewNumericDate(expirationDate.AsTime())), - Nonce: grant.Nonce, + RegisteredClaims: NewJwtRegisteredClaimsFromUser(app.ClientID, grant.GetUserID(), jwt.NewNumericDate(expirationDate.AsTime())), + Nonce: grant.GetNonce(), } if grant.ScopeContains("profile") { idToken.Name = user.DisplayName() @@ -207,7 +215,7 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server idToken.EmailVerified = user.IsActive } if grant.ScopeContains("groups") { - accessTokenScope := GrantAdditionalScopes(grant.Scope) + accessTokenScope := GrantAdditionalScopes(grant.GetScope()) // since version 1.22 does not verify if groups should be public-only, // onlyPublicGroups will be set only if 'public-only' is included in a valid scope @@ -268,3 +276,13 @@ func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPubli } return groups, nil } + +// DeviceAuthorizationResponse represents the response for the device authorization grant. +// https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 +type DeviceAuthorizationResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} diff --git a/templates/user/auth/device.tmpl b/templates/user/auth/device.tmpl new file mode 100644 index 0000000000000..0f23942292a5e --- /dev/null +++ b/templates/user/auth/device.tmpl @@ -0,0 +1,20 @@ +{{template "base/head" .}} +
+
+ {{template "base/alert" .}} +

+ {{ctx.Locale.Tr "auth.authorize_device_title"}} +

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+ +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/user/auth/device_confirm.tmpl b/templates/user/auth/device_confirm.tmpl new file mode 100644 index 0000000000000..acaa694774ada --- /dev/null +++ b/templates/user/auth/device_confirm.tmpl @@ -0,0 +1,28 @@ +{{template "base/head" .}} +
+
+ {{template "base/alert" .}} +

+ {{ctx.Locale.Tr "auth.authorize_device_title"}} +

+
+ {{template "base/alert" .}} +

+ {{if not .AdditionalScopes}} + {{ctx.Locale.Tr "auth.authorize_application_description"}}
+ {{end}} + {{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
+ {{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "%s" .Scope)}} +

+
+
+
+ {{.CsrfTokenHtml}} + + + +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/user/settings/applications_oauth2_edit_form.tmpl b/templates/user/settings/applications_oauth2_edit_form.tmpl index 944729117cb09..20f6bacd56988 100644 --- a/templates/user/settings/applications_oauth2_edit_form.tmpl +++ b/templates/user/settings/applications_oauth2_edit_form.tmpl @@ -53,6 +53,12 @@ +
+
+ + +
+
diff --git a/templates/user/settings/applications_oauth2_list.tmpl b/templates/user/settings/applications_oauth2_list.tmpl index 418d8e9cfc1ac..3ff80c5e04497 100644 --- a/templates/user/settings/applications_oauth2_list.tmpl +++ b/templates/user/settings/applications_oauth2_list.tmpl @@ -72,6 +72,12 @@ +
+
+ + +
+
diff --git a/templates/user/settings/grants_oauth2.tmpl b/templates/user/settings/grants_oauth2.tmpl index 3f0f79c2f3814..427cac767153b 100644 --- a/templates/user/settings/grants_oauth2.tmpl +++ b/templates/user/settings/grants_oauth2.tmpl @@ -2,6 +2,7 @@ {{ctx.Locale.Tr "settings.authorized_oauth2_applications"}}
+ {{if gt (len .Grants) 0}}
{{ctx.Locale.Tr "settings.authorized_oauth2_applications_description"}} @@ -26,6 +27,34 @@
{{end}}
+ {{end}} + + {{if gt (len .DeviceGrants) 0}} +
+
+ {{ctx.Locale.Tr "settings.authorized_oauth2_device_description"}} +
+ {{range .DeviceGrants}} +
+
+ {{svg "octicon-key" 32}} +
+
+
{{.Application.Name}} ({{ctx.Locale.Tr "settings.authorized_oauth2_user_code" .UserCode}})
+
+ {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} +
+
+
+ +
+
+ {{end}} +
+ {{end}}