diff --git a/.deadcode-out b/.deadcode-out index 790e47fb4c..6d2c35e374 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -14,7 +14,6 @@ forgejo.org/models IsErrMergeDivergingFastForwardOnly forgejo.org/models/auth - GetRepositoriesAccessibleWithToken WebAuthnCredentials forgejo.org/models/db diff --git a/routers/api/shared/middleware.go b/routers/api/shared/middleware.go index 59d9f28d60..efd252728e 100644 --- a/routers/api/shared/middleware.go +++ b/routers/api/shared/middleware.go @@ -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{} + } } } diff --git a/routers/api/shared/middleware_test.go b/routers/api/shared/middleware_test.go new file mode 100644 index 0000000000..3c5fc4d00d --- /dev/null +++ b/routers/api/shared/middleware_test.go @@ -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) + }) +} diff --git a/routers/api/v1/TestTokenRequiresScopes/access_token.yml b/routers/api/v1/TestTokenRequiresScopes/access_token.yml new file mode 100644 index 0000000000..ee29bc4959 --- /dev/null +++ b/routers/api/v1/TestTokenRequiresScopes/access_token.yml @@ -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 diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b8af31fa75..dbeb916ca6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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{} + } + } } } diff --git a/routers/api/v1/api_test.go b/routers/api/v1/api_test.go new file mode 100644 index 0000000000..4b6ae948f3 --- /dev/null +++ b/routers/api/v1/api_test.go @@ -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) + }) + }) +} diff --git a/routers/api/v1/main_test.go b/routers/api/v1/main_test.go new file mode 100644 index 0000000000..7553b68bfd --- /dev/null +++ b/routers/api/v1/main_test.go @@ -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) +} diff --git a/services/auth/basic.go b/services/auth/basic.go index f117494762..4125c914a9 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -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) diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index eb1dffa32a..1c1809b092 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -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 } diff --git a/services/context/api.go b/services/context/api.go index 14708147ad..27458f3768 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -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() {