diff --git a/.mockery.yml b/.mockery.yml index 7c98b29508..2e9427cd77 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -4,6 +4,7 @@ packages: forgejo.org/modules/nosql: config: filename: mocks.go # make mocks public so that external packages can use + forgejo.org/services/auth/method: forgejo.org/services/authz: config: filename: authorization_reducer_mock.go # make mocks public so that external packages can use diff --git a/cmd/admin_user_generate_authorized_integration.go b/cmd/admin_user_generate_authorized_integration.go index 5f3e13a48e..fc159d90ae 100644 --- a/cmd/admin_user_generate_authorized_integration.go +++ b/cmd/admin_user_generate_authorized_integration.go @@ -23,8 +23,14 @@ import ( func microcmdUserCreateAuthorizedIntegration() *cli.Command { return &cli.Command{ - Name: "create-authorized-integration", - Usage: "Create an authorized integration for a specific user", + Name: "create-authorized-integration", + 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{ &cli.StringFlag{ Name: "username", diff --git a/routers/api/actions/oidc.go b/routers/api/actions/oidc.go index 20cc44f36a..fc82bc299f 100644 --- a/routers/api/actions/oidc.go +++ b/routers/api/actions/oidc.go @@ -14,6 +14,7 @@ import ( "forgejo.org/modules/web" web_types "forgejo.org/modules/web/types" actions_service "forgejo.org/services/actions" + auth_method "forgejo.org/services/auth/method" "forgejo.org/services/context" ) @@ -66,6 +67,7 @@ func init() { web.RegisterResponseStatusProvider[*OIDCContext](func(req *http.Request) web_types.ResponseStatusProvider { return req.Context().Value(oidcContextKey).(*OIDCContext) }) + auth_method.RegisterInternalIssuer("api/actions", internalIssuer{}) } func OIDCContexter() func(next http.Handler) http.Handler { @@ -143,3 +145,16 @@ func (o *oidcRoutes) configuration(ctx *OIDCContext) { func (o *oidcRoutes) keys(ctx *OIDCContext) { 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 +} diff --git a/services/auth/method/authorized_integration.go b/services/auth/method/authorized_integration.go index 368e1fe036..7c24c3c8c3 100644 --- a/services/auth/method/authorized_integration.go +++ b/services/auth/method/authorized_integration.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "slices" + "strings" "sync" "time" @@ -48,8 +49,40 @@ var ( return aiHTTPClient } 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 // 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 @@ -111,7 +144,19 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter, 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) { return nil, errors.New("matching authorized_integration not found") } 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) } + // 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) if err != nil { return nil, fmt.Errorf("failed parsing issuer: %w", err) diff --git a/services/auth/method/authorized_integration_test.go b/services/auth/method/authorized_integration_test.go index 16b09ae9ad..6bce1259c2 100644 --- a/services/auth/method/authorized_integration_test.go +++ b/services/auth/method/authorized_integration_test.go @@ -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 { @@ -686,6 +769,7 @@ type AuthorizedIntegrationTester struct { testServer *httptest.Server resetHTTPClient func() tweaks []tweak + ii *MockInternalIssuer } func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester { @@ -788,6 +872,25 @@ func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester { 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 { claims := flexibleClaims{ RegisteredClaims: jwt.RegisteredClaims{ @@ -803,7 +906,13 @@ func (ait *AuthorizedIntegrationTester) signedJWT() string { 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) return signedToken } @@ -840,3 +949,5 @@ type jwksTweak func(*openIDKeys) type aiDBTweak func(*auth_model.AuthorizedIntegration) type jwtxKeyTweak func() jwtx.SigningKey + +type jwtClientSignatureTweak func() jwtx.SigningKey diff --git a/services/auth/method/mocks_test.go b/services/auth/method/mocks_test.go new file mode 100644 index 0000000000..46648c8cba --- /dev/null +++ b/services/auth/method/mocks_test.go @@ -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 +} diff --git a/tests/integration/api_actions_id_token_test.go b/tests/integration/api_actions_id_token_test.go index 53a0a5b269..0fd478b9d2 100644 --- a/tests/integration/api_actions_id_token_test.go +++ b/tests/integration/api_actions_id_token_test.go @@ -6,13 +6,16 @@ package integration import ( "crypto/rsa" "encoding/base64" + "fmt" "math/big" "net/http" "testing" actions_model "forgejo.org/models/actions" + "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/modules/setting" + api "forgejo.org/modules/structs" actions_service "forgejo.org/services/actions" "forgejo.org/tests" @@ -188,4 +191,37 @@ func TestActionsIDToken(t *testing.T) { resp = MakeRequest(t, req, http.StatusBadRequest) 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) + }) }