feat: authorized integrations DB models and authentication implementation (#12261)

Authorized Integrations is a new feature to allow users to define external systems which can generate JSON Web Tokens (JWTs) that Forgejo will trust in order to perform API access on behalf of that user.  This is an authentication mechanism that requires zero preconfiguration of shared secrets, and instead establishes trust through short-lived secrets (JWTs) that are signed by the issuer, signatures are validated by comparison with published public keys, and a public-keys retrieved through well-known HTTP endpoints secured with TLS verification.

The primary goal of Authorized Integrations is to support a mechanism for Forgejo Actions to receive elevated, but controlled, additional access to Forgejo.  More details as to what the end result will look like are available in the [design proposal](https://codeberg.org/forgejo/forgejo/issues/3571#issuecomment-13268004) on #3571.

This PR adds the core database storage and authentication verification for Authorized Integrations, with these capabilities:
- An Authorized Integration is resolved by a unique key of an "issuer" and an "audience".  The value of "issuer" is defined by the remote integration, and the value of "audience" will incorporate a unique identifier generated by Forgejo.
    - Example issuer: `https://token.actions.githubusercontent.com/` is the issuer for GitHub JWTs
    - Example audience: `https://forgejo.example.org/-/mfenniak/authorized-integration/6cc55ba0` is the expected format for a random audience field that Forgejo will generate.
- JWTs can contain any number of claims, which are represented as a JSON object; Forgejo can validate these with a flexible policy.
    - eg. a claim may be `{"sub": "repo:coolguy/forgejo-runner-testrepo:pull_request"}` indicating that an OIDC token was received from an Actions execution in a specific repo on a specific event.
    - Authorized Integrations support a `ClaimRules` system which allows claim equal, glob, and nested object inspection.
    - `{"claim":"sub","comparison":"eq","value":"repo:mfenniak/forgejo-runner-testrepo:pull_request"}` -- would validate that `sub` exactly equals the specific value
    - `{"claim":"sub","comparison":"glob","value":"repo:mfenniak/forgejo-runner-testrepo:*"}` -- would validate that `sub` matches the given string prefix but allow any event
- When a JWT is received on an incoming API call, Forgejo retrieves the Authorized Integration from the DB (if present), validates the token signature against a remote JWKS, validates the claims, and grants API access as the user with a permission scope defined on the Authorized Integration.

In addition to the unit testing provided here, this PR has been manually integration tested against three JWT issuing systems: Forgejo Actions, GitHub Actions, and AWS STS GetWebIdentityToken.

Careful consideration has been made of these security concerns:
- SSRF attacks against Forgejo are prevented by:
    - having a blocklist on remote HTTP validation requests which prevent access to internal network resources,
    - ensuring that authorized integrations are created by users with matching issuers, before attempting to validate tokens
- Resource utilization attacks against Forgejo are reduced by limiting the possible size of external metadata requests; when fetching `/.well-known/openid-configuration` and `jkws_uri`'s from remote, untrusted servers, a maximum response size of 16 kB is enforced
- Only well-known secure assymmetric JWT signing algorithms are supported -- in particular, the sketchy `none` JWT algorithm isn't supported.
- JWT validation is covered by extensive unit tests, covering validation of all JWT timestamps, validation of the issuers, validation of the issuer's documented supported signing algorithms.

This PR serves as a core, and many enhancements are required for this to be a usable system for users.

## 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.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [ ] `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.
    - Documentation updates for new config entries will be authored.
- [ ] 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.
    - Marking not visible as there's no mechanism to interact with this backend yet.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12261
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-04-26 20:52:42 +02:00 committed by Mathieu Fenniak
commit 48218c654b
16 changed files with 2088 additions and 0 deletions

View file

@ -0,0 +1,146 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"context"
"time"
"forgejo.org/models/db"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
"xorm.io/builder"
)
// An Authorized Integration allow users to define external systems which can generate JSON Web Tokens (JWTs) that
// Forgejo will trust in order to perform API access on behalf of a user defined by the UserID field.
//
// When a JWT is received by Forgejo, the issuer (iss) and audience (aud) claims are used to lookup an authorized
// integration with an exact match. Together these fields serve as a unique key for the authorized issuer. Duplicates
// cannot be permitted because we would not know which user to authenticate the JWT as.
type AuthorizedIntegration struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL REFERENCES(user, id)"`
Scope AccessTokenScope `xorm:"NOT NULL"`
ResourceAllRepos bool `xorm:"NOT NULL"` // flag for whether AuthorizedIntegrationResourceRepo instances will limit the resources this access token can access (false) or won't limit them (true).
// Exact-match `iss` claim of the JWT
Issuer string `xorm:"NOT NULL UNIQUE(s)"`
// Exact-match `aud` claim of the JWT
Audience string `xorm:"NOT NULL UNIQUE(s)"`
ClaimRules *ClaimRules `xorm:"NOT NULL JSON"`
CreatedUnix timeutil.TimeStamp `xorm:"NOT NULL created"`
UpdatedUnix timeutil.TimeStamp `xorm:"NOT NULL updated"`
}
func init() {
db.RegisterModel(new(AuthorizedIntegration))
}
// An [AuthorizedIntegration] can validate the claims in a JWT against a set of rules defined by this structure.
//
// JWTs can contain any number of claims, which are represented as a JSON object. A small number of common claims are
// described in RFC7519 (sec 4.1) which defines JWTs, but most claims are entirely arbitrarily defined by the JWT
// issuer.
//
// For example, eg. a claim may be {"sub": "repo:coolguy/forgejo-runner-testrepo:pull_request"} indicating that an OIDC
// token was received from an Actions execution in a specific repo on a specific event.
//
// Validating the claims from a JWT issuer is a critical part of creating a secure [AuthorizedIssuer]. For example,
// assume that we receive a JWT from a public hosting platform like Codeberg. We will validate that it is a claim
// created by the correct Issuer, Codeberg -- but anyone can do that through Forgejo Actions. We will validate that it
// has the correct audience -- but that's an *input* to Forgejo Actions, so anyone can create a claim on Codeberg with
// an arbitrary audience. The rest of the claims contain the critical information about who ran a Forgejo Action, on
// which repository, and in response to which events, and those must be validated to ensure that an authorized issuer is
// correctly authorized.
//
// Following that an example, a minimum claim rule that would be required for securely using Forgejo Actions would be
// something like:
//
// {
// "rules": [{
// "claim": "sub",
// "comparison": "eq",
// "value": "repo:forgejo/website:pull_request"
// }]
// }
//
// This defines a single rule which says that the `sub` claim must be exactly equal to
// "repo:forgejo/website:pull_request". Forgejo Actions would generate this subject when an Action is running on the
// repo forgejo/website in response to the pull_request event.
//
// Some JWT claims are JSON objects. The [ClaimNested] comparison operator can be used to define rules that inspect the
// object within a claim. For example, AWS STS generates a claim "https://sts.amazonaws.com/": {...} with values inside
// an object, like "aws_account". A nested claim can inspect those values:
//
// {
// "rules":[{
// "claim": "https://sts.amazonaws.com/",
// "compare": "nest",
// "nested": {"rules":[
// {"claim": "aws_account", "compare": "eq", "value": "1234567890"},
// {"claim": "lambda_source_function_arn", "compare": "eq", "value": "arn:aws:lambda:ca-central-1:1234567890:function:forgejo-oidc-accepting-test"}
// ]}
// }
//
// ]}
//
// This defines a rule that looks into the "https://sts..." claim and verifies the "aws_account" and
// "lambda_source_function_arn" keys match specific known values.
type ClaimRules struct {
Rules []ClaimRule `json:"rules"`
}
// Defines a single rule that will check the value of one JWT claim.
type ClaimRule struct {
// The target claim, eg. "sub"
Claim string `json:"claim"`
// Comparison rule to use on this claim
Comparison ClaimComparison `json:"compare"`
// For Comparison of ClaimEqual or ClaimGlob, the specific value or glob to match against
Value string `json:"value,omitempty"`
// For ClaimNested, the rules to apply to the nested object
Nested *ClaimRules `json:"nested,omitempty"`
}
type ClaimComparison string
const (
ClaimEqual ClaimComparison = "eq" // exactly equal claim
ClaimGlob ClaimComparison = "glob" // glob match complete claim string
ClaimNested ClaimComparison = "nest" // recurse into a claim that is an map[string]any with it's own data fields
)
func GetAuthorizedIntegration(ctx context.Context, issuer, audience string) (*AuthorizedIntegration, error) {
var ai AuthorizedIntegration
found, err := db.GetEngine(ctx).Where("issuer = ? AND audience = ?", issuer, audience).Get(&ai)
if err != nil {
return nil, err
} else if !found {
return nil, util.ErrNotExist
}
return &ai, nil
}
// Bump the UpdatedUnix field of this authorized integration to now, tracking when it was last used for authentication.
// To reduce database write workload, this is only tracked by one-minute intervals -- the UPDATE statement conditionally
// avoids writes.
func (ai *AuthorizedIntegration) UpdateLastUsed(ctx context.Context) error {
newTime := timeutil.TimeStampNow()
cnt, err := db.GetEngine(ctx).
Table(&AuthorizedIntegration{}).
Where(builder.Eq{"id": ai.ID}).
Where(builder.Lt{"updated_unix": newTime.AddDuration(-1 * time.Minute)}).
NoAutoTime().
Update(map[string]any{"updated_unix": newTime})
if cnt == 1 {
ai.UpdatedUnix = newTime
}
return err
}

View file

@ -0,0 +1,27 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"forgejo.org/models/db"
"forgejo.org/modules/timeutil"
)
// Represents a many-to-many join table which indicates specific repositories (RepoID) that can be accessed by an
// authorized integration (IntegID). An authorized integrations's ResourceAllRepos field must be false for records in
// this table to become active.
//
// Model name is shortend (from AuthorizedIntegrationResourceRepo) to accomodate recreate-tables + MySQL, where the
// "tmp_recreate_" + foreign key index name would exceed the max identifier length.
type AuthorizedIntegResourceRepo struct {
ID int64 `xorm:"pk autoincr"`
IntegID int64 `xorm:"NOT NULL REFERENCES(authorized_integration, id)"` // field name shortened (AuthorizationIntegrationID) for max identifier length
RepoID int64 `xorm:"NOT NULL REFERENCES(repository, id)"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
}
func init() {
db.RegisterModel(new(AuthorizedIntegResourceRepo))
}

View file

@ -0,0 +1,81 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package auth_test
import (
"fmt"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeAuthorizedIntegration(t *testing.T) *auth_model.AuthorizedIntegration {
t.Helper()
ai := &auth_model.AuthorizedIntegration{
UserID: 2,
Scope: auth_model.AccessTokenScopeAll,
ResourceAllRepos: true,
Issuer: "https://example.org/",
Audience: fmt.Sprintf("https://forgejo.example.org/api/actions/%s", gouuid.New().String()),
ClaimRules: &auth_model.ClaimRules{},
}
_, err := db.GetEngine(t.Context()).Insert(ai)
require.NoError(t, err)
return ai
}
func TestGetAuthorizedIntegration(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ai := makeAuthorizedIntegration(t)
get, err := auth_model.GetAuthorizedIntegration(t.Context(), "abc", "123")
require.ErrorIs(t, err, util.ErrNotExist)
assert.Nil(t, get)
get, err = auth_model.GetAuthorizedIntegration(t.Context(), ai.Issuer, ai.Audience)
require.NoError(t, err)
require.NotNil(t, get)
assert.Equal(t, ai.ID, get.ID)
}
func TestAuthorizedIntegrationUpdateLastUsed(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ai := makeAuthorizedIntegration(t)
ai.UpdatedUnix = 0
cnt, err := db.GetEngine(t.Context()).ID(ai.ID).Cols("updated_unix").NoAutoTime().Update(ai)
require.NoError(t, err)
assert.EqualValues(t, 1, cnt)
timeutil.MockSet(time.Unix(1777130023, 0))
defer timeutil.MockUnset()
assert.EqualValues(t, 0, ai.UpdatedUnix)
require.NoError(t, ai.UpdateLastUsed(t.Context()))
assert.EqualValues(t, 1777130023, ai.UpdatedUnix) // object field updated
assert.EqualValues(t, 1777130023, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix)
// nearly immediate redo should have same timestamp due to the 1 minute deduplication:
timeutil.MockSet(time.Unix(1777130025, 0))
require.NoError(t, ai.UpdateLastUsed(t.Context()))
assert.EqualValues(t, 1777130023, ai.UpdatedUnix) // object field not updated
assert.EqualValues(t, 1777130023, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) // database field not updated
// but if it's a little while later..
timeutil.MockSet(time.Unix(1777131139, 0))
require.NoError(t, ai.UpdateLastUsed(t.Context()))
assert.EqualValues(t, 1777131139, ai.UpdatedUnix) // object field updated
assert.EqualValues(t, 1777131139, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) // database field updated
}

View file

@ -0,0 +1,45 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"forgejo.org/modules/timeutil"
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "add authorized_integration tables",
Upgrade: addAuthorizedIntegrationTables,
})
}
func addAuthorizedIntegrationTables(x *xorm.Engine) error {
type ClaimRules struct{}
type AuthorizedIntegration struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL REFERENCES(user, id)"`
Scope string `xorm:"NOT NULL"`
ResourceAllRepos bool `xorm:"NOT NULL"`
Issuer string `xorm:"NOT NULL UNIQUE(s)"`
Audience string `xorm:"NOT NULL UNIQUE(s)"`
ClaimRules *ClaimRules `xorm:"NOT NULL JSON"`
CreatedUnix timeutil.TimeStamp `xorm:"NOT NULL created"`
UpdatedUnix timeutil.TimeStamp `xorm:"NOT NULL updated"`
}
type AuthorizedIntegResourceRepo struct {
ID int64 `xorm:"pk autoincr"`
IntegID int64 `xorm:"NOT NULL REFERENCES(authorized_integration, id)"`
RepoID int64 `xorm:"NOT NULL REFERENCES(repository, id)"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
}
_, err := x.SyncWithOptions(
xorm.SyncOptions{IgnoreDropIndices: true},
new(AuthorizedIntegration),
new(AuthorizedIntegResourceRepo),
)
return err
}