feat: enable auth to git LFS via authorized integrations (#12725)

The goal is to enable access to Git LFS resources with Authorized Integrations JWTs.

Blocker that needed to be resolved is that adding the `AuthorizedIntegration` auth method would conflict with the LFS tokens, which are handed out during git ssh clones to allow access to LFS resources -- `AuthorizedIntegration` would mark these as `AuthenticationAttemptedIncorrectCredential`, and therefore the requests would 401 before they got to the LFS-specific token validation routines.  The fix is to move LFS token authentication into an authentication group so that it could be resolved at the same time as the authorized integration, rather than doing it inside the LFS server routines.

Refactors for LFS tokens are covered by refreshed test automation.  Authorized integrations LFS Access has been manually tested, and will be further covered in an end-to-end integration test (https://code.forgejo.org/forgejo/end-to-end/pulls/1954).

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [ ] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12725
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-05-28 23:20:58 +02:00 committed by Mathieu Fenniak
commit cdd35458a9
18 changed files with 494 additions and 351 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,8 @@ import (
"time"
"forgejo.org/modules/util"
"github.com/golang-jwt/jwt/v5"
)
const (
@ -117,3 +119,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,24 @@ 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{})
group.Add(&auth_method.AuthorizedIntegration{
// "Authorization: Basic ..." is easier to use for git operations, and already supported for other tokens, so it
// is enabled for Authorized Integrations as well:
PermitBasic: true,
})
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 +360,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,66 @@
// 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))
@ -454,7 +442,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
}
@ -535,7 +523,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
@ -557,92 +545,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) })
}
}

View file

@ -103,11 +103,11 @@ func TestAPILFSLocksLogged(t *testing.T) {
// create locks
for _, test := range tests {
session := loginUser(t, test.user.Name)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", test.repo.FullName()), map[string]string{"path": test.path})
req.AddBasicAuth(test.user.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
resp := session.MakeRequest(t, req, test.httpResult)
resp := MakeRequest(t, req, test.httpResult)
if len(test.addTime) > 0 {
var lfsLock api.LFSLockResponse
DecodeJSON(t, resp, &lfsLock)
@ -121,10 +121,10 @@ func TestAPILFSLocksLogged(t *testing.T) {
// check creation
for _, test := range resultsTests {
session := loginUser(t, test.user.Name)
req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName())
req.AddBasicAuth(test.user.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
var lfsLocks api.LFSLockList
DecodeJSON(t, resp, &lfsLocks)
assert.Len(t, lfsLocks.Locks, test.totalCount)
@ -135,9 +135,10 @@ func TestAPILFSLocksLogged(t *testing.T) {
}
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/verify", test.repo.FullName()), map[string]string{})
req.AddBasicAuth(test.user.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
resp = session.MakeRequest(t, req, http.StatusOK)
resp = MakeRequest(t, req, http.StatusOK)
var lfsLocksVerify api.LFSLockListVerify
DecodeJSON(t, resp, &lfsLocksVerify)
assert.Len(t, lfsLocksVerify.Ours, test.oursCount)
@ -157,11 +158,11 @@ func TestAPILFSLocksLogged(t *testing.T) {
// remove all locks
for _, test := range deleteTests {
session := loginUser(t, test.user.Name)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", test.repo.FullName(), test.lockID), map[string]string{})
req.AddBasicAuth(test.user.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
var lfsLockRep api.LFSLockResponse
DecodeJSON(t, resp, &lfsLockRep)
assert.Equal(t, test.lockID, lfsLockRep.Lock.ID)
@ -170,10 +171,10 @@ func TestAPILFSLocksLogged(t *testing.T) {
// check that we don't have any lock
for _, test := range resultsTests {
session := loginUser(t, test.user.Name)
req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName())
req.AddBasicAuth(test.user.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
var lfsLocks api.LFSLockList
DecodeJSON(t, resp, &lfsLocks)
assert.Empty(t, lfsLocks.Locks)

View file

@ -5,6 +5,7 @@ package integration
import (
"bytes"
"fmt"
"net/http"
"path"
"strconv"
@ -83,8 +84,6 @@ func TestAPILFSBatch(t *testing.T) {
oid := storeObjectInRepo(t, repo.ID, &content)
defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
session := loginUser(t, "user2")
newRequest := func(t testing.TB, br *lfs.BatchRequest) *RequestWrapper {
return NewRequestWithJSON(t, "POST", "/user2/lfs-batch-repo.git/info/lfs/objects/batch", br).
SetHeader("Accept", lfs.AcceptHeader).
@ -101,8 +100,9 @@ func TestAPILFSBatch(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, nil)
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusBadRequest)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("InvalidOperation", func(t *testing.T) {
@ -111,8 +111,9 @@ func TestAPILFSBatch(t *testing.T) {
req := newRequest(t, &lfs.BatchRequest{
Operation: "dummy",
})
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusBadRequest)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("InvalidPointer", func(t *testing.T) {
@ -125,8 +126,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: oid, Size: -1},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 2)
assert.Equal(t, "dummy", br.Objects[0].Oid)
@ -150,8 +152,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: oid, Size: 1},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
@ -171,8 +174,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
@ -195,8 +199,9 @@ func TestAPILFSBatch(t *testing.T) {
Operation: "download",
Objects: []lfs.Pointer{p},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
@ -212,8 +217,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: oid, Size: 6},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
@ -237,8 +243,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
@ -268,8 +275,9 @@ func TestAPILFSBatch(t *testing.T) {
Operation: "upload",
Objects: []lfs.Pointer{p},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
@ -293,8 +301,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: oid, Size: 6},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
@ -310,8 +319,9 @@ func TestAPILFSBatch(t *testing.T) {
{Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0153", Size: 1},
},
})
req.AddBasicAuth("user2")
resp := session.MakeRequest(t, req, http.StatusOK)
resp := MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
@ -338,8 +348,6 @@ func TestAPILFSUpload(t *testing.T) {
oid := storeObjectInRepo(t, repo.ID, &content)
defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
session := loginUser(t, "user2")
newRequest := func(t testing.TB, p lfs.Pointer, content string) *RequestWrapper {
return NewRequestWithBody(t, "PUT", path.Join("/user2/lfs-upload-repo.git/info/lfs/objects/", p.Oid, strconv.FormatInt(p.Size, 10)), strings.NewReader(content))
}
@ -348,8 +356,9 @@ func TestAPILFSUpload(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: "dummy"}, "")
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("AlreadyExistsInStore", func(t *testing.T) {
@ -370,13 +379,15 @@ func TestAPILFSUpload(t *testing.T) {
t.Run("InvalidAccess", func(t *testing.T) {
req := newRequest(t, p, "invalid")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
req.AddBasicAuth("user2")
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("ValidAccess", func(t *testing.T) {
req := newRequest(t, p, "dummy5")
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusOK)
meta, err = git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
require.NoError(t, err)
assert.NotNil(t, meta)
@ -391,24 +402,27 @@ func TestAPILFSUpload(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: oid, Size: 6}, "")
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusOK)
})
t.Run("HashMismatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: "2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a", Size: 1}, "a")
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("SizeMismatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", Size: 2}, "a")
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("Success", func(t *testing.T) {
@ -417,8 +431,9 @@ func TestAPILFSUpload(t *testing.T) {
p := lfs.Pointer{Oid: "6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d", Size: 5}
req := newRequest(t, p, "gitea")
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusOK)
contentStore := lfs.NewContentStore()
exist, err := contentStore.Exists(p)
@ -442,8 +457,6 @@ func TestAPILFSVerify(t *testing.T) {
oid := storeObjectInRepo(t, repo.ID, &content)
defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
session := loginUser(t, "user2")
newRequest := func(t testing.TB, p *lfs.Pointer) *RequestWrapper {
return NewRequestWithJSON(t, "POST", "/user2/lfs-verify-repo.git/info/lfs/verify", p).
SetHeader("Accept", lfs.AcceptHeader).
@ -454,31 +467,140 @@ func TestAPILFSVerify(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, nil)
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("InvalidPointer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.Pointer{})
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("PointerNotExisting", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6})
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("Success", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.Pointer{Oid: oid, Size: 6})
req.AddBasicAuth("user2")
session.MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusOK)
})
}
// Accessing git LFS resources uses CheckRepoScopedToken to validate a PAT; here we run that through all variations of
// access token resource access to ensure it is accurately applied for LFS access.
func TestAPILFSScopeAndResources(t *testing.T) {
defer tests.PrepareTestEnv(t)()
writeOperation := func(t *testing.T, repoFullName, token string, expectedStatus int) {
oid := "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4"
batch := &lfs.BatchRequest{
Operation: "upload",
Objects: []lfs.Pointer{
{Oid: oid, Size: 6},
},
}
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/objects/batch", repoFullName), batch).
SetHeader("Accept", lfs.AcceptHeader).
SetHeader("Content-Type", lfs.MediaType)
req.Request.SetBasicAuth("any", token)
MakeRequest(t, req, expectedStatus)
}
readOperation := func(t *testing.T, repoFullName, token string, expectedStatus int) {
oid := "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4"
batch := &lfs.BatchRequest{
Operation: "download",
Objects: []lfs.Pointer{
{Oid: oid, Size: 6},
},
}
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/objects/batch", repoFullName), batch).
SetHeader("Accept", lfs.AcceptHeader).
SetHeader("Content-Type", lfs.MediaType)
req.Request.SetBasicAuth("any", token)
MakeRequest(t, req, expectedStatus)
}
t.Run("read-write access token", func(t *testing.T) {
session := loginUser(t, "user2")
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
t.Run("allowed public repo1", func(t *testing.T) {
readOperation(t, "user2/repo1", allToken, http.StatusOK)
writeOperation(t, "user2/repo1", allToken, http.StatusOK)
})
t.Run("allowed private repo2", func(t *testing.T) {
readOperation(t, "user2/repo2", allToken, http.StatusOK)
writeOperation(t, "user2/repo2", allToken, http.StatusOK)
})
// repo16 is a second repo used in fine-grain testing below, so we include it in other tests as a baseline
t.Run("allowed private repo16", func(t *testing.T) {
readOperation(t, "user2/repo16", allToken, http.StatusOK)
writeOperation(t, "user2/repo16", allToken, http.StatusOK)
})
})
t.Run("read-only access token", func(t *testing.T) {
session := loginUser(t, "user2")
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("allowed public repo1", func(t *testing.T) {
readOperation(t, "user2/repo1", allToken, http.StatusOK)
writeOperation(t, "user2/repo1", allToken, http.StatusForbidden)
})
t.Run("allowed private repo2", func(t *testing.T) {
readOperation(t, "user2/repo2", allToken, http.StatusOK)
writeOperation(t, "user2/repo2", allToken, http.StatusForbidden)
})
// repo16 is a second repo used in fine-grain testing below, so we include it in other tests as a baseline
t.Run("allowed private repo16", func(t *testing.T) {
readOperation(t, "user2/repo16", allToken, http.StatusOK)
writeOperation(t, "user2/repo16", allToken, http.StatusForbidden)
})
})
t.Run("public-only access token", func(t *testing.T) {
session := loginUser(t, "user2")
publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadRepository)
t.Run("allowed public repo1", func(t *testing.T) {
readOperation(t, "user2/repo1", publicOnlyToken, http.StatusOK)
})
t.Run("denied private repo2", func(t *testing.T) {
readOperation(t, "user2/repo2", publicOnlyToken, http.StatusForbidden)
})
t.Run("denied private repo16", func(t *testing.T) {
readOperation(t, "user2/repo16", publicOnlyToken, http.StatusForbidden)
})
})
t.Run("specific repo access token", func(t *testing.T) {
repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2",
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository},
[]int64{2},
)
t.Run("allowed public repo1", func(t *testing.T) {
readOperation(t, "user2/repo1", repo2OnlyToken, http.StatusOK)
})
t.Run("allowed inside fine-grain repo2", func(t *testing.T) {
readOperation(t, "user2/repo2", repo2OnlyToken, http.StatusOK)
})
t.Run("denied private outside fine-grain repo16", func(t *testing.T) {
readOperation(t, "user2/repo16", repo2OnlyToken, http.StatusForbidden)
})
})
}

View file

@ -138,9 +138,10 @@ func TestLFSLockView(t *testing.T) {
lockID := ""
{
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", repo3.FullName()), map[string]string{"path": lockPath})
req.AddBasicAuth(user2.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
resp := session.MakeRequest(t, req, http.StatusCreated)
resp := MakeRequest(t, req, http.StatusCreated)
lockResp := &api.LFSLockResponse{}
DecodeJSON(t, resp, lockResp)
lockID = lockResp.Lock.ID
@ -148,9 +149,10 @@ func TestLFSLockView(t *testing.T) {
defer func() {
// release the lock
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", repo3.FullName(), lockID), map[string]string{})
req.AddBasicAuth(user2.Name)
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
session.MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusOK)
}()
t.Run("owner name", func(t *testing.T) {
@ -161,6 +163,7 @@ func TestLFSLockView(t *testing.T) {
require.NotEqual(t, user2.DisplayName(), repo3.Owner.DisplayName())
req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings/lfs/locks", repo3.FullName()))
req.AddBasicAuth(user2.Name)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).doc