Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
401 changes: 385 additions & 16 deletions models/auth/oauth2.go

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions models/auth/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
55 changes: 55 additions & 0 deletions models/migrations/v1_25/v322.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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))
}
2 changes: 2 additions & 0 deletions modules/setting/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading