Files
gitocean/routers/web/user/setting/authorized_integrations.go
T
Mathieu FenniakandMathieu Fenniak cf087a2f12 feat: ability to edit authorized integration in web UI (#12601)
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:

![Screenshot 2026-05-16 at 15-43-20 Authorized Integrations - Forgejo Beyond coding. We Forge](/attachments/ffaf60e2-3652-429b-a815-b339100f05f8)

## 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>
2026-05-17 18:33:39 +02:00

404 lines
13 KiB
Go

// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
access_model "forgejo.org/models/perm/access"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/base"
"forgejo.org/modules/json"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/util"
"forgejo.org/modules/web"
auth_service "forgejo.org/services/auth"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"xorm.io/builder"
)
const (
tplAuthorizedIntegrationList base.TplName = "user/settings/authorized_integrations"
tplAuthorizedIntegrationViewGeneric base.TplName = "user/settings/authorized_integrations/generic/view"
)
func ListAuthorizedIntegrations(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
ais, err := db.Find[auth_model.AuthorizedIntegration](ctx,
auth_model.ListAuthorizedIntegrationOptions{UserID: optional.Some(ctx.Doer.ID)})
if err != nil {
ctx.ServerError("ListAuthorizedIntegrations", err)
return
}
ctx.Data["AuthorizedIntegrations"] = ais
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationList)
}
type AuthorizedIntegrationForm struct {
// Top data in UI, descriptive information about the Authorized Integration:
Name string
Description string
Audience string
// Middle data in UI, how JWTs are validated by this Authorized Integration:
Issuer string // Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
ClaimRules string // Future: ClaimRules is only required when aiUI == "generic"
// Bottom data in the UI, what authorization is permitted by this Authorized Integration:
Resource string // all, public-only, repo-specific
SelectedRepo []string // slice of ownername/reponame for repo-specific
ScopeAll bool
Scope []string
// Values used for repo-specific repository multi-select UI, not stored in Authorized Integration:
RepoSearch string
AddSelectedRepo string // add a repo to SelectedRepo
RemoveSelectedRepo string // remove a repo from SelectedRepo
Page int // repo search page
SetPage int // repo search buttons
}
func getAuthorizedIntegration(ctx *context.Context) *auth_model.AuthorizedIntegration {
aiUIString := ctx.Params("ui")
aiUI, err := auth_model.ParseAuthorizedIntegrationUI(aiUIString)
if err != nil {
ctx.NotFound("ParseAuthorizedIntegrationUI", err)
return nil
}
aiID := ctx.ParamsInt64("id")
ai, err := auth_model.GetAuthorizedIntegrationByUI(ctx, ctx.Doer.ID, aiUI, aiID)
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound("GetAuthorizedIntegrationByUI", err)
return nil
} else if err != nil {
ctx.ServerError("GetAuthorizedIntegrationByUI", err)
return nil
}
return ai
}
func EditAuthorizedIntegration(ctx *context.Context) {
ai := getAuthorizedIntegration(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
if form.Audience == "" { // empty GET; first load of the page
repos, err := auth_model.GetRepositoriesAccessibleWithIntegration(ctx, ai.ID)
if err != nil {
ctx.ServerError("GetRepositoriesAccessibleWithIntegration", err)
return
}
form, err = copyAuthorizedIntegrationToForm(ctx, ai, repos)
if err != nil {
ctx.ServerError("copyAuthorizedIntegrationToForm", err)
return
}
}
ctx.Data["Form"] = form
EditAuthorizedIntegrationRenderCommon(ctx)
}
func EditAuthorizedIntegrationPost(ctx *context.Context) {
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
ctx.Data["Form"] = form // make form available for template render on any error
ai := getAuthorizedIntegration(ctx)
if ctx.Written() {
return
}
rr, err := copyFormToAuthorizedIntegration(ctx, form, ai)
if err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
}
if err := auth_service.UpdateAuthorizedIntegration(ctx, ai, rr); err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
}
ctx.Redirect(setting.AppSubURL + "/user/settings/authorized-integrations")
}
func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
var errMissingField *auth_service.MissingFieldError
switch {
case errors.As(err, &errMissingField):
switch errMissingField.Field {
case "Name":
ctx.Data["Err_Name"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.name.required"), true)
default:
// Unrecognized field; fallback to server error handling.
ctx.ServerError("UpdateAuthorizedIntegration", err)
return
}
EditAuthorizedIntegrationRenderCommon(ctx)
return
case errors.Is(err, auth_service.ErrInvalidIssuer):
ctx.Data["Err_Issuer"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.issuer.invalid", err.Error()), true)
EditAuthorizedIntegrationRenderCommon(ctx)
return
case errors.Is(err, auth_service.ErrInvalidClaimRules):
ctx.Data["Err_ClaimRules"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.claim_rules.invalid", err.Error()), true)
EditAuthorizedIntegrationRenderCommon(ctx)
return
case errors.Is(err, authz.ErrSpecifiedReposNone):
ctx.Data["Err_SelectedRepo"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.specified_repos_none"), true)
EditAuthorizedIntegrationRenderCommon(ctx)
return
case errors.Is(err, authz.ErrSpecifiedReposNoPublicOnly):
ctx.Data["Err_SelectedRepo"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.specified_repos_and_public_only"), true)
EditAuthorizedIntegrationRenderCommon(ctx)
return
case errors.Is(err, authz.ErrSpecifiedReposInvalidScope):
ctx.Data["Err_SelectedRepo"] = true
ctx.Data["Err_Scope"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.specified_repos_and_invalid_scope"), true)
EditAuthorizedIntegrationRenderCommon(ctx)
return
}
ctx.ServerError("UpdateAuthorizedIntegration", err)
}
func copyAuthorizedIntegrationToForm(ctx *context.Context, ai *auth_model.AuthorizedIntegration, rr []*auth_model.AuthorizedIntegResourceRepo) (*AuthorizedIntegrationForm, error) {
form := &AuthorizedIntegrationForm{
Name: ai.Name,
Description: ai.Description,
Audience: ai.Audience,
Issuer: ai.Issuer, // Future: Issuer is only required when ai.UI == "generic"
}
if ai.ResourceAllRepos {
publicOnly, err := ai.Scope.PublicOnly()
if err != nil {
return nil, err
}
if publicOnly {
form.Resource = "public-only"
} else {
form.Resource = "all"
}
} else {
form.Resource = "repo-specific"
}
form.Scope = ai.Scope.StringSlice()
scopeAll, err := ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
if err != nil {
return nil, err
}
form.ScopeAll = scopeAll
// Future: ClaimRules is only required when aiUI == "generic"
claimRulesJSON, err := json.MarshalIndent(ai.ClaimRules, "", " ")
if err != nil {
return nil, err
}
form.ClaimRules = string(claimRulesJSON)
form.SelectedRepo = []string{}
if len(rr) != 0 {
repoIDs := make([]int64, len(rr))
for _, r := range rr {
repoIDs = append(repoIDs, r.RepoID)
}
repos, err := db.GetByIDs(ctx, "id", repoIDs, &repo_model.Repository{})
if err != nil {
return nil, err
}
for _, r := range rr {
repo := repos[r.RepoID]
// Repos associated with an authorized integration should already be visible to the owner, but it's possible
// that access has changed, such as a removed collaborator on a repo -- don't provide info on that repo if
// so.
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
return nil, err
}
if permission.HasAccess() {
form.SelectedRepo = append(form.SelectedRepo, fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name))
}
}
}
return form, nil
}
func copyFormToAuthorizedIntegration(ctx *context.Context, form *AuthorizedIntegrationForm, ai *auth_model.AuthorizedIntegration) ([]*auth_model.AuthorizedIntegResourceRepo, error) {
ai.Name = form.Name
ai.Description = form.Description
// ui=Generic, to be refactored later
ai.Issuer = form.Issuer
var claimRules *auth_model.ClaimRules
reader := bytes.NewReader([]byte(form.ClaimRules))
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields() // prevent typo fields from being ignored to make errors easier to identify
if err := decoder.Decode(&claimRules); err != nil {
return nil, fmt.Errorf("%w: %w", auth_service.ErrInvalidClaimRules, err)
}
// json.Decoder doesn't guarantee that all of the reader is consumed, which can lead to weird situations
// where the UI appears to work correctly if extra content is in the form field, but it won't be parsed,
// misleading users. Detect if anything other than io.EOF comes out of further decodings:
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("%w: unexpected trailing content: %s", auth_service.ErrInvalidClaimRules, extra)
}
return nil, fmt.Errorf("%w: error after JSON value: %w", auth_service.ErrInvalidClaimRules, err)
}
ai.ClaimRules = claimRules
scopeRaw := strings.Join(form.Scope, ",")
var resourceRepos []*auth_model.AuthorizedIntegResourceRepo
switch form.Resource {
case "all":
ai.ResourceAllRepos = true
case "public-only":
ai.ResourceAllRepos = true
scopeRaw = fmt.Sprintf("%s,%s", scopeRaw, auth_model.AccessTokenScopePublicOnly)
case "repo-specific":
ai.ResourceAllRepos = false
selectedRepos, err := getSelectedRepos(ctx, form.SelectedRepo)
if err != nil {
return nil, err
}
for _, repo := range selectedRepos {
resourceRepos = append(resourceRepos, &auth_model.AuthorizedIntegResourceRepo{RepoID: repo.ID})
}
}
scope, err := auth_model.AccessTokenScope(scopeRaw).Normalize()
if err != nil {
return nil, err
}
ai.Scope = scope
return resourceRepos, nil
}
func EditAuthorizedIntegrationRenderCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
categories := []string{
"activitypub",
"issue",
"misc",
"notification",
"organization",
"package",
"repository",
"user",
}
if ctx.Doer.IsAdmin {
categories = append(categories, "admin")
}
slices.Sort(categories)
ctx.Data["Categories"] = categories
repoMultiSelect(ctx)
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationViewGeneric)
}
func repoMultiSelect(ctx *context.Context) {
form := ctx.Data["Form"].(*AuthorizedIntegrationForm)
if form.AddSelectedRepo != "" {
form.SelectedRepo = append(form.SelectedRepo, form.AddSelectedRepo)
}
if form.RemoveSelectedRepo != "" {
form.SelectedRepo = slices.DeleteFunc(
form.SelectedRepo,
func(r string) bool { return r == form.RemoveSelectedRepo },
)
}
selectedRepos, err := getSelectedRepos(ctx, form.SelectedRepo)
if err != nil {
ctx.Error(http.StatusBadRequest, "getSelectedRepos")
return
}
ctx.Data["SelectedRepos"] = selectedRepos
repoSearchText := form.RepoSearch
page := 1
// Pagination on the repo search has form submit buttons that send the `set_page` param. It's then encoded into the
// page in the hidden input `page` which we fall back to, if anything else causes a form get (eg. adding or removing
// a repo).
if form.SetPage > 0 {
page = form.SetPage
} else if form.Page > 0 {
page = form.Page
}
pageSize := 10
repoSearch := &repo_model.SearchRepoOptions{
Actor: ctx.Doer,
Keyword: repoSearchText,
Private: true,
Archived: optional.Some(false),
// Restrict repositories to those owned by, or collaborated with, by the user. Repo-specific access tokens
// could theoretically be created on any public repository as well, but there wouldn't be much point to that and
// it would really balloon the search results to an impractical number of repos.
OwnerID: ctx.Doer.ID,
ListOptions: db.ListOptions{
Page: page,
PageSize: pageSize,
},
OrderBy: db.SearchOrderByAlphabetically, // match sorting in getSelectedRepos for consistency
}
cond := repo_model.SearchRepositoryCondition(repoSearch)
// Exclude all the repos that are currently in `form.SelectedRepo` from the search, by omitting them from the search
// condition. This prevents the UI from displaying the same repo on the left and right, and maintains the repo
// search and page-size correctly.
for _, selected := range selectedRepos {
cond = cond.And(builder.Neq{"id": selected.ID})
}
repos, count, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
if err != nil {
log.Error("SearchRepository: %v", err)
ctx.JSON(http.StatusInternalServerError, nil)
return
}
ctx.Data["Repos"] = repos
pager := context.NewPagination(int(count), pageSize, page, 3)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
}