feat: allow Forgejo Actions to be used an Authorized Integration in-memory with internal issuer (#12364)

Allow JWTs that are generated by Forgejo Actions to be validated within Forgejo in-memory.  Without any special support for this internal access situation, these problems would occur:

1. Forgejo would need to make an HTTP request to itself to get the valid public key for the JWT, in order to validate its signature.  This is a waste of resources, and introduces a self-DoS risk.
2. Forgejo would need to be available via TLS in order for Actions to make service calls to Forgejo with that JWT, due to the TLS requirement for public key fetching.  This would be a blocker for writing end-to-end tests for Forgejo, but also would affect users who do not host Forgejo with TLS.
3. Authorized Integrations would need to be saved with the `issuer` URL of Forgejo.  If Forgejo's own `setting.AppURL` changed, all the persisted records in the database would become incorrect.

## 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.
  - [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/12364
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-05-01 17:42:34 +02:00 committed by Mathieu Fenniak
parent 67250869d3
commit 7fc236c589
7 changed files with 355 additions and 4 deletions

View file

@ -4,6 +4,7 @@ packages:
forgejo.org/modules/nosql: forgejo.org/modules/nosql:
config: config:
filename: mocks.go # make mocks public so that external packages can use filename: mocks.go # make mocks public so that external packages can use
forgejo.org/services/auth/method:
forgejo.org/services/authz: forgejo.org/services/authz:
config: config:
filename: authorization_reducer_mock.go # make mocks public so that external packages can use filename: authorization_reducer_mock.go # make mocks public so that external packages can use

View file

@ -24,7 +24,13 @@ import (
func microcmdUserCreateAuthorizedIntegration() *cli.Command { func microcmdUserCreateAuthorizedIntegration() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "create-authorized-integration", Name: "create-authorized-integration",
Usage: "Create an authorized integration for a specific user", Description: `Creates an authorized integration. Authorized integrations allow Forgejo to
receive JWTs from external sources, validate their claims against
user-defined rules, and grant access to Forgejo's API on behalf of a user.
The issuer may be set to "urn:forgejo:authorized-integrations:actions"
to support JWTs from the local instance's Forgejo Actions, utilizing the
enable-openid-connect flag in a workflow.`,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "username", Name: "username",

View file

@ -14,6 +14,7 @@ import (
"forgejo.org/modules/web" "forgejo.org/modules/web"
web_types "forgejo.org/modules/web/types" web_types "forgejo.org/modules/web/types"
actions_service "forgejo.org/services/actions" actions_service "forgejo.org/services/actions"
auth_method "forgejo.org/services/auth/method"
"forgejo.org/services/context" "forgejo.org/services/context"
) )
@ -66,6 +67,7 @@ func init() {
web.RegisterResponseStatusProvider[*OIDCContext](func(req *http.Request) web_types.ResponseStatusProvider { web.RegisterResponseStatusProvider[*OIDCContext](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(oidcContextKey).(*OIDCContext) return req.Context().Value(oidcContextKey).(*OIDCContext)
}) })
auth_method.RegisterInternalIssuer("api/actions", internalIssuer{})
} }
func OIDCContexter() func(next http.Handler) http.Handler { func OIDCContexter() func(next http.Handler) http.Handler {
@ -143,3 +145,16 @@ func (o *oidcRoutes) configuration(ctx *OIDCContext) {
func (o *oidcRoutes) keys(ctx *OIDCContext) { func (o *oidcRoutes) keys(ctx *OIDCContext) {
ctx.JSON(http.StatusOK, o.jwks) ctx.JSON(http.StatusOK, o.jwks)
} }
// Register Actions OIDC as an internal issuer for authorized integrations. This allows an authorized integration to
// have the value `urn:forgejo:authorized-integrations:actions` as an issuer, and perform JWT signature validation
// against the in-memory signing key defined in this module.
type internalIssuer struct{}
func (internalIssuer) IssuerPlaceholder() string {
return "urn:forgejo:authorized-integrations:actions"
}
func (internalIssuer) SigningKey() jwtx.SigningKey {
return jwtSigningKey
}

View file

@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
"strings"
"sync" "sync"
"time" "time"
@ -48,8 +49,40 @@ var (
return aiHTTPClient return aiHTTPClient
} }
getCache = cache.GetCache getCache = cache.GetCache
internalIssuers = make(map[string]InternalIssuer)
) )
// Authorized Integrations can verify the signature of JWTs that the application itself generated without requiring
// remote access, and in a manner that is flexible to changes in [setting.AppURL].
//
// For example, Forgejo Actions is often used to access Forgejo with a JWT, by setting `enable-openid-connect: true` in
// a workflow. Without any special support for this internal access situation, problems would occur:
//
// 1. Forgejo would need to make an HTTP request to itself to get the valid public key for the JWT, in order to validate
// its signature. This is a waste of resources, and introduces a self-DoS risk.
//
// 2. Forgejo would need to be available via TLS in order for Actions to make service calls to Forgejo with that JWT
// (due to the TLS requirement for public key fetching).
//
// 3. Authorized Integrations would need to be saved with the `issuer` URL of Forgejo. If Forgejo's own
// [setting.AppURL] changed, all the persisted records in the database would become incorrect.
//
// Internal Issuers work by registering a URL suffix like "/api/actions". When a JWT is received with an issuer
// matching [setting.AppURL] and the registered URL suffix, then the [InternalIssuer] interface is used to access the
// JWT public key, and the value to be saved in the Authorized Integrations table as the issuer.
func RegisterInternalIssuer(urlSuffix string, internalIssuer InternalIssuer) {
internalIssuers[urlSuffix] = internalIssuer
}
//mockery:generate: true
type InternalIssuer interface {
// Signing key used to validate a JWT from this internal issuer.
SigningKey() jwtx.SigningKey
// Value to store in [auth_model.AuthorizedIntegration]'s Issuer field to reflect the use of this internal issuer.
IssuerPlaceholder() string
}
// Restrict document size to prevent resource exhaustion attack with a malicious authorized integration; largest // Restrict document size to prevent resource exhaustion attack with a malicious authorized integration; largest
// real-world openid-configuration observed is about 1kB, largest JWKS is 6kB, so for both cases 16kB should be // real-world openid-configuration observed is about 1kB, largest JWKS is 6kB, so for both cases 16kB should be
// sufficient. If this needs to change in the future, it could be moved to a config setting -- but until a reason comes // sufficient. If this needs to change in the future, it could be moved to a config setting -- but until a reason comes
@ -111,7 +144,19 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
return nil, fmt.Errorf("invalid `aud` claim: %q", audience) return nil, fmt.Errorf("invalid `aud` claim: %q", audience)
} }
authorizedIntegration, err = auth_model.GetAuthorizedIntegration(req.Context(), issuer, audience) // Check if there's an internal issuer that matches the JWT's issuer, and if so, change `queryIssuer` to the
// internal issuer's placeholder, and store `internalIssuer` for later:
queryIssuer := issuer
var internalIssuer InternalIssuer
issuerSuffix := strings.TrimPrefix(issuer, setting.AppURL)
if issuer != issuerSuffix { // TrimPrefix will return a different string when the prefix was present
if ii, ok := internalIssuers[issuerSuffix]; ok {
internalIssuer = ii
queryIssuer = internalIssuer.IssuerPlaceholder()
}
}
authorizedIntegration, err = auth_model.GetAuthorizedIntegration(req.Context(), queryIssuer, audience)
if errors.Is(err, util.ErrNotExist) { if errors.Is(err, util.ErrNotExist) {
return nil, errors.New("matching authorized_integration not found") return nil, errors.New("matching authorized_integration not found")
} else if err != nil { } else if err != nil {
@ -125,6 +170,14 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
return nil, fmt.Errorf("claim mismatch: %w", err) return nil, fmt.Errorf("claim mismatch: %w", err)
} }
// If an internal issuer was found earlier, then we can skip the JWKS fetch and just use its in-memory
// signing key to validate the JWT. It is critical we do this after the `checkClaims` above so that we
// don't miss important validation of the JWT.
if internalIssuer != nil {
key := internalIssuer.SigningKey().VerifyKey()
return key, nil
}
issuerURL, err := url.Parse(issuer) issuerURL, err := url.Parse(issuer)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed parsing issuer: %w", err) return nil, fmt.Errorf("failed parsing issuer: %w", err)

View file

@ -676,6 +676,89 @@ func TestAuthorizedIntegration(t *testing.T) {
}) })
}) })
}) })
t.Run("internal issuer", func(t *testing.T) {
t.Run("success", func(t *testing.T) {
ait := newInternalIssuerAITester(t)
defer ait.close()
output := ait.bearerRequest()
requireOutput[*auth.AuthenticationSuccess](t, output)
})
t.Run("mismatched issuer app URL", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = "https://example.org/fake-jwt-issuer" // correct suffix, incorrect prefix
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
ait.ii.ExpectedCalls = nil // InternalIssuer should have zero calls
})
t.Run("mismatched issuer URL suffix", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = setting.AppURL + "/fake-jwt-issuer-123" // correct prefix, incorrect suffix
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
ait.ii.ExpectedCalls = nil // InternalIssuer should have zero calls
})
t.Run("mismatched DB issuer placeholder", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
aiDBTweak(func(ai *auth_model.AuthorizedIntegration) {
ai.Issuer = "urn:forgejo:authorized-issuer:internal:bad-choice-here"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
ait.ii.ExpectedCalls = nil // InternalIssuer should have zero calls
})
t.Run("checks claim rules", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.other["custom-claim"] = "oops wrong claim"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "claim \"custom-claim\" must be \"custom-claim-value\"")
ait.ii.ExpectedCalls = []*mock.Call{ait.ii.ExpectedCalls[0]} // drop call to SigningKey() -- won't occur due to claim mismatch
})
t.Run("JWT times checked", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.ExpiresAt = jwt.NewNumericDate(time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local))
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "token is expired")
})
t.Run("signed by incorrect JWT key", func(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048-bad-key.priv")
badSigningKey, err := jwtx.InitAsymmetricSigningKey(keyPath, "RS256")
require.NoError(t, err)
ait := newInternalIssuerAITester(t, jwtClientSignatureTweak(func() jwtx.SigningKey {
return badSigningKey
}))
defer ait.close()
output := ait.bearerRequest()
err = requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "crypto/rsa: verification error")
})
})
} }
type AuthorizedIntegrationTester struct { type AuthorizedIntegrationTester struct {
@ -686,6 +769,7 @@ type AuthorizedIntegrationTester struct {
testServer *httptest.Server testServer *httptest.Server
resetHTTPClient func() resetHTTPClient func()
tweaks []tweak tweaks []tweak
ii *MockInternalIssuer
} }
func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester { func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
@ -788,6 +872,25 @@ func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
return ait return ait
} }
func newInternalIssuerAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
innerTweaks := []tweak{
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = setting.AppURL + "/fake-jwt-issuer"
}),
aiDBTweak(func(ai *auth_model.AuthorizedIntegration) {
ai.Issuer = "urn:forgejo:authorized-issuer:internal:test1"
}),
}
innerTweaks = append(innerTweaks, tweaks...)
ait := newAITester(t, innerTweaks...)
ii := NewMockInternalIssuer(t)
internalIssuers["/fake-jwt-issuer"] = ii
ii.On("IssuerPlaceholder").Return("urn:forgejo:authorized-issuer:internal:test1")
ii.On("SigningKey").Return(ait.jwtSigningKey)
ait.ii = ii
return ait
}
func (ait *AuthorizedIntegrationTester) signedJWT() string { func (ait *AuthorizedIntegrationTester) signedJWT() string {
claims := flexibleClaims{ claims := flexibleClaims{
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
@ -803,7 +906,13 @@ func (ait *AuthorizedIntegrationTester) signedJWT() string {
tweak(&claims) tweak(&claims)
} }
} }
signedToken, err := ait.jwtSigningKey.JWT(claims) clientSigningKey := ait.jwtSigningKey
for _, tweak := range ait.tweaks {
if tweak, is := tweak.(jwtClientSignatureTweak); is {
clientSigningKey = tweak()
}
}
signedToken, err := clientSigningKey.JWT(claims)
require.NoError(ait.t, err) require.NoError(ait.t, err)
return signedToken return signedToken
} }
@ -840,3 +949,5 @@ type jwksTweak func(*openIDKeys)
type aiDBTweak func(*auth_model.AuthorizedIntegration) type aiDBTweak func(*auth_model.AuthorizedIntegration)
type jwtxKeyTweak func() jwtx.SigningKey type jwtxKeyTweak func() jwtx.SigningKey
type jwtClientSignatureTweak func() jwtx.SigningKey

View file

@ -0,0 +1,129 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package method
import (
"forgejo.org/modules/jwtx"
mock "github.com/stretchr/testify/mock"
)
// NewMockInternalIssuer creates a new instance of MockInternalIssuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockInternalIssuer(t interface {
mock.TestingT
Cleanup(func())
},
) *MockInternalIssuer {
mock := &MockInternalIssuer{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockInternalIssuer is an autogenerated mock type for the InternalIssuer type
type MockInternalIssuer struct {
mock.Mock
}
type MockInternalIssuer_Expecter struct {
mock *mock.Mock
}
func (_m *MockInternalIssuer) EXPECT() *MockInternalIssuer_Expecter {
return &MockInternalIssuer_Expecter{mock: &_m.Mock}
}
// IssuerPlaceholder provides a mock function for the type MockInternalIssuer
func (_mock *MockInternalIssuer) IssuerPlaceholder() string {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for IssuerPlaceholder")
}
var r0 string
if returnFunc, ok := ret.Get(0).(func() string); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockInternalIssuer_IssuerPlaceholder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IssuerPlaceholder'
type MockInternalIssuer_IssuerPlaceholder_Call struct {
*mock.Call
}
// IssuerPlaceholder is a helper method to define mock.On call
func (_e *MockInternalIssuer_Expecter) IssuerPlaceholder() *MockInternalIssuer_IssuerPlaceholder_Call {
return &MockInternalIssuer_IssuerPlaceholder_Call{Call: _e.mock.On("IssuerPlaceholder")}
}
func (_c *MockInternalIssuer_IssuerPlaceholder_Call) Run(run func()) *MockInternalIssuer_IssuerPlaceholder_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockInternalIssuer_IssuerPlaceholder_Call) Return(s string) *MockInternalIssuer_IssuerPlaceholder_Call {
_c.Call.Return(s)
return _c
}
func (_c *MockInternalIssuer_IssuerPlaceholder_Call) RunAndReturn(run func() string) *MockInternalIssuer_IssuerPlaceholder_Call {
_c.Call.Return(run)
return _c
}
// SigningKey provides a mock function for the type MockInternalIssuer
func (_mock *MockInternalIssuer) SigningKey() jwtx.SigningKey {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for SigningKey")
}
var r0 jwtx.SigningKey
if returnFunc, ok := ret.Get(0).(func() jwtx.SigningKey); ok {
r0 = returnFunc()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(jwtx.SigningKey)
}
}
return r0
}
// MockInternalIssuer_SigningKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SigningKey'
type MockInternalIssuer_SigningKey_Call struct {
*mock.Call
}
// SigningKey is a helper method to define mock.On call
func (_e *MockInternalIssuer_Expecter) SigningKey() *MockInternalIssuer_SigningKey_Call {
return &MockInternalIssuer_SigningKey_Call{Call: _e.mock.On("SigningKey")}
}
func (_c *MockInternalIssuer_SigningKey_Call) Run(run func()) *MockInternalIssuer_SigningKey_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockInternalIssuer_SigningKey_Call) Return(signingKey jwtx.SigningKey) *MockInternalIssuer_SigningKey_Call {
_c.Call.Return(signingKey)
return _c
}
func (_c *MockInternalIssuer_SigningKey_Call) RunAndReturn(run func() jwtx.SigningKey) *MockInternalIssuer_SigningKey_Call {
_c.Call.Return(run)
return _c
}

View file

@ -6,13 +6,16 @@ package integration
import ( import (
"crypto/rsa" "crypto/rsa"
"encoding/base64" "encoding/base64"
"fmt"
"math/big" "math/big"
"net/http" "net/http"
"testing" "testing"
actions_model "forgejo.org/models/actions" actions_model "forgejo.org/models/actions"
"forgejo.org/models/auth"
"forgejo.org/models/db" "forgejo.org/models/db"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
actions_service "forgejo.org/services/actions" actions_service "forgejo.org/services/actions"
"forgejo.org/tests" "forgejo.org/tests"
@ -188,4 +191,37 @@ func TestActionsIDToken(t *testing.T) {
resp = MakeRequest(t, req, http.StatusBadRequest) resp = MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "run-id does not match") assert.Contains(t, resp.Body.String(), "run-id does not match")
}) })
t.Run("authorized integration internal issuer", func(t *testing.T) {
// Create an Authorized Integration which is set-up to be validated with the in-memory Actions' JWT signing key:
ai := &auth.AuthorizedIntegration{
UserID: 2,
Scope: auth.AccessTokenScopeAll,
Issuer: "urn:forgejo:authorized-integrations:actions",
ClaimRules: &auth.ClaimRules{
Rules: []auth.ClaimRule{
{
Claim: "sub",
Comparison: auth.ClaimEqual,
Value: "repo:user5/repo4:ref:refs/heads/master",
},
},
},
ResourceAllRepos: true,
}
require.NoError(t, auth.InsertAuthorizedIntegration(t.Context(), ai))
// Create a JWT from the Actions system:
var getResponse getTokenResponse
req = NewRequest(t, "GET", fmt.Sprintf("/api/actions/_apis/pipelines/workflows/792/idtoken?placeholder=true&audience=%s", ai.Audience)).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &getResponse)
// Should be able to make a Forgejo API call with the JWT, authenticated by the Authorized Integration:
req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(getResponse.Value)
resp := MakeRequest(t, req, http.StatusOK)
var user api.User
DecodeJSON(t, resp, &user)
assert.Equal(t, "user2", user.LoginName)
})
} }