feat: add APIContext.Reducer computed from access token

This commit is contained in:
Mathieu Fenniak 2026-02-15 13:06:19 -07:00 committed by Mathieu Fenniak
parent 44c18465b5
commit 635f13a07e
10 changed files with 426 additions and 1 deletions

View file

@ -14,7 +14,6 @@ forgejo.org/models
IsErrMergeDivergingFastForwardOnly
forgejo.org/models/auth
GetRepositoriesAccessibleWithToken
WebAuthnCredentials
forgejo.org/models/db

View file

@ -12,6 +12,7 @@ import (
"forgejo.org/modules/setting"
"forgejo.org/routers/common"
"forgejo.org/services/auth"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"github.com/go-chi/cors"
@ -64,6 +65,10 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
ctx.Doer = ar.Doer
ctx.IsSigned = ar.Doer != nil
ctx.IsBasicAuth = ar.IsBasicAuth
if ctx.Reducer == nil {
// Ensure ctx.Reducer isn't nil, but has no impact:
ctx.Reducer = &authz.AllAccessAuthorizationReducer{}
}
}
}

View file

@ -0,0 +1,68 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package shared
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"forgejo.org/modules/json"
"forgejo.org/modules/web"
"forgejo.org/routers/common"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReducer(t *testing.T) {
makeRecorder := func() *httptest.ResponseRecorder {
buff := bytes.NewBufferString("")
recorder := httptest.NewRecorder()
recorder.Body = buff
return recorder
}
r := web.NewRoute()
r.Use(common.ProtocolMiddlewares()...)
r.Use(Middlewares()...)
type ReducerInfo struct {
IsSigned bool
IsNil bool
IsAllAccess bool
}
r.Get("/api/test", func(ctx *context.APIContext) {
retval := ReducerInfo{
IsSigned: ctx.IsSigned,
IsNil: ctx.Reducer == nil,
}
_, isAllAccess := ctx.Reducer.(*authz.AllAccessAuthorizationReducer)
retval.IsAllAccess = isAllAccess
ctx.JSON(http.StatusOK, retval)
})
// shared middleware ensures that `APIContext.Reducer` is not nil, and so that's the only test required in this
// package's scope -- an anonymous request and `Reducer` is not nil:
t.Run("anonymous", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.False(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
})
}

View file

@ -0,0 +1,38 @@
-
id: 6
uid: 2
name: Unrestricted Token
# token: 4a0c970da8bf58408a8c22264b2ac1ff47dadcce
token_hash: e26d1456df98b47394c9e8750023e690426e0209de1f719edd4b852651fb69fb349e15f6d42912a0899d055dc7e595be077a
token_salt: XKbbVcQbz-ahT8LQfXB5C_
token_last_eight: 47dadcce
created_unix: 946687980
updated_unix: 946687980
scope: write:repository
resource_all_repos: true
-
id: 7
uid: 2
name: Public Resources Only Token
# token: 83909b5b978acc5620ae0c7b0e55b548da2e26b5
token_hash: 211bc49d6c48a1f553d5f4400d903f20280ec3851a06f0e300253c042beb608191a8bcd52cb5d230a9096bbb9494b9d4cabf
token_salt: AD89WTwe8hCHGEUF7AG8BO
token_last_eight: da2e26b5
created_unix: 946687980
updated_unix: 946687980
scope: public-only,write:repository
resource_all_repos: true
-
id: 8
uid: 2
name: Specific Repos Only Token
# token: 46088605ec804b43ebd15cef1b3f210c31b066dd
token_hash: cd2739ac5894f25f0fd0e4b93e92b382d7bde82b09b791fa8aed6a514f8ffa9fda90fdb943c56f7b2eac78c9fd484ab3ecdf
token_salt: IhB81EQbYWHsVJGEGx587X
token_last_eight: 31b066dd
created_unix: 946687980
updated_unix: 946687980
scope: write:repository
resource_all_repos: false

View file

@ -86,6 +86,7 @@ import (
"forgejo.org/routers/api/v1/user"
"forgejo.org/services/actions"
"forgejo.org/services/auth"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"forgejo.org/services/forms"
redirect_service "forgejo.org/services/redirect"
@ -365,6 +366,20 @@ func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeC
// assign to true so that those searching should only filter public repositories/users/organizations
ctx.PublicOnly = publicOnly
reducer, ok := ctx.Data["ApiTokenReducer"].(authz.AuthorizationReducer)
if ok {
ctx.Reducer = reducer
} else {
// No "ApiTokenReducer" will be populated if the auth method wasn't an PAT. In this case, we populate
// `ctx.Reducer` so no nil checks are needed, and we respect the scope `PublicOnly()` so that it it's safe
// to just rely on `ctx.Reducer` to account for public-only access:
if ctx.PublicOnly {
ctx.Reducer = &authz.PublicReposAuthorizationReducer{}
} else {
ctx.Reducer = &authz.AllAccessAuthorizationReducer{}
}
}
}
}

266
routers/api/v1/api_test.go Normal file
View file

@ -0,0 +1,266 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package v1
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/json"
"forgejo.org/modules/jwtx"
"forgejo.org/modules/test"
"forgejo.org/modules/web"
"forgejo.org/routers/api/shared"
"forgejo.org/routers/common"
"forgejo.org/services/auth/source/oauth2"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenRequiresScopes(t *testing.T) {
defer unittest.OverrideFixtures("routers/api/v1/TestTokenRequiresScopes")()
require.NoError(t, unittest.PrepareTestDatabase())
makeRecorder := func() *httptest.ResponseRecorder {
buff := bytes.NewBufferString("")
recorder := httptest.NewRecorder()
recorder.Body = buff
return recorder
}
r := web.NewRoute()
r.Use(common.ProtocolMiddlewares()...)
r.Use(shared.Middlewares()...)
type ReducerInfo struct {
IsSigned bool
IsNil bool
IsAllAccess bool
IsPublicAccess bool
IsSpecificAccess bool
}
r.Get("/api/test", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), func(ctx *context.APIContext) {
retval := ReducerInfo{
IsSigned: ctx.IsSigned,
IsNil: ctx.Reducer == nil,
}
_, isAllAccess := ctx.Reducer.(*authz.AllAccessAuthorizationReducer)
retval.IsAllAccess = isAllAccess
_, isPublicAccess := ctx.Reducer.(*authz.PublicReposAuthorizationReducer)
retval.IsPublicAccess = isPublicAccess
_, isSpecificAccess := ctx.Reducer.(*authz.SpecificReposAuthorizationReducer)
retval.IsSpecificAccess = isSpecificAccess
ctx.JSON(http.StatusOK, retval)
})
t.Run("Basic Auth w/ PAT", func(t *testing.T) {
t.Run("unrestricted access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "4a0c970da8bf58408a8c22264b2ac1ff47dadcce")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "83909b5b978acc5620ae0c7b0e55b548da2e26b5")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("specific-repo access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "46088605ec804b43ebd15cef1b3f210c31b066dd")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.True(t, reducerInfo.IsSpecificAccess)
})
})
t.Run("Token Auth", func(t *testing.T) {
t.Run("unrestricted access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 4a0c970da8bf58408a8c22264b2ac1ff47dadcce")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 83909b5b978acc5620ae0c7b0e55b548da2e26b5")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("specific-repo access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 46088605ec804b43ebd15cef1b3f210c31b066dd")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.True(t, reducerInfo.IsSpecificAccess)
})
})
t.Run("OAuth", func(t *testing.T) {
signingKey, err := jwtx.CreateSigningKey("HS256", make([]byte, 32))
require.NoError(t, err)
defer test.MockVariableValue(&oauth2.DefaultSigningKey, signingKey)()
t.Run("unrestricted grant", func(t *testing.T) {
grant := &auth_model.OAuth2Grant{
UserID: 2,
ApplicationID: 100, // fake, but required here for unique constraint
Scope: "write:repository",
}
_, err = db.GetEngine(t.Context()).Insert(grant)
require.NoError(t, err)
token := oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
Counter: 100,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
signed, err := token.SignToken(oauth2.DefaultSigningKey)
require.NoError(t, err)
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", fmt.Sprintf("bearer %s", signed))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only grant", func(t *testing.T) {
grant := &auth_model.OAuth2Grant{
UserID: 2,
ApplicationID: 101, // fake, but required here for unique constraint
Scope: "write:repository public-only",
}
_, err = db.GetEngine(t.Context()).Insert(grant)
require.NoError(t, err)
token := oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
Counter: 100,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
signed, err := token.SignToken(oauth2.DefaultSigningKey)
require.NoError(t, err)
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", fmt.Sprintf("bearer %s", signed))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
})
}

View file

@ -0,0 +1,14 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package v1
import (
"testing"
"forgejo.org/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

View file

@ -17,6 +17,7 @@ import (
"forgejo.org/modules/setting"
"forgejo.org/modules/util"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/authz"
)
// Ensure the struct implements the interface.
@ -102,6 +103,14 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = token.Scope
reducer, err := authz.GetAuthorizationReducerForAccessToken(req.Context(), token)
if err != nil {
log.Error("authz.GetAuthorizationReducerForAccessToken: %v", err)
return nil, err
}
store.GetData()["ApiTokenReducer"] = reducer
return u, nil
} else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
log.Error("GetAccessTokenBySha: %v", err)

View file

@ -20,6 +20,7 @@ import (
"forgejo.org/modules/web/middleware"
"forgejo.org/services/actions"
"forgejo.org/services/auth/source/oauth2"
"forgejo.org/services/authz"
)
// Ensure the struct implements the interface.
@ -200,6 +201,14 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
}
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = t.Scope
reducer, err := authz.GetAuthorizationReducerForAccessToken(ctx, t)
if err != nil {
log.Error("authz.GetAuthorizationReducerForAccessToken: %v", err)
return 0, err
}
store.GetData()["ApiTokenReducer"] = reducer
return t.UID, nil
}

View file

@ -24,6 +24,7 @@ import (
"forgejo.org/modules/setting"
"forgejo.org/modules/web"
web_types "forgejo.org/modules/web/types"
"forgejo.org/services/authz"
"code.forgejo.org/go-chi/cache"
)
@ -47,6 +48,7 @@ type APIContext struct {
QuotaGroup *quota_model.Group
QuotaRule *quota_model.Rule
PublicOnly bool // Whether the request is for a public endpoint
Reducer authz.AuthorizationReducer
}
func init() {