refactor: extract LFS JWT authentication into an authentication method

This commit is contained in:
Mathieu Fenniak 2026-05-24 14:31:38 -06:00
parent c8d24ff06a
commit 3328d7a69b
No known key found for this signature in database
15 changed files with 322 additions and 312 deletions

View file

@ -23,13 +23,13 @@ import (
"forgejo.org/models/perm"
"forgejo.org/modules/git"
"forgejo.org/modules/json"
"forgejo.org/modules/lfs"
"forgejo.org/modules/log"
"forgejo.org/modules/pprof"
"forgejo.org/modules/private"
"forgejo.org/modules/process"
repo_module "forgejo.org/modules/repository"
"forgejo.org/modules/setting"
"forgejo.org/services/lfs"
"github.com/golang-jwt/jwt/v5"
"github.com/kballard/go-shellquote"

View file

@ -9,6 +9,7 @@ import (
"time"
"forgejo.org/modules/util"
"github.com/golang-jwt/jwt/v5"
)
const (
@ -117,3 +118,11 @@ type ErrorResponse struct {
DocumentationURL string `json:"documentation_url,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
// Claims is a JWT Token Claims
type Claims struct {
RepoID int64
Op string
UserID int64
jwt.RegisteredClaims
}

View file

@ -105,11 +105,15 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
// earlier authentication success would prevent later authentication methods from being attempted.
func buildAuthGroup() *auth_method.Group {
group := auth_method.NewGroup()
group.Add(&auth_method.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers
group.Add(&auth_method.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers
// FIXME: extracted from OAuth2 & Basic -- these methods have internal URL filters that should be moved into
// middlewares (if we can figure out the right way to do that), similar to the notes on OAuth2 & Basic above.
// FIXME: OAuth2, Basic, AccessToken, ActionRuntimeToken, ActionTaskToken authentication methods all use path
// matching so that they only authenticate requests in specific endpoints -- in general these auth methods aren't
// permitted for web endpoints, and aren't supported because the security models don't match. (For example, they
// can have scoped permissions, which web UI handlers don't implement.) A clearer way to do this would be to apply
// specific authentication methods to their auth middlewares. buildGitLfsAuthGroup and buildGitAuthGroup are the
// start of transitioning to that model. Eventually these methods will be fully removed from buildAuthGroup:
group.Add(&auth_method.OAuth2{})
group.Add(&auth_method.Basic{})
group.Add(&auth_method.AccessToken{})
group.Add(&auth_method.ActionRuntimeToken{})
group.Add(&auth_method.ActionTaskToken{})
@ -122,8 +126,19 @@ func buildAuthGroup() *auth_method.Group {
return group
}
// Authentication methods that are applied to Git HTTP & Git LFS HTTP routes. They are processed in the order defined;
// an earlier authentication success would prevent later authentication methods from being attempted.
// Authentication methods that are applied to Git LFS HTTP routes. They are processed in the order defined; an earlier
// authentication success would prevent later authentication methods from being attempted.
func buildGitLfsAuthGroup() *auth_method.Group {
group := auth_method.NewGroup()
group.Add(&auth_method.LFSToken{})
group.Add(&auth_method.Basic{})
group.Add(&auth_method.AccessToken{})
group.Add(&auth_method.ActionTaskToken{})
return group
}
// Authentication methods that are applied to Git HTTP routes. They are processed in the order defined; an earlier
// authentication success would prevent later authentication methods from being attempted.
func buildGitAuthGroup() *auth_method.Group {
group := auth_method.NewGroup()
group.Add(&auth_method.OAuth2{})
@ -340,24 +355,10 @@ func Routes() *web.Route {
// TODO: GetNotificationCount & GetActiveStopwatch really seem like things that could be folded into Contexter or as helper functions
user.GetNotificationCount, repo.GetActiveStopwatch,
goGet)
// /{username}/{repo}/info/lfs routes currently need to use buildAuthGroup(), not buildGitAuthGroup(), because:
//
// a) there are tests that use session auth to access the LFS endpoints (TestAPILFSLocksLogged), which may be a test
// error, and
//
// b) if AuthorizedIntegration is included then JWTs generated by the LFS system with `setting.LFS.SigningKey` will
// return AuthenticationAttemptedIncorrectCredential from AuthorizedIntegration, and cause the requests to 401
// before reaching the LFS handlers.
//
// (a) is probably an error that can be fixed, and (b) should be fixed by changing LFS's JWT handling to be an
// `auth_service.Method` implementation which would be incorporated into the auth group, so that LFS isn't doing
// it's own "after the authentication" authentication. In the interm, it's split out from the `registerRoutes` call
// above because at least fewer middlewares can be safely applied.
routes.Group("",
func() {
registerGitLFSRoutes(routes)
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildAuthGroup()), goGet)
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildGitLfsAuthGroup()), goGet)
routes.Group("",
func() {
registerGitRoutes(routes)

View file

@ -0,0 +1,42 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package method
import (
auth_model "forgejo.org/models/auth"
user_model "forgejo.org/models/user"
"forgejo.org/modules/lfs"
"forgejo.org/modules/optional"
"forgejo.org/services/auth"
"forgejo.org/services/authz"
)
var _ auth.AuthenticationResult = &lfsTokenAuthenticationResult{}
type lfsTokenAuthenticationResult struct {
*auth.BaseAuthenticationResult
user *user_model.User
claims *lfs.Claims
}
func (r *lfsTokenAuthenticationResult) User() *user_model.User {
return r.user
}
func (r *lfsTokenAuthenticationResult) Scope() optional.Option[auth_model.AccessTokenScope] {
if r.claims.Op == "download" {
return optional.Some(auth_model.AccessTokenScopeReadRepository)
}
return optional.Some(auth_model.AccessTokenScopeWriteRepository)
}
func (r *lfsTokenAuthenticationResult) Reducer() authz.AuthorizationReducer {
return &authz.SpecificReposAuthorizationReducer{
ResourceRepos: []authz.RepoGetter{r},
}
}
func (r *lfsTokenAuthenticationResult) GetTargetRepoID() int64 {
return r.claims.RepoID
}

View file

@ -0,0 +1,65 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package method
import (
"errors"
"fmt"
"net/http"
user_model "forgejo.org/models/user"
"forgejo.org/modules/lfs"
"forgejo.org/modules/setting"
"forgejo.org/services/auth"
"github.com/golang-jwt/jwt/v5"
)
var _ auth.Method = &LFSToken{}
// LFSToken is an authentication method used when access a git repository over ssh which has LFS resources in-use. The
// LFS client will issue a `git-lfs-authenticate` command over the ssh connection, and Forgejo will provide a
// supplemental HTTP header "Authorization: Bearer ..." with a JWT. The LFS client can then make HTTP requests to LFS
// endpoints with that supplemental header in order to inherit the permissions of the SSH user and to retrieve LFS
// objects.
type LFSToken struct{}
func (a *LFSToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
hasToken, tokenText := tokenFromAuthorizationBearer(req).Get()
if !hasToken {
return &auth.AuthenticationNotAttempted{}
}
token, err := jwt.ParseWithClaims(tokenText, &lfs.Claims{}, func(t *jwt.Token) (any, error) {
k := setting.LFS.SigningKey
if t.Method != k.SigningMethod() {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return k.VerifyKey(), nil
})
if err != nil {
return &auth.AuthenticationAttemptedIncorrectCredential{Error: err}
}
claims, claimsOk := token.Claims.(*lfs.Claims)
if !token.Valid {
return &auth.AuthenticationAttemptedIncorrectCredential{Error: errors.New("not a valid LFS JWT")}
} else if !claimsOk {
return &auth.AuthenticationError{Error: fmt.Errorf("claim object %v was not an lfs.Claims instance", token.Claims)}
}
u, err := user_model.GetUserByID(req.Context(), claims.UserID)
if err != nil {
return &auth.AuthenticationError{Error: fmt.Errorf("unable to load claim user %d: %w", claims.UserID, err)}
}
if !u.IsAccessAllowed(req.Context()) {
return &auth.AuthenticationError{Error: fmt.Errorf("user access is not permitted")}
}
return &auth.AuthenticationSuccess{
Result: &lfsTokenAuthenticationResult{
user: u,
claims: claims,
},
}
}

View file

@ -0,0 +1,160 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package method
import (
"fmt"
"net/http/httptest"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/unittest"
"forgejo.org/modules/lfs"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/services/auth"
"forgejo.org/services/authz"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type authTokenOptions struct {
Op string
UserID int64
RepoID int64
}
func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
now := time.Now()
claims := lfs.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
}
// Sign and get the complete encoded token as a string using the secret
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, cfg string) {
require.NoError(t, unittest.PrepareTestDatabase())
var err error
setting.CfgProvider, err = setting.NewConfigProviderFromData(cfg)
require.NoError(t, err, "Config")
setting.LoadCommonSettings()
t.Run("no bearer token", func(t *testing.T) {
a := &LFSToken{}
req := httptest.NewRequest("GET", "https://example.org", nil)
output := a.Verify(req, nil, nil)
requireOutput[*auth.AuthenticationNotAttempted](t, output)
})
t.Run("bearer not a JWT", func(t *testing.T) {
a := &LFSToken{}
req := httptest.NewRequest("GET", "https://example.org", nil)
req.Header.Set("Authorization", "Bearer abc")
output := a.Verify(req, nil, nil)
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "token is malformed")
})
t.Run("token valid op=download", func(t *testing.T) {
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
require.NoError(t, err)
a := &LFSToken{}
req := httptest.NewRequest("GET", "https://example.org", nil)
req.Header.Set("Authorization", bearerAuth)
output := a.Verify(req, nil, nil)
result := requireOutput[*auth.AuthenticationSuccess](t, output).Result
assert.EqualValues(t, 2, result.User().ID)
assert.Equal(t, optional.Some(auth_model.AccessTokenScopeReadRepository), result.Scope())
require.NotNil(t, result.Reducer())
// No direct way to query an authz.Reducer for its specific repos permitted, so this is a bit of a workaround to
// see if it's targeting the repo from the JWT:
repoGetter, isRepoGetter := result.(authz.RepoGetter)
require.True(t, isRepoGetter)
assert.EqualValues(t, 1, repoGetter.GetTargetRepoID())
})
t.Run("token valid op=upload", func(t *testing.T) {
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 2, RepoID: 24})
require.NoError(t, err)
a := &LFSToken{}
req := httptest.NewRequest("GET", "https://example.org", nil)
req.Header.Set("Authorization", bearerAuth)
output := a.Verify(req, nil, nil)
result := requireOutput[*auth.AuthenticationSuccess](t, output).Result
assert.EqualValues(t, 2, result.User().ID)
assert.Equal(t, optional.Some(auth_model.AccessTokenScopeWriteRepository), result.Scope())
require.NotNil(t, result.Reducer())
// No direct way to query an authz.Reducer for its specific repos permitted, so this is a bit of a workaround to
// see if it's targeting the repo from the JWT:
repoGetter, isRepoGetter := result.(authz.RepoGetter)
require.True(t, isRepoGetter)
assert.EqualValues(t, 24, repoGetter.GetTargetRepoID())
})
t.Run("token signature malformed", func(t *testing.T) {
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
bearerAuth += "malformed"
require.NoError(t, err)
a := &LFSToken{}
req := httptest.NewRequest("GET", "https://example.org", nil)
req.Header.Set("Authorization", bearerAuth)
output := a.Verify(req, nil, nil)
err = requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "token signature is invalid")
})
t.Run("invalid user", func(t *testing.T) {
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 999, RepoID: 1})
require.NoError(t, err)
a := &LFSToken{}
req := httptest.NewRequest("GET", "https://example.org", nil)
req.Header.Set("Authorization", bearerAuth)
output := a.Verify(req, nil, nil)
err = requireOutput[*auth.AuthenticationError](t, output).Error
require.ErrorContains(t, err, "user does not exist")
})
}
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) {
for _, v := range cfgVariants {
cfg := iniCommon + v.cfg
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
}
}

View file

@ -30,7 +30,7 @@ func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_mode
for i, r := range repos {
iface[i] = r
}
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
return &SpecificReposAuthorizationReducer{ResourceRepos: iface}, nil
}
// Validate that an access token's state is valid for creation. For example, that it doesn't have a conflicting set of

View file

@ -41,8 +41,8 @@ func TestGetAuthorizationReducerForAccessToken(t *testing.T) {
require.True(t, ok)
require.NotNil(t, specific)
require.Len(t, specific.resourceRepos, 1)
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
require.Len(t, specific.ResourceRepos, 1)
assert.EqualValues(t, 1, specific.ResourceRepos[0].GetTargetRepoID())
})
}

View file

@ -29,5 +29,5 @@ func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *au
for i, r := range repos {
iface[i] = r
}
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
return &SpecificReposAuthorizationReducer{ResourceRepos: iface}, nil
}

View file

@ -40,7 +40,7 @@ func TestGetAuthorizationReducerForAuthorizedIntegration(t *testing.T) {
require.True(t, ok)
require.NotNil(t, specific)
require.Len(t, specific.resourceRepos, 1)
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
require.Len(t, specific.ResourceRepos, 1)
assert.EqualValues(t, 1, specific.ResourceRepos[0].GetTargetRepoID())
})
}

View file

@ -18,7 +18,7 @@ import (
// repositories that aren't listed among the specific repos, read-only access is permitted. For all other repos, no
// access is permitted.
type SpecificReposAuthorizationReducer struct {
resourceRepos []RepoGetter
ResourceRepos []RepoGetter
}
type RepoGetter interface {
@ -26,7 +26,7 @@ type RepoGetter interface {
}
func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) {
for _, tokenRepo := range r.resourceRepos {
for _, tokenRepo := range r.ResourceRepos {
if tokenRepo.GetTargetRepoID() == repo.ID {
// No restrictions as this repo is within the scope of the access token.
return accessMode, nil
@ -48,8 +48,8 @@ func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context
}
func (r *SpecificReposAuthorizationReducer) RepoReadAccessFilter() builder.Cond {
repoIDs := make([]int64, len(r.resourceRepos))
for i, tokenRepo := range r.resourceRepos {
repoIDs := make([]int64, len(r.ResourceRepos))
for i, tokenRepo := range r.ResourceRepos {
repoIDs[i] = tokenRepo.GetTargetRepoID()
}
targetRepos := builder.In("repository.id", repoIDs)

View file

@ -20,7 +20,7 @@ func TestSpecificReposAuthorizationReducer(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
reducer := &SpecificReposAuthorizationReducer{
resourceRepos: []RepoGetter{
ResourceRepos: []RepoGetter{
&auth.AccessTokenResourceRepo{
RepoID: 1,
},

View file

@ -64,7 +64,7 @@ func GetListLockHandler(ctx *context.Context) {
return
}
authenticated := authenticate(ctx, repository, rv.Authorization, true, false)
authenticated := authenticate(ctx, repository, true, false)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
@ -135,7 +135,6 @@ func GetListLockHandler(ctx *context.Context) {
func PostLockHandler(ctx *context.Context) {
userName := ctx.Params("username")
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
authorization := ctx.Req.Header.Get("Authorization")
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
if err != nil {
@ -153,7 +152,7 @@ func PostLockHandler(ctx *context.Context) {
return
}
authenticated := authenticate(ctx, repository, authorization, true, true)
authenticated := authenticate(ctx, repository, true, true)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
@ -207,7 +206,6 @@ func PostLockHandler(ctx *context.Context) {
func VerifyLockHandler(ctx *context.Context) {
userName := ctx.Params("username")
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
authorization := ctx.Req.Header.Get("Authorization")
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
if err != nil {
@ -225,7 +223,7 @@ func VerifyLockHandler(ctx *context.Context) {
return
}
authenticated := authenticate(ctx, repository, authorization, true, true)
authenticated := authenticate(ctx, repository, true, true)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
@ -275,7 +273,6 @@ func VerifyLockHandler(ctx *context.Context) {
func UnLockHandler(ctx *context.Context) {
userName := ctx.Params("username")
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
authorization := ctx.Req.Header.Get("Authorization")
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
if err != nil {
@ -293,7 +290,7 @@ func UnLockHandler(ctx *context.Context) {
return
}
authenticated := authenticate(ctx, repository, authorization, true, true)
authenticated := authenticate(ctx, repository, true, true)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{

View file

@ -4,7 +4,6 @@
package lfs
import (
stdCtx "context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
@ -27,15 +26,12 @@ import (
quota_model "forgejo.org/models/quota"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unit"
user_model "forgejo.org/models/user"
"forgejo.org/modules/json"
lfs_module "forgejo.org/modules/lfs"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/services/context"
"github.com/golang-jwt/jwt/v5"
)
// requestContext contain variables from the HTTP request.
@ -45,14 +41,6 @@ type requestContext struct {
Authorization string
}
// Claims is a JWT Token Claims
type Claims struct {
RepoID int64
Op string
UserID int64
jwt.RegisteredClaims
}
// DownloadLink builds a URL to download the object.
func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string {
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid))
@ -452,7 +440,7 @@ func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requir
return nil
}
if !authenticate(ctx, repository, rc.Authorization, false, requireWrite) {
if !authenticate(ctx, repository, false, requireWrite) {
requireAuth(ctx)
return nil
}
@ -533,7 +521,7 @@ func writeStatusMessage(ctx *context.Context, status int, message string) {
// authenticate uses the authorization string to determine whether
// or not to proceed. This server assumes an HTTP Basic auth format.
func authenticate(ctx *context.Context, repository *repo_model.Repository, authorization string, requireSigned, requireWrite bool) bool {
func authenticate(ctx *context.Context, repository *repo_model.Repository, requireSigned, requireWrite bool) bool {
accessMode := perm.AccessModeRead
if requireWrite {
accessMode = perm.AccessModeWrite
@ -555,92 +543,21 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
return accessMode <= perm.AccessModeWrite
}
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess
perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess. Usage of ctx.Authentication.Reducer()
// or .Scope() is also unnecessary here in `authenticate` -- context.CheckRepoScopedToken is used after
// authentication to provide authorization checks.
repoPerm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
if err != nil {
log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.Doer, repository, err)
return false
}
canRead := perm.CanAccess(accessMode, unit.TypeCode)
canRead := repoPerm.CanAccess(accessMode, unit.TypeCode)
if canRead && (!requireSigned || ctx.IsSigned) {
return true
}
user, err := parseToken(ctx, authorization, repository, accessMode)
if err != nil {
// Most of these are Warn level - the true internal server errors are logged in parseToken already
log.Warn("Authentication failure for provided token with Error: %v", err)
return false
}
ctx.Doer = user
return true
}
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) {
k := setting.LFS.SigningKey
if t.Method != k.SigningMethod() {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return k.VerifyKey(), nil
})
if err != nil {
return nil, errors.New("invalid token")
}
claims, claimsOk := token.Claims.(*Claims)
if !token.Valid || !claimsOk {
return nil, errors.New("invalid token claim")
}
if claims.RepoID != target.ID {
return nil, errors.New("invalid token claim")
}
if mode == perm.AccessModeWrite && claims.Op != "upload" {
return nil, errors.New("invalid token claim")
}
u, err := user_model.GetUserByID(ctx, claims.UserID)
if err != nil {
log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
return nil, err
}
if !u.IsAccessAllowed(ctx) {
return nil, errors.New("user access is blocked")
}
repoPerm, err := access_model.GetUserRepoPermission(ctx, target, u)
if err != nil {
log.Error("Unable to GetUserRepoPermission[%d]: Error: %v", claims.UserID, err)
return nil, err
}
if !repoPerm.CanAccess(mode, unit.TypeCode) {
return nil, errors.New("user does not have access to the repository")
}
return u, nil
}
func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) {
if authorization == "" {
return nil, errors.New("no token")
}
parts := strings.SplitN(authorization, " ", 2)
if len(parts) != 2 {
return nil, errors.New("no token")
}
tokenSHA := parts[1]
switch strings.ToLower(parts[0]) {
case "bearer":
fallthrough
case "token":
return handleLFSToken(ctx, tokenSHA, target, mode)
}
return nil, errors.New("token not found")
return false
}
func requireAuth(ctx *context.Context) {

View file

@ -1,181 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"fmt"
"strings"
"testing"
"time"
perm_model "forgejo.org/models/perm"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/modules/setting"
"forgejo.org/services/contexttest"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
type authTokenOptions struct {
Op string
UserID int64
RepoID int64
}
func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
}
// Sign and get the complete encoded token as a string using the secret
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, 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})
_, token2, _ = strings.Cut(token2, " ")
ctx, _ := contexttest.MockContext(t, "/")
t.Run("handleLFSToken", func(t *testing.T) {
u, err := handleLFSToken(ctx, "", repo1, perm_model.AccessModeRead)
require.Error(t, err)
assert.Nil(t, u)
u, err = handleLFSToken(ctx, "invalid", repo1, perm_model.AccessModeRead)
require.Error(t, err)
assert.Nil(t, u)
u, err = handleLFSToken(ctx, token2, repo1, perm_model.AccessModeRead)
require.NoError(t, err)
assert.EqualValues(t, 2, u.ID)
})
t.Run("handleLFSToken nonexistent user", func(t *testing.T) {
tokenMissing, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 999, RepoID: 1})
_, tokenMissing, _ = strings.Cut(tokenMissing, " ")
u, err := handleLFSToken(ctx, tokenMissing, repo1, perm_model.AccessModeRead)
require.Error(t, err)
assert.Contains(t, err.Error(), "user does not exist")
assert.Nil(t, u)
})
t.Run("handleLFSToken nonexistent repo", func(t *testing.T) {
tokenBadRepo, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 999})
_, tokenBadRepo, _ = strings.Cut(tokenBadRepo, " ")
badRepo := &repo_model.Repository{ID: 999}
u, err := handleLFSToken(ctx, tokenBadRepo, badRepo, perm_model.AccessModeRead)
require.Error(t, err)
assert.Nil(t, u)
})
t.Run("handleLFSToken blocked user", func(t *testing.T) {
tokenBlocked, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 37, RepoID: 1})
_, tokenBlocked, _ = strings.Cut(tokenBlocked, " ")
u, err := handleLFSToken(ctx, tokenBlocked, repo1, perm_model.AccessModeRead)
require.Error(t, err)
assert.Contains(t, err.Error(), "user access is blocked")
assert.Nil(t, u)
})
t.Run("handleLFSToken no repo access", func(t *testing.T) {
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
tokenNoAccess, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 10, RepoID: 2})
_, tokenNoAccess, _ = strings.Cut(tokenNoAccess, " ")
u, err := handleLFSToken(ctx, tokenNoAccess, repo2, perm_model.AccessModeRead)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not have access to the repository")
assert.Nil(t, u)
})
t.Run("handleLFSToken upload write access allowed", func(t *testing.T) {
tokenUploadRW, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 2, RepoID: 1})
_, tokenUploadRW, _ = strings.Cut(tokenUploadRW, " ")
u, err := handleLFSToken(ctx, tokenUploadRW, repo1, perm_model.AccessModeWrite)
require.NoError(t, err)
assert.EqualValues(t, 2, u.ID)
})
t.Run("handleLFSToken upload read-only access denied", func(t *testing.T) {
tokenUploadRO, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 10, RepoID: 1})
_, tokenUploadRO, _ = strings.Cut(tokenUploadRO, " ")
u, err := handleLFSToken(ctx, tokenUploadRO, repo1, perm_model.AccessModeWrite)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not have access to the repository")
assert.Nil(t, u)
})
t.Run("handleLFSToken download read-only access allowed", func(t *testing.T) {
tokenDownloadRO, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 10, RepoID: 1})
_, tokenDownloadRO, _ = strings.Cut(tokenDownloadRO, " ")
u, err := handleLFSToken(ctx, tokenDownloadRO, repo1, perm_model.AccessModeRead)
require.NoError(t, err)
assert.EqualValues(t, 10, u.ID)
})
t.Run("authenticate", func(t *testing.T) {
const prefixBearer = "Bearer "
assert.False(t, authenticate(ctx, repo1, "", true, false))
assert.False(t, authenticate(ctx, repo1, prefixBearer+"invalid", true, false))
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) {
for _, v := range cfgVariants {
cfg := iniCommon + v.cfg
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
}
}