test: add API integration testing for authorized integration authentication (#12266)

Built on #12261; one commit added.

Adds an integration test verifying that access to the API can be authenticated by an authorized integration.

## 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...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `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/12266
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-04-26 22:06:16 +02:00 committed by Mathieu Fenniak
commit c9d8682f90
4 changed files with 162 additions and 3 deletions

View file

@ -222,6 +222,9 @@ forgejo.org/modules/zstd
forgejo.org/routers/web/org
MustEnableProjects
forgejo.org/services/auth/method
OverrideAuthorizedIntegrationHTTPClient
forgejo.org/services/context
GetPrivateContext

View file

@ -35,6 +35,12 @@ var (
initHTTPClient sync.Once
errParseInternalServer = errors.New("internal server error")
// Allow mocking / overridding during tests:
GetAuthorizedIntegrationHTTPClient = func() *http.Client {
initHTTPClient.Do(initAuthorizedIntegrationHTTPClient)
return aiHTTPClient
}
)
// Restrict document size to prevent resource exhaustion attack with a malicious authorized integration; largest
@ -247,9 +253,7 @@ func (a *AuthorizedIntegration) fetchJSON(urlString string, v any) error {
return fmt.Errorf("unsupported URL scheme: %q", parsedURL.String())
}
initHTTPClient.Do(initAuthorizedIntegrationHTTPClient)
resp, err := aiHTTPClient.Get(parsedURL.String())
resp, err := GetAuthorizedIntegrationHTTPClient().Get(parsedURL.String())
if err != nil {
return err
}

View file

@ -0,0 +1,53 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"testing"
auth_model "forgejo.org/models/auth"
api "forgejo.org/modules/structs"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIAuthWithAuthorizedIntegration(t *testing.T) {
t.Run("all access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
ait := newAITester(t)
defer ait.close()
token := ait.signedJWT()
req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var user api.User
DecodeJSON(t, resp, &user)
assert.Equal(t, "user2", user.LoginName)
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
})
t.Run("scope-limited access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) {
ai.Scope = auth_model.AccessTokenScopeReadUser
})
defer ait.close()
token := ait.signedJWT()
req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var user api.User
DecodeJSON(t, resp, &user)
assert.Equal(t, "user2", user.LoginName)
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
})
}

View file

@ -33,13 +33,16 @@ import (
user_model "forgejo.org/models/user"
"forgejo.org/modules/graceful"
"forgejo.org/modules/json"
"forgejo.org/modules/jwtx"
"forgejo.org/modules/keying"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/modules/testlogger"
"forgejo.org/modules/util"
"forgejo.org/modules/web"
"forgejo.org/routers"
auth_method "forgejo.org/services/auth/method"
"forgejo.org/services/auth/source/remote"
app_context "forgejo.org/services/context"
"forgejo.org/services/mailer"
@ -47,6 +50,8 @@ import (
"forgejo.org/tests"
"github.com/PuerkitoBio/goquery"
"github.com/golang-jwt/jwt/v5"
gouuid "github.com/google/uuid"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
goth_github "github.com/markbates/goth/providers/github"
@ -779,3 +784,97 @@ func SortMailerMessages(msgs []*mailer.Message) {
return strings.Compare(b.To, a.To)
})
}
type AuthorizedIntegrationTester struct {
t *testing.T
authorizedIntegration *auth.AuthorizedIntegration
jwtSigningKey jwtx.SigningKey
testServer *httptest.Server
resetHTTPClient func()
resetAllowLocalNetworks func()
}
func newAITester(t *testing.T, setupAI ...func(*auth.AuthorizedIntegration)) *AuthorizedIntegrationTester {
ait := &AuthorizedIntegrationTester{
t: t,
}
var jwtSigningKey jwtx.SigningKey
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
jwtSigningKey, err := jwtx.InitAsymmetricSigningKey(keyPath, "RS256")
require.NoError(t, err)
ait.jwtSigningKey = jwtSigningKey
ait.testServer = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/actions/.well-known/openid-configuration" {
retval := map[string]any{
"issuer": ait.authorizedIntegration.Issuer,
"jwks_uri": fmt.Sprintf("%s/.keys", ait.authorizedIntegration.Issuer),
"id_token_signing_alg_values_supported": []string{"RS256"},
}
err := json.NewEncoder(w).Encode(retval)
require.NoError(t, err)
return
}
if r.URL.Path == "/api/actions/.keys" {
jwk, err := ait.jwtSigningKey.ToJWK()
require.NoError(t, err)
jwk["use"] = "sig"
retval := map[string]any{
"keys": []map[string]string{jwk},
}
_ = json.NewEncoder(w).Encode(retval) // no error checking -- some tests abort read
return
}
w.WriteHeader(http.StatusNotFound)
}))
// trust TLS cert of our NewTLSServer by inserting the test client for our test server in as the HTTP client to use
ait.resetHTTPClient = test.MockVariableValue(
&auth_method.GetAuthorizedIntegrationHTTPClient,
func() *http.Client {
return ait.testServer.Client()
})
ait.authorizedIntegration = &auth.AuthorizedIntegration{
UserID: 2,
Scope: auth.AccessTokenScopeAll,
Issuer: fmt.Sprintf("%s/api/actions", ait.testServer.URL),
Audience: fmt.Sprintf("https://forgejo.example.org/-/coolguy/authorized-integration/%s", gouuid.New().String()),
ClaimRules: &auth.ClaimRules{
Rules: []auth.ClaimRule{
{
Claim: "custom-claim",
Comparison: auth.ClaimEqual,
Value: "custom-claim-value",
},
},
},
}
for _, setup := range setupAI {
setup(ait.authorizedIntegration)
}
_, err = db.GetEngine(t.Context()).Insert(ait.authorizedIntegration)
require.NoError(t, err)
ait.resetAllowLocalNetworks = test.MockVariableValue(&setting.AuthorizedIntegration.AllowLocalNetworks, true)
return ait
}
func (ait *AuthorizedIntegrationTester) signedJWT() string {
claims := jwt.MapClaims{
"iss": ait.authorizedIntegration.Issuer,
"aud": ait.authorizedIntegration.Audience,
"custom-claim": "custom-claim-value",
}
signedToken, err := ait.jwtSigningKey.JWT(claims)
require.NoError(ait.t, err)
return signedToken
}
func (ait *AuthorizedIntegrationTester) close() {
ait.resetAllowLocalNetworks()
ait.resetHTTPClient()
ait.testServer.Close()
}