forked from mirrors/forgejo
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:
parent
48218c654b
commit
c9d8682f90
4 changed files with 162 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
53
tests/integration/auth_authorized_integration_test.go
Normal file
53
tests/integration/auth_authorized_integration_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue