Refactor services/lfs: Change token code to use SigningKey

This now also enables use of token algorithms other than HS256.

In this case, signing key initialization also happens during settings
initialization, because LFS is also used in CLI commands.
This commit is contained in:
Nils Goroll 2026-01-24 11:55:21 +01:00
parent fd8249acc6
commit a8a2d7e8f6
No known key found for this signature in database
GPG key ID: 1DCD8F57A3868BD7
7 changed files with 70 additions and 25 deletions

View file

@ -290,10 +290,9 @@ func runServ(ctx context.Context, c *cli.Command) error {
Op: lfsVerb,
UserID: results.UserID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes)
tokenString, err := setting.LFS.SigningKey.JWT(claims)
if err != nil {
return fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err)
}

View file

@ -313,6 +313,9 @@ RUN_USER = ; git
;LFS_START_SERVER = false
;;
;;
;; see JWT_* under [oauth2]
;LFS_JWT_SIGNING_ALGORITHM = HS256
;LFS_JWT_SIGNING_PRIVATE_KEY_FILE = jwt/lfs_private.pem
;; LFS authentication secret, change this yourself
;LFS_JWT_SECRET =
;;
@ -544,6 +547,7 @@ ENABLED = true
;; Private key file path used to sign OAuth2 tokens. The path is relative to APP_DATA_PATH.
;; This setting is only needed if JWT_SIGNING_ALGORITHM is set to RS256, RS384, RS512, ES256, ES384 or ES512.
;; The file must contain a RSA or ECDSA private key in the PKCS8 format. If no key exists a 4096 bit key will be created for you.
;; XXX jwt/ is a misnomer, it should rather be oauth2/, because we use many JWTs
;JWT_SIGNING_PRIVATE_KEY_FILE = jwt/private.pem
;;
;; OAuth2 authentication secret for access and refresh tokens, change this yourself to a unique string. CLI generate option is helpful in this case. https://forgejo.org/docs/latest/admin/command-line/#generate-secret

View file

@ -7,7 +7,7 @@ import (
"fmt"
"time"
"forgejo.org/modules/generate"
"forgejo.org/modules/jwtx"
)
// LFS represents the server-side configuration for Git LFS.
@ -16,13 +16,13 @@ import (
// Could be refactored in the future while keeping backwards compatibility.
var LFS = struct {
StartServer bool `ini:"LFS_START_SERVER"`
JWTSecretBytes []byte `ini:"-"`
HTTPAuthExpiry time.Duration `ini:"LFS_HTTP_AUTH_EXPIRY"`
MaxFileSize int64 `ini:"LFS_MAX_FILE_SIZE"`
LocksPagingNum int `ini:"LFS_LOCKS_PAGING_NUM"`
MaxBatchSize int `ini:"LFS_MAX_BATCH_SIZE"`
Storage *Storage
SigningKey jwtx.SigningKey
Storage *Storage
}{}
// LFSClient represents configuration for Gitea's LFS clients, for example: mirroring upstream Git LFS
@ -77,20 +77,14 @@ func loadLFSFrom(rootCfg ConfigProvider) error {
return nil
}
jwtSecretBase64 := loadSecret(rootCfg.Section("server"), "LFS_JWT_SECRET_URI", "LFS_JWT_SECRET")
LFS.JWTSecretBytes, err = generate.DecodeJwtSecret(jwtSecretBase64)
if err != nil {
LFS.JWTSecretBytes, jwtSecretBase64 = generate.NewJwtSecret()
// Save secret
saveCfg, err := rootCfg.PrepareSaving()
if err != nil {
return fmt.Errorf("error saving JWT Secret for custom config: %v", err)
// XXX #11024 check nil because settings loaded twice
if LFS.SigningKey == nil {
keyCfg, err := loadKeyCfg(rootCfg, "server", "LFS_JWT_", "HS256", "lfs/private.pem")
if err == nil {
LFS.SigningKey, err = jwtx.InitSigningKey(&keyCfg.Signing)
}
rootCfg.Section("server").Key("LFS_JWT_SECRET").SetValue(jwtSecretBase64)
saveCfg.Section("server").Key("LFS_JWT_SECRET").SetValue(jwtSecretBase64)
if err := saveCfg.Save(); err != nil {
return fmt.Errorf("error saving JWT Secret for custom config: %v", err)
if err != nil {
return fmt.Errorf("lfs key initialization failed: %v", err)
}
}

View file

@ -164,6 +164,7 @@ func initLFS() (err error) {
LFS = DiscardStorage("LFS isn't enabled")
return nil
}
log.Info("Initialising LFS storage with type: %s", setting.LFS.Storage.Type)
LFS, err = NewStorage(setting.LFS.Storage.Type, setting.LFS.Storage)
return err

View file

@ -581,10 +581,11 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) {
token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
k := setting.LFS.SigningKey
if t.Method != k.SigningMethod() {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return setting.LFS.JWTSecretBytes, nil
return k.VerifyKey(), nil
})
if err != nil {
return nil, errors.New("invalid token")

View file

@ -41,18 +41,23 @@ func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
Op: opts.Op,
UserID: opts.UserID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes)
tokenString, err := setting.LFS.SigningKey.JWT(claims)
if err != nil {
return "", fmt.Errorf("failed to sign LFS JWT token: %w", err)
}
return "Bearer " + tokenString, nil
}
func TestAuthenticate(t *testing.T) {
func testAuthenticate(t *testing.T, cfg string) {
require.NoError(t, unittest.PrepareTestDatabase())
var err error
setting.CfgProvider, err = setting.NewConfigProviderFromData(cfg)
require.NoError(t, err, "Config")
setting.LoadCommonSettings()
assert.True(t, setting.LFS.StartServer, "LFS_START_SERVER = true")
assert.NotNil(t, setting.LFS.SigningKey, "SigningKey initialized")
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
token2, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
@ -80,3 +85,30 @@ func TestAuthenticate(t *testing.T) {
assert.True(t, authenticate(ctx, repo1, prefixBearer+token2, true, false))
})
}
type namedCfg struct {
name, cfg string
}
var iniCommon = `[security]
INSTALL_LOCK = true
INTERNAL_TOKEN = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
[oauth2]
JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
[server]
LFS_START_SERVER = true
`
var cfgVariants = []namedCfg{
{name: "HS256_default", cfg: `LFS_JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_`},
{name: "RS256", cfg: `LFS_JWT_SIGNING_ALGORITHM = RS256`},
}
func TestAuthenticate(t *testing.T) {
// XXX #11024
setting.InstallLock = true
for _, v := range cfgVariants {
cfg := iniCommon + v.cfg
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
}
}

View file

@ -21,11 +21,25 @@ import (
"github.com/stretchr/testify/require"
)
var ini = `[security]
INSTALL_LOCK = true
INTERNAL_TOKEN = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
[oauth2]
JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
[server]
LFS_START_SERVER = true
LFS_JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
`
func TestGarbageCollectLFSMetaObjects(t *testing.T) {
var err error
setting.CfgProvider, err = setting.NewConfigProviderFromData(ini)
require.NoError(t, err, "Config")
setting.LoadCommonSettings()
unittest.PrepareTestEnv(t)
setting.LFS.StartServer = true
err := storage.Init()
err = storage.Init()
require.NoError(t, err)
repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "lfs")