Files
gitocean/models/auth/authorized_integration.go
T
Mathieu FenniakandMathieu Fenniak 70f7260e66 feat: add CLI command 'admin user create-authorized-integration' (#12299)
Allows the creation of an authorized integration as a Forgejo administrator, either for development testing or to support server-automation.  Clipping out the CLI config options, looks like:

```
NAME:
   forgejo admin user create-authorized-integration - Create an authorized integration for a specific user

USAGE:
   forgejo admin user create-authorized-integration [options]

OPTIONS:
   --username string, -u string                               Username
   --issuer string                                            JWT issuer ('iss' claim), example: https://forgejo.example.org/api/actions
   --claim-eq string=string [ --claim-eq string=string ]      Zero-or-more claim equality checks, formatted as claim=value, example: "actor=someuser"
   --claim-glob string=string [ --claim-glob string=string ]  Zero-or-more claim glob checks, formatted as claim=value, example: "sub=repo:forgejo/*:pull_request"
   --scope string [ --scope string ]                          One-or-more scopes to apply to access token, examples: "all", "read:issue", "write:repository" (default: "all")
   --repo string [ --repo string ]                            Zero-or-more specific repositories that can be accessed, or "all" to allow access to all repositories, example: "owner1/repo1" (default: "all")
```

As an example, this will create an authorized integration that will permit Codeberg's Forgejo Actions to generate trusted JWTs that can access the local user `mfenniak`:
```bash
$ ./forgejo admin user create-authorized-integration \
    --username mfenniak \
    --issuer https://codeberg.org/api/actions \
    --claim-eq sub=repo:mfenniak/forgejo-runner-testrepo:pull_request \
    --scope read:user

{
  "message": "Authorized integration was successfully created.",
  "issuer": "https://codeberg.org/api/actions",
  "audience": "u:1:c97d83bc-fa4e-4db3-b898-414cd5b6ce33",
  "claim_rules": [
    {
      "description": "\"sub\" = \"repo:mfenniak/forgejo-runner-testrepo:pull_request\"",
      "claim": "sub",
      "compare": "eq",
      "value": "repo:mfenniak/forgejo-runner-testrepo:pull_request"
    }
  ]
}
```

The output is a JSON document to aid in use in automation.  The `audience` field is the audience generated by Forgejo that must be used by the remote to generate the JWT.  Continuing this example to the client-side, a matching Forgejo Action like this in the `mfenniak/forgejo-runner-testrepo` repo, for a `pull_request` event, then it will be able to access the Forgejo server that the authorized integration was created on like this:

```yaml
on:
  pull_request:

enable-openid-connect: true

jobs:
  job1:
    runs-on: docker
    steps:
      - name: Fetch JWT
        id: jwt
        run: |
          set -eux -o pipefail
          set +x
          jwt=$(curl --fail \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=u:1:c97d83bc-fa4e-4db3-b898-414cd5b6ce33" \
            | jq -r ".value")
          echo "::add-mask::$jwt"
          set -x
          echo "jwt=$jwt" >> $FORGEJO_OUTPUT

      - name: API call to Forgejo
        run: |
          curl \
            -v --fail \
            -H "Authorization: bearer ${{ steps.jwt.outputs.jwt }}" \
            "https://example.org/api/v1/user" | jq
```

CLI command is tested manually.  Supporting functions have associated unit tests.

## 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

### 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.
    - CLI update should be automatic in docs -- more detailed Authorized Integration documentation is on my project plan.

### 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/12299
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
2026-04-28 21:32:45 +02:00

182 lines
7.8 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/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).
// 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
}
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
}
// 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
}