gitforge/models/auth/authorized_integration.go
Mathieu Fenniak c8d24ff06a feat: enable auth to git HTTP via authorized integrations (#12715)
Allow authentication to git HTTP & git LFS via an authorized integration.

This is the first step in getting rid of OAuth, basic auth, etc.'s usage of [`isGitRawOrAttachPath(req)`](26f18a94ee/services/auth/method/basic.go (L38-L40)).  I don't want to follow that pattern of HTTP route matching in the authentication method, so I've broken the HTTP routes related to git functionality out to using a separate authentication middleware in the top-level `web.Routes` handler.  As this approach is expanded to the other endpoints in order to add support to them for authorized integrations, eventually it will be possible to remove this URL matching completely and just rely on middleware installation.

## 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/12715
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2026-05-25 19:33:36 +02:00

297 lines
13 KiB
Go

// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"context"
"errors"
"fmt"
"time"
"forgejo.org/models/db"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
gouuid "github.com/google/uuid"
"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).
Name string // short name for lists of authorized integrations
Description string `xorm:"LONGTEXT"` // long description, optional to document relevant details of the integration
// Which UI to use for view/edit of this Authorized Integration. Authorized Integrations' functional behaviour is
// defined by other fields, such as the Issuer, Audience, ClaimRules. The UI field only defines how this record can
// be interacted with by the user in order to provide user-friendly access for specific systems -- like Forgejo
// Actions. Within the potential scope of the UI is any user interaction with the Authorized Integration -- web
// create, read, update, API, CLI.
//
// The UI field must never be used to make functional decisions about evaluating JWTs. It must always be possible
// to convert an Authorized Integration to a "generic" UI (for customization that the UI doesn't support). The
// intent of this design is that, the Authorized Integration system is complicated in its claim rules, but they
// always fully define the behaviour in a transparent manner.
UI AuthorizedIntegrationUI `xorm:"NOT NULL default('generic')"`
// 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",
// "compare": "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 Comparison of ClaimIn or ClaimGlobIn, an array of values to match against
Values []string `json:"values,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
ClaimIn ClaimComparison = "in" // exactly equal any of the options in a list
ClaimGlob ClaimComparison = "glob" // glob match complete claim string
ClaimGlobIn ClaimComparison = "glob-in" // glob match any of the options in a list
ClaimNested ClaimComparison = "nest" // recurse into a claim that is an map[string]any with it's own data fields
)
type AuthorizedIntegrationUI string
const (
// Generic UI which allows the user to view and edit claim rules directly to support integrations that Forgejo
// doesn't have a user-friendly UI to support.
AuthorizedIntegrationUIGeneric AuthorizedIntegrationUI = "generic"
// UI specific to Actions that are running on this local Forgejo instance accessing itself.
AuthorizedIntegrationUIForgejoActionsLocal AuthorizedIntegrationUI = "forgejo-actions-local"
)
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
}
func GetAuthorizedIntegrationByUI(ctx context.Context, ownerID int64, aiUI AuthorizedIntegrationUI, aiID int64) (*AuthorizedIntegration, error) {
var ai AuthorizedIntegration
found, err := db.GetEngine(ctx).Where("id = ? AND user_id = ? AND ui = ?", aiID, ownerID, aiUI).Get(&ai)
if err != nil {
return nil, err
} else if !found {
return nil, util.ErrNotExist
}
return &ai, nil
}
func InsertAuthorizedIntegration(ctx context.Context, ai *AuthorizedIntegration) error {
if ai.Audience != "" {
return errors.New("audience cannot be provided, and must be generated by NewAuthorizedIntegration")
} else if err := ai.generateAudience(); err != nil {
return err
}
_, err := db.GetEngine(ctx).Insert(ai)
return err
}
func UpdateAuthorizedIntegration(ctx context.Context, ai *AuthorizedIntegration) error {
// NoAutoTime -- UpdatedUnix is used to track the last used time, don't update it when editing
// AllCols -- ensure ResourceAllRepo can be set to false
rowsImpacted, err := db.GetEngine(ctx).ID(ai.ID).NoAutoTime().AllCols().Update(ai)
if rowsImpacted == 0 {
return fmt.Errorf("authorized integration update affected 0 records: %w", util.ErrNotExist)
} else if rowsImpacted != 1 {
return fmt.Errorf("authorized integration update affected %d records", rowsImpacted)
}
return err
}
// 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
}
// Generates the `aud` claim that the remote JWT generator must use to match this authorized integration. The `aud`
// claim is an arbitrary value in a JWT claim, but Forgejo is faced with a few hard and soft requirements:
//
// - Hard requirement: each authorized integration must have a unique `aud`, as it is used to find the DB record that
// authenticates a request.
// - If authentication is failing, being able to inspect the `aud` claim can be useful to identify the intent.
// - Inspection should have a stable meaning -- eg. if it included the username, and the user was renamed, the `aud`
// value which can't be changed would continue to reference the old username causing confusion when inspecting it.
// - Forgejo & GitHub Actions uses a URL $ACTIONS_ID_TOKEN_REQUEST_URL&audience=... to generate a JWT for the running
// action, so it should only consist of safe characters for URL encoding.
// - It should be relatively short, as it's encoded into the JWT and increases its size.
//
// Meeting these requirements decently well is a combination of the owner's ID, a guid, and a "u:" prefix that makes the
// fact that it's an `aud` claim value a little bit identifiable.
func (ai *AuthorizedIntegration) generateAudience() error {
if ai.UserID == 0 {
return errors.New("UserID must be initialized")
}
ai.Audience = fmt.Sprintf("u:%d:%s", ai.UserID, gouuid.New().String())
return nil
}
func (ai *AuthorizedIntegration) HasRecentActivity() bool {
return ai.HasBeenUsed() && ai.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
}
func (ai *AuthorizedIntegration) HasBeenUsed() bool {
return ai.UpdatedUnix > ai.CreatedUnix
}
type ListAuthorizedIntegrationOptions struct {
db.ListOptions
UserID optional.Option[int64]
}
func (opts ListAuthorizedIntegrationOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if has, userID := opts.UserID.Get(); has {
cond = cond.And(builder.Eq{"user_id": userID})
}
return cond
}
func (opts ListAuthorizedIntegrationOptions) ToOrders() string {
return "created_unix DESC"
}
func ParseAuthorizedIntegrationUI(ui string) (AuthorizedIntegrationUI, error) {
switch ui {
case string(AuthorizedIntegrationUIGeneric):
return AuthorizedIntegrationUIGeneric, nil
case string(AuthorizedIntegrationUIForgejoActionsLocal):
return AuthorizedIntegrationUIForgejoActionsLocal, nil
}
return AuthorizedIntegrationUI(""), fmt.Errorf("invalid authorized integration UI: %q", ui)
}
// Delete an authorized integration by ID. Must only succeed if the authorized integration identified is owned by the
// user provided.
func DeleteAuthorizedIntegrationByID(ctx context.Context, id, userID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
// Delete doesn't take into account userID, but will be rolled back by the transaction if the user ID isn't
// correct. Needs to occur first due to foreign key.
if err := db.DeleteBeans(ctx,
&AuthorizedIntegResourceRepo{IntegID: id},
); err != nil {
return fmt.Errorf("DeleteBeans: %w", err)
}
cnt, err := db.GetEngine(ctx).
ID(id).
Delete(&AuthorizedIntegration{
UserID: userID,
})
if err != nil {
return err
} else if cnt != 1 {
return fmt.Errorf("authorized integration %d does not exist: %w", id, util.ErrNotExist)
}
return nil
})
}