Extends the UI introduced in #12558 to have edit capabilities. (not in scope: "Add" for a new Authorized Integration will be the next update to this UI; `create-authorized-integration` CLI is still the only way to create a new record) This PR includes a few refactoring steps. The goal of these steps is to have `services/auth` be a single entrypoint for validating, inserting, or updating an authorized integration. Some logic is moved out of `services/authz` because it is not authorization related, and some is moved out of `services/auth/method` to allow it to be reused during validation without creating a cyclical module dependency. This PR also adds comprehensive validation to the more complex fields in the authorized integration, such as the issuer and claim rules. This validation applies to the `forgejo admin user create-authorized-integration` CLI as well. The visible UI is the same as #12558, but with a "Save" button, and the ability to display errors:  ## 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... - [x] `make pr-go` before pushing ### Tests for JavaScript changes - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [x] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### 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. - Documentation is on my TODO list and will be completed before release. ### Release notes - [x] 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. - [ ] 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/12601 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
272 lines
12 KiB
Go
272 lines
12 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",
|
|
// "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 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)
|
|
}
|