mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-20 01:36:37 +00:00
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:  ## 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>
This commit is contained in:
parent
8a1021e2a0
commit
cf087a2f12
33 changed files with 1710 additions and 413 deletions
|
|
@ -222,6 +222,9 @@ forgejo.org/modules/zstd
|
|||
forgejo.org/routers/web/org
|
||||
MustEnableProjects
|
||||
|
||||
forgejo.org/services/auth
|
||||
RegisterInternalIssuerForTesting
|
||||
|
||||
forgejo.org/services/context
|
||||
GetPrivateContext
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ packages:
|
|||
forgejo.org/modules/nosql:
|
||||
config:
|
||||
filename: mocks.go # make mocks public so that external packages can use
|
||||
forgejo.org/services/auth/method:
|
||||
forgejo.org/services/auth:
|
||||
config:
|
||||
filename: mocks.go # make mocks public so that external packages can use
|
||||
forgejo.org/services/authz:
|
||||
config:
|
||||
filename: authorization_reducer_mock.go # make mocks public so that external packages can use
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ import (
|
|||
"strings"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/repo"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/services/authz"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
|
@ -195,22 +194,8 @@ func runCreateAuthorizedIntegration(ctx context.Context, c *cli.Command) error {
|
|||
for i := range repos {
|
||||
rr[i] = &auth_model.AuthorizedIntegResourceRepo{RepoID: repos[i].ID}
|
||||
}
|
||||
if err := authz.ValidateAuthorizedIntegration(ai, rr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := auth_model.InsertAuthorizedIntegration(ctx, ai); err != nil {
|
||||
return err
|
||||
}
|
||||
if !allRepos {
|
||||
if err := auth_model.InsertAuthorizedIntegrationResourceRepos(ctx, ai.ID, rr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if err := auth_service.InsertAuthorizedIntegration(ctx, ai, rr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,18 @@ func InsertAuthorizedIntegration(ctx context.Context, ai *AuthorizedIntegration)
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -54,3 +54,12 @@ func InsertAuthorizedIntegrationResourceRepos(ctx context.Context, aiID int64, r
|
|||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateAuthorizedIntegrationResourceRepos(ctx context.Context, aiID int64, resources []*AuthorizedIntegResourceRepo) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if _, err := db.GetEngine(ctx).Delete(&AuthorizedIntegResourceRepo{IntegID: aiID}); err != nil {
|
||||
return err
|
||||
}
|
||||
return InsertAuthorizedIntegrationResourceRepos(ctx, aiID, resources)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,3 +175,21 @@ func TestListAuthorizedIntegrationOptions(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Empty(t, ais)
|
||||
}
|
||||
|
||||
func TestUpdateAuthorizedIntegration(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ai := makeAuthorizedIntegration(t)
|
||||
ai.Name = "Hello, world!"
|
||||
ai.ResourceAllRepos = false
|
||||
createdUnix := ai.CreatedUnix
|
||||
updatedUnix := ai.UpdatedUnix
|
||||
|
||||
require.NoError(t, auth_model.UpdateAuthorizedIntegration(t.Context(), ai))
|
||||
|
||||
fromDB := unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID})
|
||||
assert.Equal(t, "Hello, world!", fromDB.Name)
|
||||
assert.False(t, fromDB.ResourceAllRepos)
|
||||
assert.Equal(t, createdUnix, fromDB.CreatedUnix)
|
||||
assert.Equal(t, updatedUnix, fromDB.UpdatedUnix) // not changed -- used to track usage for authentication
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type Encoder interface {
|
|||
// Decoder represents a decoder for json
|
||||
type Decoder interface {
|
||||
Decode(v any) error
|
||||
DisallowUnknownFields()
|
||||
}
|
||||
|
||||
// Interface represents an interface to handle json data
|
||||
|
|
|
|||
|
|
@ -325,8 +325,9 @@
|
|||
"settings.authorized_integration.ui.generic": "Generic JWT",
|
||||
"settings.authorized_integration.ui.forgejo_actions_local": "Forgejo Actions (Local)",
|
||||
"settings.authorized_integration.none": "No authorized integrations currently configured.",
|
||||
"settings.authorized_integration.view": "View",
|
||||
"settings.authorized_integration.view_page_title": "Authorized Integration <b>%s</b>",
|
||||
"settings.authorized_integration.edit": "Edit",
|
||||
"settings.authorized_integration.edit_page_title": "Authorized Integration <b>%s</b>",
|
||||
"settings.authorized_integration.save": "Save authorized integration",
|
||||
"settings.authorized_integration.field.name": "Name",
|
||||
"settings.authorized_integration.field.description": "Description",
|
||||
"settings.authorized_integration.field.description.placeholder": "Used to publish packages when ...",
|
||||
|
|
@ -336,6 +337,12 @@
|
|||
"settings.authorized_integration.claims.generic": "Generic JWT Rules",
|
||||
"settings.authorized_integration.perms.title": "Capabilities Permitted",
|
||||
"settings.authorized_integration.copy_audience": "Copy audience to clipboard",
|
||||
"settings.authorized_integration.name.required": "Authorized integration name is required.",
|
||||
"settings.authorized_integration.issuer.invalid": "Issuer validation failed: %s",
|
||||
"settings.authorized_integration.claim_rules.invalid": "Claim Rules validation failed: %s",
|
||||
"settings.authorized_integration.specified_repos_none": "Authorized integrations with specified repositories must have at least one repository.",
|
||||
"settings.authorized_integration.specified_repos_and_public_only": "Authorized integrations with specified repositories cannot be combined with the public-only scope.",
|
||||
"settings.authorized_integration.specified_repos_and_invalid_scope": "Authorized integrations with specified repositories can only be used with the read:issue, write:issue, read:repository, and write:repository scopes.",
|
||||
"webauthn.insert_key": "Insert your security key",
|
||||
"webauthn.sign_in": "Press the button on your security key. If your security key has no button, re-insert it.",
|
||||
"webauthn.press_button": "Please press the button on your security key…",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"forgejo.org/modules/web"
|
||||
web_types "forgejo.org/modules/web/types"
|
||||
actions_service "forgejo.org/services/actions"
|
||||
auth_method "forgejo.org/services/auth/method"
|
||||
"forgejo.org/services/auth"
|
||||
"forgejo.org/services/context"
|
||||
)
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ func init() {
|
|||
web.RegisterResponseStatusProvider[*OIDCContext](func(req *http.Request) web_types.ResponseStatusProvider {
|
||||
return req.Context().Value(oidcContextKey).(*OIDCContext)
|
||||
})
|
||||
auth_method.RegisterInternalIssuer("api/actions", internalIssuer{})
|
||||
auth.RegisterInternalIssuer("api/actions", internalIssuer{})
|
||||
}
|
||||
|
||||
func OIDCContexter() func(next http.Handler) http.Handler {
|
||||
|
|
|
|||
|
|
@ -34,13 +34,12 @@ const (
|
|||
tplAccessTokenEdit base.TplName = "user/settings/access_token_edit"
|
||||
)
|
||||
|
||||
func getSelectedRepos(ctx *context.Context, selectedReposRaw []string) []*repo_model.Repository {
|
||||
func getSelectedRepos(ctx *context.Context, selectedReposRaw []string) ([]*repo_model.Repository, error) {
|
||||
ownerAndName := make([][2]string, len(selectedReposRaw))
|
||||
for i, selected := range selectedReposRaw {
|
||||
split := strings.SplitN(selected, "/", 2) // ownername/reponame
|
||||
if len(split) != 2 {
|
||||
ctx.Error(http.StatusBadRequest, fmt.Sprintf("invalid selected_repo: %s", selected))
|
||||
return nil
|
||||
return nil, fmt.Errorf("invalid selected_repo: %s", selected)
|
||||
}
|
||||
ownerAndName[i] = [2]string{split[0], split[1]}
|
||||
}
|
||||
|
|
@ -54,29 +53,26 @@ func getSelectedRepos(ctx *context.Context, selectedReposRaw []string) []*repo_m
|
|||
cond := repo_model.SearchRepositoryCondition(repoSearch)
|
||||
repos, _, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
|
||||
if err != nil {
|
||||
ctx.ServerError("SearchRepositoryByCondition", err)
|
||||
return nil
|
||||
return nil, err
|
||||
} else if len(repos) != len(selectedReposRaw) {
|
||||
// One or more of the repositories couldn't be found by search by owner & name.
|
||||
ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName") // keep in sync w/ !permission.HasAccess below
|
||||
return nil
|
||||
// Keep error in sync w/ ~permission.HasAccess below to avoid data existence probing.
|
||||
return nil, fmt.Errorf("one or more of the repositories couldn't be found by owner & name")
|
||||
}
|
||||
|
||||
selectedRepos := make([]*repo_model.Repository, len(selectedReposRaw))
|
||||
for i, repo := range repos {
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserRepoPermission", err)
|
||||
return nil
|
||||
return nil, err
|
||||
} else if !permission.HasAccess() {
|
||||
// Prevent data existence probing -- ensure this error is the exact same as the (len(repos) !=
|
||||
// len(selectedReposRaw)) case above
|
||||
ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName")
|
||||
return nil
|
||||
return nil, fmt.Errorf("one or more of the repositories couldn't be found by owner & name")
|
||||
}
|
||||
selectedRepos[i] = repo
|
||||
}
|
||||
return selectedRepos
|
||||
return selectedRepos, nil
|
||||
}
|
||||
|
||||
func loadAccessTokenCreateData(ctx *context.Context) {
|
||||
|
|
@ -136,8 +132,9 @@ func loadAccessTokenCreateData(ctx *context.Context) {
|
|||
} else if isPost {
|
||||
selectedReposRaw = postForm.SelectedRepo
|
||||
}
|
||||
selectedRepos := getSelectedRepos(ctx, selectedReposRaw)
|
||||
if ctx.Written() {
|
||||
selectedRepos, err := getSelectedRepos(ctx, selectedReposRaw)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusBadRequest, "getSelectedRepos")
|
||||
return
|
||||
}
|
||||
ctx.Data["SelectedRepos"] = selectedRepos
|
||||
|
|
@ -273,8 +270,9 @@ func AccessTokenCreatePost(ctx *context.Context) {
|
|||
t.Scope = newScope
|
||||
case "repo-specific":
|
||||
t.ResourceAllRepos = false
|
||||
selectedRepos := getSelectedRepos(ctx, form.SelectedRepo)
|
||||
if ctx.Written() {
|
||||
selectedRepos, err := getSelectedRepos(ctx, form.SelectedRepo)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusBadRequest, "getSelectedRepos")
|
||||
return
|
||||
}
|
||||
for _, repo := range selectedRepos {
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ func TestAccessTokenCreatePost(t *testing.T) {
|
|||
SelectedRepo: []string{"org17/big_test_private_4000000_does_not_exist"},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, resp.Result().StatusCode)
|
||||
assert.Equal(t, "GetRepositoryByOwnerAndName\n", resp.Body.String())
|
||||
assert.Equal(t, "getSelectedRepos\n", resp.Body.String())
|
||||
})
|
||||
|
||||
t.Run("non-visible repo selected via IDOR", func(t *testing.T) {
|
||||
|
|
@ -154,6 +154,6 @@ func TestAccessTokenCreatePost(t *testing.T) {
|
|||
SelectedRepo: []string{"user30/empty"}, // private repo, user2 has no visibility
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, resp.Result().StatusCode)
|
||||
assert.Equal(t, "GetRepositoryByOwnerAndName\n", resp.Body.String()) // should be exact same response as "invalid repo selected" case
|
||||
assert.Equal(t, "getSelectedRepos\n", resp.Body.String()) // should be exact same response as "invalid repo selected" case
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,30 @@
|
|||
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 (
|
||||
|
|
@ -38,53 +51,156 @@ func ListAuthorizedIntegrations(ctx *context.Context) {
|
|||
}
|
||||
|
||||
type AuthorizedIntegrationForm struct {
|
||||
Name string
|
||||
Description string
|
||||
Audience string
|
||||
Resource string // all, public-only, repo-specific
|
||||
// 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
|
||||
SelectedRepo []string // slice of ownername/reponame
|
||||
|
||||
// Future: ClaimRules is only required when aiUI == "generic"
|
||||
ClaimRules string
|
||||
// Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
|
||||
Issuer 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 ViewAuthorizedIntegration(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
|
||||
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
Issuer: ai.Issuer, // Future: Issuer is only required when ai.UI == "generic"
|
||||
}
|
||||
|
||||
if ai.ResourceAllRepos {
|
||||
publicOnly, err := ai.Scope.PublicOnly()
|
||||
if err != nil {
|
||||
ctx.ServerError("PublicOnly", err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
if publicOnly {
|
||||
form.Resource = "public-only"
|
||||
|
|
@ -96,23 +212,105 @@ func ViewAuthorizedIntegration(ctx *context.Context) {
|
|||
}
|
||||
|
||||
form.Scope = ai.Scope.StringSlice()
|
||||
form.ScopeAll, err = ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
|
||||
scopeAll, err := ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasScope", err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
form.ScopeAll = scopeAll
|
||||
|
||||
// Future: ClaimRules is only required when aiUI == "generic"
|
||||
claimRulesJSON, err := json.MarshalIndent(ai.ClaimRules, "", " ")
|
||||
if err != nil {
|
||||
ctx.ServerError("MarshalIndent", err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
form.ClaimRules = string(claimRulesJSON)
|
||||
|
||||
// FIXME: form.SelectedRepo
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Form"] = form
|
||||
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",
|
||||
|
|
@ -130,5 +328,76 @@ func ViewAuthorizedIntegration(ctx *context.Context) {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -672,7 +672,9 @@ func registerRoutes(m *web.Route) {
|
|||
})
|
||||
|
||||
m.Group("/authorized-integrations", func() {
|
||||
m.Get("/{ui}/{id}", user_setting.ViewAuthorizedIntegration)
|
||||
m.Combo("/{ui}/{id}").
|
||||
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegration).
|
||||
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegrationPost)
|
||||
m.Get("", user_setting.ListAuthorizedIntegrations)
|
||||
})
|
||||
|
||||
|
|
|
|||
344
services/auth/authorized_integration.go
Normal file
344
services/auth/authorized_integration.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/cache"
|
||||
"forgejo.org/modules/hostmatcher"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/proxy"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/authz"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthorizedIntegrationBadUI = errors.New("invalid authorized integration UI")
|
||||
ErrInvalidIssuer = errors.New("invalid issuer")
|
||||
ErrInvalidClaimRules = errors.New("invalid claim rules")
|
||||
|
||||
// Authorized Integration's HTTP client for remote OIDC metadata and key fetches:
|
||||
aiHTTPClient *http.Client
|
||||
initHTTPClient sync.Once
|
||||
|
||||
// Allow mocking / overridding during tests:
|
||||
GetAuthorizedIntegrationHTTPClient = func() *http.Client {
|
||||
initHTTPClient.Do(initAuthorizedIntegrationHTTPClient)
|
||||
return aiHTTPClient
|
||||
}
|
||||
GetAuthorizedIntegrationCache = cache.GetCache
|
||||
)
|
||||
|
||||
// Restrict document size to prevent resource exhaustion attack with a malicious authorized integration; largest
|
||||
// real-world openid-configuration observed is about 1kB, largest JWKS is 6kB, so for both cases 16kB should be
|
||||
// sufficient. If this needs to change in the future, it could be moved to a config setting -- but until a reason comes
|
||||
// up it seems reasonable to keep microscopic settings out-of-sight.
|
||||
const authorizedIntegrationRequestBodyLimit = int64(16 * 1024)
|
||||
|
||||
func initAuthorizedIntegrationHTTPClient() {
|
||||
blockList := hostmatcher.ParseSimpleMatchList("authorized_integration.BLOCKED_DOMAINS", setting.AuthorizedIntegration.BlockedDomains)
|
||||
|
||||
allowList := hostmatcher.ParseSimpleMatchList("authorized_integration.ALLOWED_DOMAINS", setting.AuthorizedIntegration.AllowedDomains)
|
||||
if allowList.IsEmpty() {
|
||||
// the default policy is that authorized integrations can access external hosts
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinExternal)
|
||||
}
|
||||
if setting.AuthorizedIntegration.AllowLocalNetworks {
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
|
||||
}
|
||||
|
||||
aiHTTPClient = &http.Client{
|
||||
Timeout: setting.AuthorizedIntegration.RequestTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: proxy.Proxy(),
|
||||
DialContext: hostmatcher.NewDialContext("authorized_integration", allowList, blockList, setting.Proxy.ProxyURLFixed),
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// It might be possible to come up with some reasonable capability to support redirects -- such as
|
||||
// keeping them within the same issuer host? -- but there are risks that this can be used for SSRF
|
||||
// attacks. In the face of those risks, and with a lack of real-world use-cases, disable redirects.
|
||||
return errors.New("authorized integration: HTTP redirects are disabled")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func authorizedIntegrationCacheKey(urlString string) string {
|
||||
return fmt.Sprintf("auth-int-remote:%s", urlString)
|
||||
}
|
||||
|
||||
func authorizedIntegrationCacheGetJSON[K any](urlString string, v *K) bool {
|
||||
conn := GetAuthorizedIntegrationCache()
|
||||
if conn == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
cachedAny := conn.Get(authorizedIntegrationCacheKey(urlString))
|
||||
if cachedAny == nil {
|
||||
return false
|
||||
}
|
||||
cachedBytes, ok := cachedAny.([]byte)
|
||||
if !ok {
|
||||
cachedString, ok := cachedAny.(string)
|
||||
if !ok {
|
||||
log.Error("cached content was not []byte or string, but was %T", cachedAny)
|
||||
return false
|
||||
}
|
||||
cachedBytes = []byte(cachedString)
|
||||
}
|
||||
|
||||
err := json.Unmarshal(cachedBytes, &v)
|
||||
if err != nil {
|
||||
// This error case shouldn't occur, as we only store data in the cache once we're sure we could unmarshal it.
|
||||
// If it does occur, log and fallback to treating as uncached.
|
||||
log.Error("failed to Unmarshal cached content: %s", err)
|
||||
// Caller may reuse `v` in a future unmarshal/decode call, and failure here may have polluted it.
|
||||
var zeroValue K
|
||||
*v = zeroValue
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func authorizedIntegrationCacheSetJSON(urlString string, buf []byte) {
|
||||
conn := GetAuthorizedIntegrationCache()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
err := conn.Put(authorizedIntegrationCacheKey(urlString), buf, int64(setting.AuthorizedIntegration.CacheTTL.Seconds()))
|
||||
if err != nil {
|
||||
log.Error("failed to put cache: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func AuthorizedIntegrationFetchJSON[K any](urlString string, v *K) error {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed parsing URL %q: %w", urlString, err)
|
||||
}
|
||||
// Fetching openid-connect or JWKS needs to come from a source that is authentic, and therefore only `https` is
|
||||
// supported. This also protects against a trusted issuer being configured maliciously as `file://` or a JKWS URI
|
||||
// being `file://` -- the HTTP client won't permit that, but, extra safety doesn't hurt.
|
||||
if parsedURL.Scheme != "https" {
|
||||
return fmt.Errorf("unsupported URL scheme: %q", parsedURL.String())
|
||||
}
|
||||
|
||||
// Check our cache, save a remote HTTP interaction.
|
||||
if authorizedIntegrationCacheGetJSON(urlString, v) {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := GetAuthorizedIntegrationHTTPClient().Get(parsedURL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("non-OK response code: %s", resp.Status)
|
||||
}
|
||||
|
||||
bodyReader := io.LimitReader(resp.Body, authorizedIntegrationRequestBodyLimit)
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(bufio.NewWriter(&buf), bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read from remote error: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(buf.Bytes(), &v)
|
||||
if err != nil {
|
||||
// If a decoding error is hit, decorate with information about the limited body size so that it doesn't look
|
||||
// like the remote server provided an incomplete response. err should be something like `io.UnexpectedEOF` in
|
||||
// this case, but it actually isn't, so don't bother trying to detect precisely.
|
||||
return fmt.Errorf("failed to decode (response body restricted to %d bytes): %w", authorizedIntegrationRequestBodyLimit, err)
|
||||
}
|
||||
|
||||
// Successfully decoded the response -- cache the raw bytes for later access.
|
||||
authorizedIntegrationCacheSetJSON(urlString, buf.Bytes())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type MissingFieldError struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
func (e *MissingFieldError) Error() string {
|
||||
return fmt.Sprintf("missing field %s", e.Field)
|
||||
}
|
||||
|
||||
// Validate that an authorized integration's state is valid for creation. For example, that it doesn't have a
|
||||
// conflicting set of resources (public-only and specific repositories), and other similar checks.
|
||||
func ValidateAuthorizedIntegration(ai *auth_model.AuthorizedIntegration, repoResources []*auth_model.AuthorizedIntegResourceRepo) error {
|
||||
if ai.Name == "" {
|
||||
return &MissingFieldError{Field: "Name"}
|
||||
}
|
||||
|
||||
switch ai.UI {
|
||||
case auth_model.AuthorizedIntegrationUIGeneric,
|
||||
auth_model.AuthorizedIntegrationUIForgejoActionsLocal:
|
||||
break
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid UI: %q", ErrAuthorizedIntegrationBadUI, ai.UI)
|
||||
}
|
||||
|
||||
internalIssuer := false
|
||||
for _, ii := range GetInternalIssuers() {
|
||||
if ai.Issuer == ii.IssuerPlaceholder() {
|
||||
internalIssuer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !internalIssuer {
|
||||
if err := validateExternalIssuer(ai.Issuer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateClaimRules(ai.ClaimRules, "root"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return authz.ValidateRepositoryResource(ai.ResourceAllRepos, ai.Scope, len(repoResources))
|
||||
}
|
||||
|
||||
// Validate and insert a new authorized integration.
|
||||
func InsertAuthorizedIntegration(ctx context.Context, ai *auth_model.AuthorizedIntegration, repoResources []*auth_model.AuthorizedIntegResourceRepo) error {
|
||||
ai.Name = strings.TrimSpace(ai.Name)
|
||||
ai.Description = strings.TrimSpace(ai.Description)
|
||||
|
||||
if err := ValidateAuthorizedIntegration(ai, repoResources); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := auth_model.InsertAuthorizedIntegration(ctx, ai); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ai.ResourceAllRepos {
|
||||
if err := auth_model.InsertAuthorizedIntegrationResourceRepos(ctx, ai.ID, repoResources); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateAuthorizedIntegration(ctx context.Context, ai *auth_model.AuthorizedIntegration, repoResources []*auth_model.AuthorizedIntegResourceRepo) error {
|
||||
ai.Name = strings.TrimSpace(ai.Name)
|
||||
ai.Description = strings.TrimSpace(ai.Description)
|
||||
|
||||
if err := ValidateAuthorizedIntegration(ai, repoResources); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := auth_model.UpdateAuthorizedIntegration(ctx, ai); err != nil {
|
||||
return err
|
||||
}
|
||||
return auth_model.UpdateAuthorizedIntegrationResourceRepos(ctx, ai.ID, repoResources)
|
||||
})
|
||||
}
|
||||
|
||||
func validateExternalIssuer(issuer string) error {
|
||||
issuerURL, err := url.Parse(issuer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed parsing issuer URL: %w", ErrInvalidIssuer, err)
|
||||
}
|
||||
|
||||
// Checks implemented here a variation of [AuthorizedIntegration.Verify]'s checks on the remote issuer. Where
|
||||
// possible, if validation changes are made on either implementation, they should be kept in sync with each other.
|
||||
|
||||
issuerOIDCURL := issuerURL.JoinPath(".well-known/openid-configuration")
|
||||
var oidcConfig AuthorizedIntegrationOpenIDConfiguration
|
||||
if err := AuthorizedIntegrationFetchJSON(issuerOIDCURL.String(), &oidcConfig); err != nil {
|
||||
return fmt.Errorf("%w: error when fetching .well-known/openid-configuration from %s: %w", ErrInvalidIssuer, issuerOIDCURL, err)
|
||||
}
|
||||
if oidcConfig.Issuer != issuer {
|
||||
return fmt.Errorf("%w: .well-known/openid-configuration from %s has issuer %q, but input issuer was %q", ErrInvalidIssuer, issuerOIDCURL, oidcConfig.Issuer, issuer)
|
||||
} else if len(oidcConfig.IDTokenSigningAlgValuesSupported) == 0 {
|
||||
return fmt.Errorf("%w: .well-known/openid-configuration from %s lacks required field id_token_signing_alg_values_supported", ErrInvalidIssuer, issuerOIDCURL)
|
||||
} else if oidcConfig.JwksURI == "" {
|
||||
return fmt.Errorf("%w: .well-known/openid-configuration from %s lacks required field jwks_uri", ErrInvalidIssuer, issuerOIDCURL)
|
||||
}
|
||||
|
||||
jwksURI, err := url.Parse(oidcConfig.JwksURI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: .well-known/openid-configuration from %s has invalid jwks_uri: %w", ErrInvalidIssuer, issuerOIDCURL, err)
|
||||
} else if jwksURI.Host != issuerURL.Host {
|
||||
return fmt.Errorf("%w: .well-known/openid-configuration from %s has jwks_uri host mismatch: must be the same as issuer host %q, but was %q", ErrInvalidIssuer, issuerOIDCURL, issuerURL.Host, jwksURI.Host)
|
||||
}
|
||||
|
||||
var keys AuthorizedIntegrationOpenIDKeys
|
||||
if err := AuthorizedIntegrationFetchJSON(oidcConfig.JwksURI, &keys); err != nil {
|
||||
return fmt.Errorf("%w: error when fetching JWKS from %s: %w", ErrInvalidIssuer, oidcConfig.JwksURI, err)
|
||||
} else if len(keys.Keys) == 0 {
|
||||
return fmt.Errorf("%w: fetching JWKS from %s had zero keys", ErrInvalidIssuer, oidcConfig.JwksURI)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClaimRules(cr *auth_model.ClaimRules, path string) error {
|
||||
if cr == nil {
|
||||
return fmt.Errorf("%w: claim rules are nil at %s", ErrInvalidClaimRules, path)
|
||||
}
|
||||
|
||||
for ruleIndex, r := range cr.Rules {
|
||||
if r.Claim == "" {
|
||||
return fmt.Errorf("%w: claim is missing at %s[%d]", ErrInvalidClaimRules, path, ruleIndex)
|
||||
}
|
||||
switch r.Comparison {
|
||||
case auth_model.ClaimEqual:
|
||||
if r.Value == "" {
|
||||
return fmt.Errorf("%w: claim value missing at %s[%d].value", ErrInvalidClaimRules, path, ruleIndex)
|
||||
}
|
||||
case auth_model.ClaimGlob:
|
||||
if r.Value == "" {
|
||||
return fmt.Errorf("%w: claim value missing at %s[%d].value", ErrInvalidClaimRules, path, ruleIndex)
|
||||
} else if _, err := glob.Compile(r.Value); err != nil {
|
||||
return fmt.Errorf("%w: claim glob invalid at %s[%d].value: %w", ErrInvalidClaimRules, path, ruleIndex, err)
|
||||
}
|
||||
case auth_model.ClaimIn:
|
||||
if len(r.Values) == 0 {
|
||||
return fmt.Errorf("%w: claim values missing at %s[%d].values", ErrInvalidClaimRules, path, ruleIndex)
|
||||
}
|
||||
case auth_model.ClaimGlobIn:
|
||||
if len(r.Values) == 0 {
|
||||
return fmt.Errorf("%w: claim values missing at %s[%d].values", ErrInvalidClaimRules, path, ruleIndex)
|
||||
}
|
||||
for globIndex, g := range r.Values {
|
||||
if g == "" {
|
||||
return fmt.Errorf("%w: claim glob empty string invalid, would match anything, at %s[%d].values[%d]", ErrInvalidClaimRules, path, ruleIndex, globIndex)
|
||||
} else if _, err := glob.Compile(g); err != nil {
|
||||
return fmt.Errorf("%w: claim glob invalid at %s[%d].values[%d]: %w", ErrInvalidClaimRules, path, ruleIndex, globIndex, err)
|
||||
}
|
||||
}
|
||||
case auth_model.ClaimNested:
|
||||
if err := validateClaimRules(r.Nested, fmt.Sprintf("%s.%s", path, r.Claim)); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: compare %q is not valid at %s[%d]", ErrInvalidClaimRules, r.Comparison, path, ruleIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package method
|
||||
package auth
|
||||
|
||||
// Response structure for a JWT issuer's `${iss}/.well-known/openid-configuration` URL endpoint; this is pared down to
|
||||
// the relevant entries for authorized integrations to inspect from the remote issuer.
|
||||
type openIDConfiguration struct {
|
||||
type AuthorizedIntegrationOpenIDConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
}
|
||||
|
||||
// Response structure for a JSON Web Key Set, which is typically read from the JwksURI field of [openIDConfiguration].
|
||||
type openIDKeys struct {
|
||||
type AuthorizedIntegrationOpenIDKeys struct {
|
||||
// Typically map[string]string, for fields like "kty", "alg", "use", "kid", "n", "e", but also string:any for fields
|
||||
// like x5c which are []string. We currently don't parse any fields that aren't string, but we need to Unmarshal
|
||||
// into this field successfully in those cases.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package method
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -232,19 +232,19 @@ const awsJWKS = `
|
|||
|
||||
func TestParseOpenIDConfiguration(t *testing.T) {
|
||||
t.Run("Forgejo", func(t *testing.T) {
|
||||
var retval openIDConfiguration
|
||||
var retval AuthorizedIntegrationOpenIDConfiguration
|
||||
data := []byte(forgejoOIDC)
|
||||
require.NoError(t, json.Unmarshal(data, &retval))
|
||||
assert.Equal(t, "https://example.org/api/actions/.well-known/keys", retval.JwksURI)
|
||||
})
|
||||
t.Run("GitHub", func(t *testing.T) {
|
||||
var retval openIDConfiguration
|
||||
var retval AuthorizedIntegrationOpenIDConfiguration
|
||||
data := []byte(githubOIDC)
|
||||
require.NoError(t, json.Unmarshal(data, &retval))
|
||||
assert.Equal(t, "https://token.actions.githubusercontent.com/.well-known/jwks", retval.JwksURI)
|
||||
})
|
||||
t.Run("AWS", func(t *testing.T) {
|
||||
var retval openIDConfiguration
|
||||
var retval AuthorizedIntegrationOpenIDConfiguration
|
||||
data := []byte(awsOIDC)
|
||||
require.NoError(t, json.Unmarshal(data, &retval))
|
||||
assert.Equal(t, "https://a103a2cc-b461-473d-84fe-6c4f6d45af88.tokens.sts.global.api.aws/.well-known/jwks.json", retval.JwksURI)
|
||||
|
|
@ -253,19 +253,19 @@ func TestParseOpenIDConfiguration(t *testing.T) {
|
|||
|
||||
func TestParseJSONWebKeySet(t *testing.T) {
|
||||
t.Run("Forgejo", func(t *testing.T) {
|
||||
var retval openIDKeys
|
||||
var retval AuthorizedIntegrationOpenIDKeys
|
||||
data := []byte(forgejoJWKS)
|
||||
require.NoError(t, json.Unmarshal(data, &retval))
|
||||
assert.Len(t, retval.Keys, 1)
|
||||
})
|
||||
t.Run("GitHub", func(t *testing.T) {
|
||||
var retval openIDKeys
|
||||
var retval AuthorizedIntegrationOpenIDKeys
|
||||
data := []byte(githubJWKS)
|
||||
require.NoError(t, json.Unmarshal(data, &retval))
|
||||
assert.Len(t, retval.Keys, 4)
|
||||
})
|
||||
t.Run("AWS", func(t *testing.T) {
|
||||
var retval openIDKeys
|
||||
var retval AuthorizedIntegrationOpenIDKeys
|
||||
data := []byte(awsJWKS)
|
||||
require.NoError(t, json.Unmarshal(data, &retval))
|
||||
assert.Len(t, retval.Keys, 2)
|
||||
537
services/auth/authorized_integration_test.go
Normal file
537
services/auth/authorized_integration_test.go
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/services/authz"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateAuthorizedIntegration(t *testing.T) {
|
||||
ii := NewMockInternalIssuer(t)
|
||||
ii.On("IssuerPlaceholder").Return("urn:forgejo:authorized-issuer:internal:test2")
|
||||
RegisterInternalIssuerForTesting(t, "/fake-jwt-issuer", ii)
|
||||
|
||||
makeValid := func() *auth.AuthorizedIntegration {
|
||||
return &auth.AuthorizedIntegration{
|
||||
Name: "Test authorized integration",
|
||||
ResourceAllRepos: true,
|
||||
Scope: auth.AccessTokenScopeReadRepository,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
Issuer: "urn:forgejo:authorized-issuer:internal:test2",
|
||||
ClaimRules: &auth.ClaimRules{},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid - all access", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.ResourceAllRepos = true
|
||||
ai.Scope = auth.AccessTokenScopeReadRepository
|
||||
err := ValidateAuthorizedIntegration(ai, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid - specified repos", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.ResourceAllRepos = false
|
||||
ai.Scope = auth.AccessTokenScopeReadRepository
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid - no specified repos", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.ResourceAllRepos = false
|
||||
ai.Scope = auth.AccessTokenScopeReadRepository
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, authz.ErrSpecifiedReposNone)
|
||||
})
|
||||
|
||||
t.Run("invalid - specified repos & public-only", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.ResourceAllRepos = false
|
||||
ai.Scope = auth.AccessTokenScope(strings.Join([]string{string(auth.AccessTokenScopePublicOnly), string(auth.AccessTokenScopeReadRepository)}, ","))
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, authz.ErrSpecifiedReposNoPublicOnly)
|
||||
})
|
||||
|
||||
t.Run("invalid - specified repos unsupported scopes", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.ResourceAllRepos = false
|
||||
ai.Scope = auth.AccessTokenScopeReadAdmin
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, authz.ErrSpecifiedReposInvalidScope)
|
||||
require.ErrorContains(t, err, string(auth.AccessTokenScopeReadAdmin))
|
||||
})
|
||||
|
||||
t.Run("invalid - missing UI", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.UI = ""
|
||||
err := ValidateAuthorizedIntegration(ai, nil)
|
||||
require.ErrorIs(t, err, ErrAuthorizedIntegrationBadUI)
|
||||
require.ErrorContains(t, err, "invalid UI: \"\"")
|
||||
})
|
||||
|
||||
t.Run("invalid - missing name", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.Name = ""
|
||||
err := ValidateAuthorizedIntegration(ai, nil)
|
||||
var mfe *MissingFieldError
|
||||
require.ErrorAs(t, err, &mfe)
|
||||
assert.Equal(t, "Name", mfe.Field)
|
||||
})
|
||||
|
||||
t.Run("invalid - checks external issuer name", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.Issuer = "ftp://example.com/"
|
||||
err := ValidateAuthorizedIntegration(ai, nil)
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
})
|
||||
|
||||
t.Run("invalid - checks claims issuer name", func(t *testing.T) {
|
||||
ai := makeValid()
|
||||
ai.ClaimRules = &auth.ClaimRules{Rules: []auth.ClaimRule{{}}}
|
||||
err := ValidateAuthorizedIntegration(ai, nil)
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsertAuthorizedIntegration(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ii := NewMockInternalIssuer(t)
|
||||
ii.On("IssuerPlaceholder").Return("urn:forgejo:authorized-issuer:internal:test3")
|
||||
RegisterInternalIssuerForTesting(t, "/fake-jwt-issuer", ii)
|
||||
|
||||
t.Run("success inserts w/ repos", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
UserID: 2,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
ResourceAllRepos: false,
|
||||
ClaimRules: &auth.ClaimRules{},
|
||||
Name: " Magical AI ",
|
||||
Scope: auth.AccessTokenScopeReadRepository,
|
||||
Issuer: "urn:forgejo:authorized-issuer:internal:test3",
|
||||
}
|
||||
rr := []*auth.AuthorizedIntegResourceRepo{
|
||||
{
|
||||
RepoID: 2,
|
||||
},
|
||||
}
|
||||
|
||||
err := InsertAuthorizedIntegration(t.Context(), ai, rr)
|
||||
require.NoError(t, err)
|
||||
|
||||
fromDB := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizedIntegration{ID: ai.ID})
|
||||
assert.Equal(t, "Magical AI", fromDB.Name)
|
||||
|
||||
// IntegID should have been initialized and the repo-specific record saved
|
||||
res := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizedIntegResourceRepo{IntegID: ai.ID})
|
||||
assert.EqualValues(t, 2, res.RepoID)
|
||||
})
|
||||
|
||||
t.Run("validates data", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
UserID: 2,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
ResourceAllRepos: false,
|
||||
ClaimRules: &auth.ClaimRules{},
|
||||
Name: " Magical AI ",
|
||||
Issuer: "urn:forgejo:authorized-issuer:internal:test3",
|
||||
}
|
||||
err := InsertAuthorizedIntegration(t.Context(), ai, nil)
|
||||
require.ErrorIs(t, err, authz.ErrSpecifiedReposNone)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAuthorizedIntegration(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ii := NewMockInternalIssuer(t)
|
||||
ii.On("IssuerPlaceholder").Return("urn:forgejo:authorized-issuer:internal:test4")
|
||||
RegisterInternalIssuerForTesting(t, "/fake-jwt-issuer", ii)
|
||||
|
||||
prep := func(t *testing.T) (*auth.AuthorizedIntegration, []*auth.AuthorizedIntegResourceRepo) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
UserID: 2,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
ResourceAllRepos: false,
|
||||
ClaimRules: &auth.ClaimRules{},
|
||||
Name: " Magical AI ",
|
||||
Scope: auth.AccessTokenScopeReadRepository,
|
||||
Issuer: "urn:forgejo:authorized-issuer:internal:test4",
|
||||
}
|
||||
rr := []*auth.AuthorizedIntegResourceRepo{
|
||||
{
|
||||
RepoID: 2,
|
||||
},
|
||||
}
|
||||
err := InsertAuthorizedIntegration(t.Context(), ai, rr)
|
||||
require.NoError(t, err)
|
||||
return ai, rr
|
||||
}
|
||||
|
||||
t.Run("update basic fields", func(t *testing.T) {
|
||||
ai, rr := prep(t)
|
||||
ai.Description = "This is the description field."
|
||||
|
||||
err := UpdateAuthorizedIntegration(t.Context(), ai, rr)
|
||||
require.NoError(t, err)
|
||||
|
||||
fromDB := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizedIntegration{ID: ai.ID})
|
||||
assert.Equal(t, "Magical AI", fromDB.Name)
|
||||
assert.Equal(t, "This is the description field.", fromDB.Description)
|
||||
unittest.AssertCount(t, &auth.AuthorizedIntegResourceRepo{IntegID: ai.ID}, 1)
|
||||
})
|
||||
|
||||
t.Run("update remove resource repos", func(t *testing.T) {
|
||||
ai, _ := prep(t)
|
||||
ai.ResourceAllRepos = true
|
||||
|
||||
err := UpdateAuthorizedIntegration(t.Context(), ai, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
unittest.AssertCount(t, &auth.AuthorizedIntegResourceRepo{IntegID: ai.ID}, 0)
|
||||
})
|
||||
|
||||
t.Run("update add resource repos", func(t *testing.T) {
|
||||
ai, _ := prep(t)
|
||||
rr := []*auth.AuthorizedIntegResourceRepo{
|
||||
{
|
||||
RepoID: 2,
|
||||
},
|
||||
{
|
||||
RepoID: 3,
|
||||
},
|
||||
}
|
||||
|
||||
err := UpdateAuthorizedIntegration(t.Context(), ai, rr)
|
||||
require.NoError(t, err)
|
||||
|
||||
unittest.AssertCount(t, &auth.AuthorizedIntegResourceRepo{IntegID: ai.ID}, 2)
|
||||
})
|
||||
|
||||
t.Run("validates data", func(t *testing.T) {
|
||||
ai, _ := prep(t)
|
||||
err := InsertAuthorizedIntegration(t.Context(), ai, nil)
|
||||
require.ErrorIs(t, err, authz.ErrSpecifiedReposNone)
|
||||
})
|
||||
}
|
||||
|
||||
type ExternalIssuerTester struct {
|
||||
t *testing.T
|
||||
jwtSigningKey jwtx.SigningKey
|
||||
testServer *httptest.Server
|
||||
resetHTTPClient func()
|
||||
tweaks []tweak
|
||||
issuer string
|
||||
}
|
||||
|
||||
func newEITester(t *testing.T, tweaks ...tweak) *ExternalIssuerTester {
|
||||
eit := &ExternalIssuerTester{
|
||||
t: t,
|
||||
tweaks: tweaks,
|
||||
}
|
||||
|
||||
var jwtSigningKey jwtx.SigningKey
|
||||
var err error
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
|
||||
jwtSigningKey, err = jwtx.InitAsymmetricSigningKey(keyPath, "RS256")
|
||||
require.NoError(t, err)
|
||||
eit.jwtSigningKey = jwtSigningKey
|
||||
|
||||
eit.testServer = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/actions/.well-known/openid-configuration" {
|
||||
retval := &AuthorizedIntegrationOpenIDConfiguration{
|
||||
Issuer: eit.issuer,
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
JwksURI: fmt.Sprintf("%s/.keys", eit.issuer),
|
||||
}
|
||||
for _, tweak := range eit.tweaks {
|
||||
if tweak, is := tweak.(openIDTweak); is {
|
||||
tweak(retval)
|
||||
}
|
||||
}
|
||||
err := json.NewEncoder(w).Encode(retval)
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/actions/.keys" {
|
||||
jwk, err := eit.jwtSigningKey.ToJWK()
|
||||
require.NoError(t, err)
|
||||
jwk["use"] = "sig"
|
||||
jwkMapAny := make(map[string]any, len(jwk))
|
||||
for k, v := range jwk {
|
||||
jwkMapAny[k] = v // convert map[string]string -> map[string]any
|
||||
}
|
||||
retval := &AuthorizedIntegrationOpenIDKeys{
|
||||
Keys: []map[string]any{jwkMapAny},
|
||||
}
|
||||
for _, tweak := range eit.tweaks {
|
||||
if jwksTweak, is := tweak.(jwksTweak); is {
|
||||
jwksTweak(retval)
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(retval) // no error checking -- some tests abort read
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
|
||||
eit.issuer = fmt.Sprintf("%s/api/actions", eit.testServer.URL)
|
||||
|
||||
// trust TLS cert of our mock client by inserting the test client for our test server into the global aiHTTPClient
|
||||
eit.resetHTTPClient = test.MockVariableValue(
|
||||
&GetAuthorizedIntegrationHTTPClient,
|
||||
func() *http.Client {
|
||||
return eit.testServer.Client()
|
||||
})
|
||||
|
||||
return eit
|
||||
}
|
||||
|
||||
func (eit *ExternalIssuerTester) close() {
|
||||
eit.resetHTTPClient()
|
||||
eit.testServer.Close()
|
||||
}
|
||||
|
||||
type tweak any
|
||||
|
||||
type openIDTweak func(*AuthorizedIntegrationOpenIDConfiguration)
|
||||
|
||||
type jwksTweak func(*AuthorizedIntegrationOpenIDKeys)
|
||||
|
||||
func TestValidateExternalIssuer(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
eit := newEITester(t)
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("unparseable URL", func(t *testing.T) {
|
||||
err := validateExternalIssuer("hello? \x7f is this a URL?")
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "failed parsing issuer URL")
|
||||
})
|
||||
|
||||
t.Run("404 OIDC", func(t *testing.T) {
|
||||
eit := newEITester(t)
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer + "/wrong-path")
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "non-OK response code: 404 Not Found")
|
||||
})
|
||||
|
||||
t.Run("mismatched issuer", func(t *testing.T) {
|
||||
eit := newEITester(t,
|
||||
openIDTweak(func(oidc *AuthorizedIntegrationOpenIDConfiguration) {
|
||||
oidc.Issuer = "https://whoops.example.org"
|
||||
}))
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer)
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "has issuer \"https://whoops.example.org\", but input issuer was")
|
||||
})
|
||||
|
||||
t.Run("no signing alg issuer", func(t *testing.T) {
|
||||
eit := newEITester(t,
|
||||
openIDTweak(func(oidc *AuthorizedIntegrationOpenIDConfiguration) {
|
||||
oidc.IDTokenSigningAlgValuesSupported = nil
|
||||
}))
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer)
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "lacks required field id_token_signing_alg_values_supported")
|
||||
})
|
||||
|
||||
t.Run("no jwks_uri", func(t *testing.T) {
|
||||
eit := newEITester(t,
|
||||
openIDTweak(func(oidc *AuthorizedIntegrationOpenIDConfiguration) {
|
||||
oidc.JwksURI = ""
|
||||
}))
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer)
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "lacks required field jwks_uri")
|
||||
})
|
||||
|
||||
t.Run("remote jwks_uri", func(t *testing.T) {
|
||||
eit := newEITester(t,
|
||||
openIDTweak(func(oidc *AuthorizedIntegrationOpenIDConfiguration) {
|
||||
oidc.JwksURI = "https://example.org/.keys"
|
||||
}))
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer)
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "jwks_uri host mismatch")
|
||||
})
|
||||
|
||||
t.Run("empty JWKS", func(t *testing.T) {
|
||||
eit := newEITester(t,
|
||||
jwksTweak(func(keys *AuthorizedIntegrationOpenIDKeys) {
|
||||
keys.Keys = nil
|
||||
}))
|
||||
defer eit.close()
|
||||
err := validateExternalIssuer(eit.issuer)
|
||||
require.ErrorIs(t, err, ErrInvalidIssuer)
|
||||
require.ErrorContains(t, err, "had zero keys")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateClaimRules(t *testing.T) {
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
err := validateClaimRules(nil, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim rules are nil")
|
||||
})
|
||||
|
||||
t.Run("missing claim", func(t *testing.T) {
|
||||
err := validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: ""}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim is missing at root[0]")
|
||||
})
|
||||
|
||||
t.Run("eq", func(t *testing.T) {
|
||||
err := validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimEqual, Value: ""}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim value missing at root[0].value")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimEqual, Value: "present"}},
|
||||
}, "root")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("glob", func(t *testing.T) {
|
||||
err := validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlob, Value: ""}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim value missing at root[0].value")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlob, Value: "abc["}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim glob invalid at root[0].value")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlob, Value: "pre*ent"}},
|
||||
}, "root")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("in", func(t *testing.T) {
|
||||
err := validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimIn, Values: nil}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim values missing at root[0].values")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimIn, Values: []string{}}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim values missing at root[0].values")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimIn, Values: []string{"1", "2"}}},
|
||||
}, "root")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("glob-in", func(t *testing.T) {
|
||||
err := validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlobIn, Values: nil}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim values missing at root[0].values")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlobIn, Values: []string{}}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim values missing at root[0].values")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlobIn, Values: []string{"abc", "abc["}}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim glob invalid at root[0].values[1]")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimGlobIn, Values: []string{"1", "2"}}},
|
||||
}, "root")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("nested", func(t *testing.T) {
|
||||
err := validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{{Claim: "c", Comparison: auth.ClaimNested}},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim rules are nil at root.c")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{
|
||||
{
|
||||
Claim: "c",
|
||||
Comparison: auth.ClaimNested,
|
||||
Nested: &auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{
|
||||
{
|
||||
Claim: "d",
|
||||
Comparison: auth.ClaimEqual,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "root")
|
||||
require.ErrorIs(t, err, ErrInvalidClaimRules)
|
||||
require.ErrorContains(t, err, "claim value missing at root.c[0].value")
|
||||
|
||||
err = validateClaimRules(&auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{
|
||||
{
|
||||
Claim: "c",
|
||||
Comparison: auth.ClaimNested,
|
||||
Nested: &auth.ClaimRules{
|
||||
Rules: []auth.ClaimRule{
|
||||
{
|
||||
Claim: "d",
|
||||
Comparison: auth.ClaimEqual,
|
||||
Value: "123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "root")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
67
services/auth/internal_issuer.go
Normal file
67
services/auth/internal_issuer.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/jwtx"
|
||||
)
|
||||
|
||||
var internalIssuers = make(map[string]InternalIssuer)
|
||||
|
||||
// Authorized Integrations can verify the signature of JWTs that the application itself generated without requiring
|
||||
// remote access, and in a manner that is flexible to changes in [setting.AppURL].
|
||||
//
|
||||
// For example, Forgejo Actions is often used to access Forgejo with a JWT, by setting `enable-openid-connect: true` in
|
||||
// a workflow. Without any special support for this internal access situation, problems would occur:
|
||||
//
|
||||
// 1. Forgejo would need to make an HTTP request to itself to get the valid public key for the JWT, in order to validate
|
||||
// its signature. This is a waste of resources, and introduces a self-DoS risk.
|
||||
//
|
||||
// 2. Forgejo would need to be available via TLS in order for Actions to make service calls to Forgejo with that JWT
|
||||
// (due to the TLS requirement for public key fetching).
|
||||
//
|
||||
// 3. Authorized Integrations would need to be saved with the `issuer` URL of Forgejo. If Forgejo's own
|
||||
// [setting.AppURL] changed, all the persisted records in the database would become incorrect.
|
||||
//
|
||||
// Internal Issuers work by registering a URL suffix like "api/actions". When a JWT is received with an issuer
|
||||
// matching [setting.AppURL] and the registered URL suffix, then the [InternalIssuer] interface is used to access the
|
||||
// JWT public key, and the value to be saved in the Authorized Integrations table as the issuer.
|
||||
func RegisterInternalIssuer(urlSuffix string, internalIssuer InternalIssuer) {
|
||||
internalIssuers[urlSuffix] = internalIssuer
|
||||
}
|
||||
|
||||
// Variant of RegisterInternalIssuer which removes the registration impact in test cleanup.
|
||||
func RegisterInternalIssuerForTesting(t *testing.T, urlSuffix string, internalIssuer InternalIssuer) {
|
||||
orig, hadOrig := internalIssuers[urlSuffix]
|
||||
internalIssuers[urlSuffix] = internalIssuer
|
||||
t.Cleanup(func() {
|
||||
if hadOrig {
|
||||
internalIssuers[urlSuffix] = orig
|
||||
} else {
|
||||
delete(internalIssuers, urlSuffix)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve an internal issuer, if one exists, for the provided URL suffix from a JWT token. For example,
|
||||
// "api/actions".
|
||||
func GetInternalIssuerByURLSuffix(issuerSuffix string) (InternalIssuer, bool) {
|
||||
ii, ok := internalIssuers[issuerSuffix]
|
||||
return ii, ok
|
||||
}
|
||||
|
||||
// Read access to the registered internal issuers.
|
||||
func GetInternalIssuers() map[string]InternalIssuer {
|
||||
return internalIssuers
|
||||
}
|
||||
|
||||
//mockery:generate: true
|
||||
type InternalIssuer interface {
|
||||
// Signing key used to validate a JWT from this internal issuer.
|
||||
SigningKey() jwtx.SigningKey
|
||||
// Value to store in [auth_model.AuthorizedIntegration]'s Issuer field to reflect the use of this internal issuer.
|
||||
IssuerPlaceholder() string
|
||||
}
|
||||
|
|
@ -4,31 +4,23 @@
|
|||
package method
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/cache"
|
||||
"forgejo.org/modules/hostmatcher"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/proxy"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/util"
|
||||
"forgejo.org/services/auth"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
"forgejo.org/services/authz"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
|
|
@ -36,59 +28,11 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
_ auth.Method = &AuthorizedIntegration{}
|
||||
|
||||
aiHTTPClient *http.Client
|
||||
initHTTPClient sync.Once
|
||||
_ auth_service.Method = &AuthorizedIntegration{}
|
||||
|
||||
errParseInternalServer = errors.New("internal server error")
|
||||
|
||||
// Allow mocking / overridding during tests:
|
||||
GetAuthorizedIntegrationHTTPClient = func() *http.Client {
|
||||
initHTTPClient.Do(initAuthorizedIntegrationHTTPClient)
|
||||
return aiHTTPClient
|
||||
}
|
||||
getCache = cache.GetCache
|
||||
|
||||
internalIssuers = make(map[string]InternalIssuer)
|
||||
)
|
||||
|
||||
// Authorized Integrations can verify the signature of JWTs that the application itself generated without requiring
|
||||
// remote access, and in a manner that is flexible to changes in [setting.AppURL].
|
||||
//
|
||||
// For example, Forgejo Actions is often used to access Forgejo with a JWT, by setting `enable-openid-connect: true` in
|
||||
// a workflow. Without any special support for this internal access situation, problems would occur:
|
||||
//
|
||||
// 1. Forgejo would need to make an HTTP request to itself to get the valid public key for the JWT, in order to validate
|
||||
// its signature. This is a waste of resources, and introduces a self-DoS risk.
|
||||
//
|
||||
// 2. Forgejo would need to be available via TLS in order for Actions to make service calls to Forgejo with that JWT
|
||||
// (due to the TLS requirement for public key fetching).
|
||||
//
|
||||
// 3. Authorized Integrations would need to be saved with the `issuer` URL of Forgejo. If Forgejo's own
|
||||
// [setting.AppURL] changed, all the persisted records in the database would become incorrect.
|
||||
//
|
||||
// Internal Issuers work by registering a URL suffix like "/api/actions". When a JWT is received with an issuer
|
||||
// matching [setting.AppURL] and the registered URL suffix, then the [InternalIssuer] interface is used to access the
|
||||
// JWT public key, and the value to be saved in the Authorized Integrations table as the issuer.
|
||||
func RegisterInternalIssuer(urlSuffix string, internalIssuer InternalIssuer) {
|
||||
internalIssuers[urlSuffix] = internalIssuer
|
||||
}
|
||||
|
||||
//mockery:generate: true
|
||||
type InternalIssuer interface {
|
||||
// Signing key used to validate a JWT from this internal issuer.
|
||||
SigningKey() jwtx.SigningKey
|
||||
// Value to store in [auth_model.AuthorizedIntegration]'s Issuer field to reflect the use of this internal issuer.
|
||||
IssuerPlaceholder() string
|
||||
}
|
||||
|
||||
// Restrict document size to prevent resource exhaustion attack with a malicious authorized integration; largest
|
||||
// real-world openid-configuration observed is about 1kB, largest JWKS is 6kB, so for both cases 16kB should be
|
||||
// sufficient. If this needs to change in the future, it could be moved to a config setting -- but until a reason comes
|
||||
// up it seems reasonable to keep microscopic settings out-of-sight.
|
||||
const authorizedIntegrationRequestBodyLimit = int64(16 * 1024)
|
||||
|
||||
// Authenticates incoming requests by JWTs that are issued by an authorized integration. Authorized integrations are
|
||||
// stored in the database in the [auth_model.AuthorizedIntegration] table. Once authenticated, the request can perform
|
||||
// actions as the owner of the authorized integration, with limited access defined by the scope and resources stored on
|
||||
|
|
@ -105,15 +49,15 @@ type AuthorizedIntegration struct {
|
|||
fixedTime *time.Time
|
||||
}
|
||||
|
||||
func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
|
||||
func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter, _ auth_service.SessionStore) auth_service.MethodOutput {
|
||||
hasToken, token := tokenFromAuthorizationBearer(req).Get()
|
||||
if !hasToken {
|
||||
if !a.PermitBasic {
|
||||
return &auth.AuthenticationNotAttempted{}
|
||||
return &auth_service.AuthenticationNotAttempted{}
|
||||
}
|
||||
hasBasic, basicToken := tokenFromAuthorizationBasic(req).Get()
|
||||
if !hasBasic {
|
||||
return &auth.AuthenticationNotAttempted{}
|
||||
return &auth_service.AuthenticationNotAttempted{}
|
||||
}
|
||||
token = basicToken
|
||||
}
|
||||
|
|
@ -147,10 +91,10 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
|
|||
// Check if there's an internal issuer that matches the JWT's issuer, and if so, change `queryIssuer` to the
|
||||
// internal issuer's placeholder, and store `internalIssuer` for later:
|
||||
queryIssuer := issuer
|
||||
var internalIssuer InternalIssuer
|
||||
var internalIssuer auth_service.InternalIssuer
|
||||
issuerSuffix := strings.TrimPrefix(issuer, setting.AppURL)
|
||||
if issuer != issuerSuffix { // TrimPrefix will return a different string when the prefix was present
|
||||
if ii, ok := internalIssuers[issuerSuffix]; ok {
|
||||
if ii, ok := auth_service.GetInternalIssuerByURLSuffix(issuerSuffix); ok {
|
||||
internalIssuer = ii
|
||||
queryIssuer = internalIssuer.IssuerPlaceholder()
|
||||
}
|
||||
|
|
@ -183,9 +127,13 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
|
|||
return nil, fmt.Errorf("failed parsing issuer: %w", err)
|
||||
}
|
||||
|
||||
// Checks implemented here a variation of validateExternalIssuer used when creating an authorized
|
||||
// integration. Where possible, if validation changes are made on either implementation, they should be
|
||||
// kept in sync with each other.
|
||||
|
||||
issuerOIDCURL := issuerURL.JoinPath(".well-known/openid-configuration")
|
||||
var oidcConfig openIDConfiguration
|
||||
if err := authorizedIntegrationFetchJSON(issuerOIDCURL.String(), &oidcConfig); err != nil {
|
||||
var oidcConfig auth_service.AuthorizedIntegrationOpenIDConfiguration
|
||||
if err := auth_service.AuthorizedIntegrationFetchJSON(issuerOIDCURL.String(), &oidcConfig); err != nil {
|
||||
return nil, fmt.Errorf("error when fetching .well-known/openid-configuration from %s: %w", issuerOIDCURL, err)
|
||||
}
|
||||
|
||||
|
|
@ -206,8 +154,8 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
|
|||
// but until a real-world case comes up where that is needed, this is a safety-first restriction.
|
||||
return nil, fmt.Errorf("jwks_uri host mismatch: must be the same as issuer host %q, but was %q", issuerURL.Host, jwksURI.Host)
|
||||
}
|
||||
var keys openIDKeys
|
||||
if err := authorizedIntegrationFetchJSON(oidcConfig.JwksURI, &keys); err != nil {
|
||||
var keys auth_service.AuthorizedIntegrationOpenIDKeys
|
||||
if err := auth_service.AuthorizedIntegrationFetchJSON(oidcConfig.JwksURI, &keys); err != nil {
|
||||
return nil, fmt.Errorf("error when fetching JWKS from %s: %w", oidcConfig.JwksURI, err)
|
||||
}
|
||||
|
||||
|
|
@ -244,18 +192,18 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
|
|||
)
|
||||
if err != nil && errors.Is(err, errParseInternalServer) {
|
||||
// Errors from parsing marked errParseInternalServer are AuthenticationError, not incorrect creds:
|
||||
return &auth.AuthenticationError{Error: err}
|
||||
return &auth_service.AuthenticationError{Error: err}
|
||||
} else if err != nil {
|
||||
return &auth.AuthenticationAttemptedIncorrectCredential{Error: fmt.Errorf("authorized integration: parse JWT error: %w", err)}
|
||||
return &auth_service.AuthenticationAttemptedIncorrectCredential{Error: fmt.Errorf("authorized integration: parse JWT error: %w", err)}
|
||||
} else if !parsedToken.Valid {
|
||||
return &auth.AuthenticationAttemptedIncorrectCredential{Error: errors.New("authorized integration: JWT not valid")}
|
||||
return &auth_service.AuthenticationAttemptedIncorrectCredential{Error: errors.New("authorized integration: JWT not valid")}
|
||||
} else if authorizedIntegration == nil { // shouldn't be possible, but overly safe
|
||||
return &auth.AuthenticationError{Error: errors.New("authorized integration: nil authorized integration")}
|
||||
return &auth_service.AuthenticationError{Error: errors.New("authorized integration: nil authorized integration")}
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), authorizedIntegration.UserID)
|
||||
if err != nil {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration: GetUserByID: %w", err)}
|
||||
return &auth_service.AuthenticationError{Error: fmt.Errorf("authorized integration: GetUserByID: %w", err)}
|
||||
}
|
||||
|
||||
if err = authorizedIntegration.UpdateLastUsed(req.Context()); err != nil {
|
||||
|
|
@ -264,17 +212,17 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
|
|||
|
||||
reducer, err := authz.GetAuthorizationReducerForAuthorizedIntegration(req.Context(), authorizedIntegration)
|
||||
if err != nil {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration GetAuthorizationReducerForAuthorizedIntegration: %w", err)}
|
||||
return &auth_service.AuthenticationError{Error: fmt.Errorf("authorized integration GetAuthorizationReducerForAuthorizedIntegration: %w", err)}
|
||||
}
|
||||
|
||||
var optionalExp optional.Option[timeutil.TimeStamp]
|
||||
if exp, err := parsedToken.Claims.GetExpirationTime(); err != nil {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration GetExpirationTime: %w", err)}
|
||||
return &auth_service.AuthenticationError{Error: fmt.Errorf("authorized integration GetExpirationTime: %w", err)}
|
||||
} else if exp != nil {
|
||||
optionalExp = optional.Some(timeutil.TimeStamp(exp.Unix()))
|
||||
}
|
||||
|
||||
return &auth.AuthenticationSuccess{
|
||||
return &auth_service.AuthenticationSuccess{
|
||||
Result: &authorizedIntegrationAuthenticationResult{
|
||||
user: u,
|
||||
scope: authorizedIntegration.Scope,
|
||||
|
|
@ -284,131 +232,6 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter,
|
|||
}
|
||||
}
|
||||
|
||||
func initAuthorizedIntegrationHTTPClient() {
|
||||
blockList := hostmatcher.ParseSimpleMatchList("authorized_integration.BLOCKED_DOMAINS", setting.AuthorizedIntegration.BlockedDomains)
|
||||
|
||||
allowList := hostmatcher.ParseSimpleMatchList("authorized_integration.ALLOWED_DOMAINS", setting.AuthorizedIntegration.AllowedDomains)
|
||||
if allowList.IsEmpty() {
|
||||
// the default policy is that authorized integrations can access external hosts
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinExternal)
|
||||
}
|
||||
if setting.AuthorizedIntegration.AllowLocalNetworks {
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
|
||||
}
|
||||
|
||||
aiHTTPClient = &http.Client{
|
||||
Timeout: setting.AuthorizedIntegration.RequestTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: proxy.Proxy(),
|
||||
DialContext: hostmatcher.NewDialContext("authorized_integration", allowList, blockList, setting.Proxy.ProxyURLFixed),
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// It might be possible to come up with some reasonable capability to support redirects -- such as
|
||||
// keeping them within the same issuer host? -- but there are risks that this can be used for SSRF
|
||||
// attacks. In the face of those risks, and with a lack of real-world use-cases, disable redirects.
|
||||
return errors.New("authorized integration: HTTP redirects are disabled")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func authorizedIntegrationCacheKey(urlString string) string {
|
||||
return fmt.Sprintf("auth-int-remote:%s", urlString)
|
||||
}
|
||||
|
||||
func authorizedIntegrationCacheGetJSON[K any](urlString string, v *K) bool {
|
||||
conn := getCache()
|
||||
if conn == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
cachedAny := conn.Get(authorizedIntegrationCacheKey(urlString))
|
||||
if cachedAny == nil {
|
||||
return false
|
||||
}
|
||||
cachedBytes, ok := cachedAny.([]byte)
|
||||
if !ok {
|
||||
cachedString, ok := cachedAny.(string)
|
||||
if !ok {
|
||||
log.Error("cached content was not []byte or string, but was %T", cachedAny)
|
||||
return false
|
||||
}
|
||||
cachedBytes = []byte(cachedString)
|
||||
}
|
||||
|
||||
err := json.Unmarshal(cachedBytes, &v)
|
||||
if err != nil {
|
||||
// This error case shouldn't occur, as we only store data in the cache once we're sure we could unmarshal it.
|
||||
// If it does occur, log and fallback to treating as uncached.
|
||||
log.Error("failed to Unmarshal cached content: %s", err)
|
||||
// Caller may reuse `v` in a future unmarshal/decode call, and failure here may have polluted it.
|
||||
var zeroValue K
|
||||
*v = zeroValue
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func authorizedIntegrationCacheSetJSON(urlString string, buf []byte) {
|
||||
conn := getCache()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
err := conn.Put(authorizedIntegrationCacheKey(urlString), buf, int64(setting.AuthorizedIntegration.CacheTTL.Seconds()))
|
||||
if err != nil {
|
||||
log.Error("failed to put cache: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func authorizedIntegrationFetchJSON[K any](urlString string, v *K) error {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed parsing URL %q: %w", urlString, err)
|
||||
}
|
||||
// Fetching openid-connect or JWKS needs to come from a source that is authentic, and therefore only `https` is
|
||||
// supported. This also protects against a trusted issuer being configured maliciously as `file://` or a JKWS URI
|
||||
// being `file://` -- the HTTP client won't permit that, but, extra safety doesn't hurt.
|
||||
if parsedURL.Scheme != "https" {
|
||||
return fmt.Errorf("unsupported URL scheme: %q", parsedURL.String())
|
||||
}
|
||||
|
||||
// Check our cache, save a remote HTTP interaction.
|
||||
if authorizedIntegrationCacheGetJSON(urlString, v) {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := GetAuthorizedIntegrationHTTPClient().Get(parsedURL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("non-OK response code: %s", resp.Status)
|
||||
}
|
||||
|
||||
bodyReader := io.LimitReader(resp.Body, authorizedIntegrationRequestBodyLimit)
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(bufio.NewWriter(&buf), bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read from remote error: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(buf.Bytes(), &v)
|
||||
if err != nil {
|
||||
// If a decoding error is hit, decorate with information about the limited body size so that it doesn't look
|
||||
// like the remote server provided an incomplete response. err should be something like `io.UnexpectedEOF` in
|
||||
// this case, but it actually isn't, so don't bother trying to detect precisely.
|
||||
return fmt.Errorf("failed to decode (response body restricted to %d bytes): %w", authorizedIntegrationRequestBodyLimit, err)
|
||||
}
|
||||
|
||||
// Successfully decoded the response -- cache the raw bytes for later access.
|
||||
authorizedIntegrationCacheSetJSON(urlString, buf.Bytes())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compare a map[string]any of incoming claims against an array of claim rules. All rules must match successfully or
|
||||
// else an error with the mismatch detail is returned.
|
||||
func (a *AuthorizedIntegration) checkClaims(incomingClaims any, stored *auth_model.ClaimRules) error {
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("mismatch openid metadata", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) {
|
||||
openIDTweak(func(oidc *auth.AuthorizedIntegrationOpenIDConfiguration, _ *AuthorizedIntegrationTester) {
|
||||
oidc.Issuer = "https://whoops.example.org"
|
||||
}))
|
||||
defer ait.close()
|
||||
|
|
@ -451,7 +451,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("signing alg values supported doesn't include in-use alg", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) {
|
||||
openIDTweak(func(oidc *auth.AuthorizedIntegrationOpenIDConfiguration, _ *AuthorizedIntegrationTester) {
|
||||
oidc.IDTokenSigningAlgValuesSupported = []string{"WEIRD"}
|
||||
}))
|
||||
defer ait.close()
|
||||
|
|
@ -517,7 +517,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
return jwtSigningKey
|
||||
}),
|
||||
openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) {
|
||||
openIDTweak(func(oidc *auth.AuthorizedIntegrationOpenIDConfiguration, _ *AuthorizedIntegrationTester) {
|
||||
oidc.IDTokenSigningAlgValuesSupported = []string{alg}
|
||||
}),
|
||||
)
|
||||
|
|
@ -531,7 +531,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
t.Run("JWKS", func(t *testing.T) {
|
||||
t.Run("jwks_uri host mismatch", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
openIDTweak(func(oidc *openIDConfiguration, ait *AuthorizedIntegrationTester) {
|
||||
openIDTweak(func(oidc *auth.AuthorizedIntegrationOpenIDConfiguration, ait *AuthorizedIntegrationTester) {
|
||||
oidc.JwksURI = "https://whoops.example.org/.keys"
|
||||
}))
|
||||
defer ait.close()
|
||||
|
|
@ -542,7 +542,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("non-HTTPS JWKS address", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
openIDTweak(func(oidc *openIDConfiguration, ait *AuthorizedIntegrationTester) {
|
||||
openIDTweak(func(oidc *auth.AuthorizedIntegrationOpenIDConfiguration, ait *AuthorizedIntegrationTester) {
|
||||
oidc.JwksURI = strings.ReplaceAll(ait.testServer.URL, "https://", "http://")
|
||||
}))
|
||||
defer ait.close()
|
||||
|
|
@ -553,7 +553,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("missing key", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
jwksTweak(func(keys *openIDKeys) {
|
||||
jwksTweak(func(keys *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
keys.Keys = []map[string]any{}
|
||||
}))
|
||||
defer ait.close()
|
||||
|
|
@ -564,7 +564,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("alg missing", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
jwksTweak(func(keys *openIDKeys) {
|
||||
jwksTweak(func(keys *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
for k := range keys.Keys {
|
||||
delete(keys.Keys[k], "alg")
|
||||
}
|
||||
|
|
@ -577,7 +577,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("alg mismatch", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
jwksTweak(func(keys *openIDKeys) {
|
||||
jwksTweak(func(keys *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
for k := range keys.Keys {
|
||||
keys.Keys[k]["alg"] = "WEIRD"
|
||||
}
|
||||
|
|
@ -590,7 +590,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("use missing", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
jwksTweak(func(keys *openIDKeys) {
|
||||
jwksTweak(func(keys *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
for k := range keys.Keys {
|
||||
delete(keys.Keys[k], "use")
|
||||
}
|
||||
|
|
@ -603,7 +603,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("use isn't 'sig'", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
jwksTweak(func(keys *openIDKeys) {
|
||||
jwksTweak(func(keys *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
for k := range keys.Keys {
|
||||
keys.Keys[k]["use"] = "enc"
|
||||
}
|
||||
|
|
@ -616,7 +616,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("large JWKS document", func(t *testing.T) {
|
||||
ait := newAITester(t,
|
||||
jwksTweak(func(keys *openIDKeys) {
|
||||
jwksTweak(func(keys *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
var keyContents map[string]any
|
||||
for _, v := range keys.Keys {
|
||||
keyContents = v
|
||||
|
|
@ -657,7 +657,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
t.Run("cache", func(t *testing.T) {
|
||||
t.Run("miss and store", func(t *testing.T) {
|
||||
c := cache.NewMockCache(t)
|
||||
defer test.MockVariableValue(&getCache, func() mc.Cache { return c })()
|
||||
defer test.MockVariableValue(&auth.GetAuthorizedIntegrationCache, func() mc.Cache { return c })()
|
||||
defer test.MockVariableValue(&setting.AuthorizedIntegration.CacheTTL, 10*time.Minute)()
|
||||
|
||||
var cacheKey string
|
||||
|
|
@ -698,12 +698,12 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
var oidcMetadata []byte
|
||||
var jwksData []byte
|
||||
ait := newAITester(t,
|
||||
openIDTweak(func(oi *openIDConfiguration, ait *AuthorizedIntegrationTester) {
|
||||
openIDTweak(func(oi *auth.AuthorizedIntegrationOpenIDConfiguration, ait *AuthorizedIntegrationTester) {
|
||||
var err error
|
||||
oidcMetadata, err = json.Marshal(oi)
|
||||
require.NoError(t, err)
|
||||
}),
|
||||
jwksTweak(func(oi *openIDKeys) {
|
||||
jwksTweak(func(oi *auth.AuthorizedIntegrationOpenIDKeys) {
|
||||
var err error
|
||||
jwksData, err = json.Marshal(oi)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -714,7 +714,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("cache returns []byte", func(t *testing.T) {
|
||||
c := cache.NewMockCache(t)
|
||||
defer test.MockVariableValue(&getCache, func() mc.Cache { return c })()
|
||||
defer test.MockVariableValue(&auth.GetAuthorizedIntegrationCache, func() mc.Cache { return c })()
|
||||
|
||||
c.On("Get",
|
||||
mock.MatchedBy(func(key string) bool {
|
||||
|
|
@ -732,7 +732,7 @@ func TestAuthorizedIntegration(t *testing.T) {
|
|||
|
||||
t.Run("cache returns string", func(t *testing.T) {
|
||||
c := cache.NewMockCache(t)
|
||||
defer test.MockVariableValue(&getCache, func() mc.Cache { return c })()
|
||||
defer test.MockVariableValue(&auth.GetAuthorizedIntegrationCache, func() mc.Cache { return c })()
|
||||
|
||||
c.On("Get",
|
||||
mock.MatchedBy(func(key string) bool {
|
||||
|
|
@ -842,7 +842,7 @@ type AuthorizedIntegrationTester struct {
|
|||
testServer *httptest.Server
|
||||
resetHTTPClient func()
|
||||
tweaks []tweak
|
||||
ii *MockInternalIssuer
|
||||
ii *auth.MockInternalIssuer
|
||||
}
|
||||
|
||||
func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
|
||||
|
|
@ -876,7 +876,7 @@ func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
|
|||
|
||||
ait.testServer = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/actions/.well-known/openid-configuration" {
|
||||
retval := &openIDConfiguration{
|
||||
retval := &auth.AuthorizedIntegrationOpenIDConfiguration{
|
||||
Issuer: ait.dbAI.Issuer,
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
JwksURI: fmt.Sprintf("%s/.keys", ait.dbAI.Issuer),
|
||||
|
|
@ -898,7 +898,7 @@ func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
|
|||
for k, v := range jwk {
|
||||
jwkMapAny[k] = v // convert map[string]string -> map[string]any
|
||||
}
|
||||
retval := &openIDKeys{
|
||||
retval := &auth.AuthorizedIntegrationOpenIDKeys{
|
||||
Keys: []map[string]any{jwkMapAny},
|
||||
}
|
||||
for _, tweak := range ait.tweaks {
|
||||
|
|
@ -913,11 +913,11 @@ func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
|
|||
}))
|
||||
|
||||
// trust TLS cert of our mock client by inserting the test client for our test server into the global aiHTTPClient
|
||||
ait.resetHTTPClient = test.MockVariableValue(&aiHTTPClient, ait.testServer.Client())
|
||||
// prevent self-initialization of the HTTP client during unit testing -- this means that a real client cant' be
|
||||
// created and aiHTTPClient will always be nil (other than when mocked), but that's fine because we don't want to do
|
||||
// external HTTP traffic in these tests
|
||||
initHTTPClient.Do(func() {})
|
||||
ait.resetHTTPClient = test.MockVariableValue(
|
||||
&auth.GetAuthorizedIntegrationHTTPClient,
|
||||
func() *http.Client {
|
||||
return ait.testServer.Client()
|
||||
})
|
||||
|
||||
ait.dbAI = &auth_model.AuthorizedIntegration{
|
||||
UserID: 2,
|
||||
|
|
@ -956,8 +956,8 @@ func newInternalIssuerAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegra
|
|||
}
|
||||
innerTweaks = append(innerTweaks, tweaks...)
|
||||
ait := newAITester(t, innerTweaks...)
|
||||
ii := NewMockInternalIssuer(t)
|
||||
internalIssuers["/fake-jwt-issuer"] = ii
|
||||
ii := auth.NewMockInternalIssuer(t)
|
||||
auth.RegisterInternalIssuerForTesting(t, "/fake-jwt-issuer", ii)
|
||||
ii.On("IssuerPlaceholder").Return("urn:forgejo:authorized-issuer:internal:test1")
|
||||
ii.On("SigningKey").Return(ait.jwtSigningKey)
|
||||
ait.ii = ii
|
||||
|
|
@ -1015,9 +1015,9 @@ type claimTweak func(*flexibleClaims)
|
|||
|
||||
type aiTweak func(*AuthorizedIntegration)
|
||||
|
||||
type openIDTweak func(*openIDConfiguration, *AuthorizedIntegrationTester)
|
||||
type openIDTweak func(*auth.AuthorizedIntegrationOpenIDConfiguration, *AuthorizedIntegrationTester)
|
||||
|
||||
type jwksTweak func(*openIDKeys)
|
||||
type jwksTweak func(*auth.AuthorizedIntegrationOpenIDKeys)
|
||||
|
||||
type aiDBTweak func(*auth_model.AuthorizedIntegration)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package method
|
||||
package auth
|
||||
|
||||
import (
|
||||
"forgejo.org/modules/jwtx"
|
||||
|
|
@ -37,7 +37,7 @@ func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_mode
|
|||
// resources (public-only and specific repositories), and other similar checks.
|
||||
func ValidateAccessToken(token *auth_model.AccessToken, repoResources []*auth_model.AccessTokenResourceRepo) error {
|
||||
// Other validations may be added here in the future.
|
||||
return validateRepositoryResource(token.ResourceAllRepos, token.Scope, len(repoResources))
|
||||
return ValidateRepositoryResource(token.ResourceAllRepos, token.Scope, len(repoResources))
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -46,7 +46,7 @@ var (
|
|||
ErrSpecifiedReposInvalidScope = errors.New("specified repository access token: invalid scope")
|
||||
)
|
||||
|
||||
func validateRepositoryResource(resourceAllRepos bool, scope auth_model.AccessTokenScope, numRepoResources int) error {
|
||||
func ValidateRepositoryResource(resourceAllRepos bool, scope auth_model.AccessTokenScope, numRepoResources int) error {
|
||||
// Access tokens with broad access to all resources don't have any relevant validation rules to apply.
|
||||
if resourceAllRepos {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ package authz
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
)
|
||||
|
||||
var ErrAuthorizedIntegrationBadUI = errors.New("invalid authorized integration UI")
|
||||
|
||||
func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *auth_model.AuthorizedIntegration) (AuthorizationReducer, error) {
|
||||
if ai.ResourceAllRepos {
|
||||
if publicOnly, err := ai.Scope.PublicOnly(); err != nil {
|
||||
|
|
@ -34,16 +31,3 @@ func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *au
|
|||
}
|
||||
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
|
||||
}
|
||||
|
||||
// Validate that an authorized integration's state is valid for creation. For example, that it doesn't have a
|
||||
// conflicting set of resources (public-only and specific repositories), and other similar checks.
|
||||
func ValidateAuthorizedIntegration(ai *auth_model.AuthorizedIntegration, repoResources []*auth_model.AuthorizedIntegResourceRepo) error {
|
||||
switch ai.UI {
|
||||
case auth_model.AuthorizedIntegrationUIGeneric,
|
||||
auth_model.AuthorizedIntegrationUIForgejoActionsLocal:
|
||||
break
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid UI: %q", ErrAuthorizedIntegrationBadUI, ai.UI)
|
||||
}
|
||||
return validateRepositoryResource(ai.ResourceAllRepos, ai.Scope, len(repoResources))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
package authz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/auth"
|
||||
|
|
@ -45,71 +44,3 @@ func TestGetAuthorizationReducerForAuthorizedIntegration(t *testing.T) {
|
|||
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAuthorizedIntegration(t *testing.T) {
|
||||
t.Run("valid - all access", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
ResourceAllRepos: true,
|
||||
Scope: auth.AccessTokenScopeReadRepository,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
}
|
||||
err := ValidateAuthorizedIntegration(ai, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid - specified repos", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
ResourceAllRepos: false,
|
||||
Scope: auth.AccessTokenScopeReadRepository,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
}
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid - no specified repos", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
ResourceAllRepos: false,
|
||||
Scope: auth.AccessTokenScopeReadRepository,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
}
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, ErrSpecifiedReposNone)
|
||||
})
|
||||
|
||||
t.Run("invalid - specified repos & public-only", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
ResourceAllRepos: false,
|
||||
Scope: auth.AccessTokenScope(strings.Join([]string{string(auth.AccessTokenScopePublicOnly), string(auth.AccessTokenScopeReadRepository)}, ",")),
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
}
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, ErrSpecifiedReposNoPublicOnly)
|
||||
})
|
||||
|
||||
t.Run("invalid - specified repos unsupported scopes", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
ResourceAllRepos: false,
|
||||
Scope: auth.AccessTokenScopeReadAdmin,
|
||||
UI: auth.AuthorizedIntegrationUIGeneric,
|
||||
}
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, ErrSpecifiedReposInvalidScope)
|
||||
require.ErrorContains(t, err, string(auth.AccessTokenScopeReadAdmin))
|
||||
})
|
||||
|
||||
t.Run("invalid - missing UI", func(t *testing.T) {
|
||||
ai := &auth.AuthorizedIntegration{
|
||||
ResourceAllRepos: false,
|
||||
Scope: auth.AccessTokenScopeReadAdmin,
|
||||
}
|
||||
resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}}
|
||||
err := ValidateAuthorizedIntegration(ai, resources)
|
||||
require.ErrorIs(t, err, ErrAuthorizedIntegrationBadUI)
|
||||
require.ErrorContains(t, err, "invalid UI: \"\"")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
<div class="flex-item-trailing">
|
||||
<a class="ui primary tiny button" href="{{AppSubUrl}}/user/settings/authorized-integrations/{{.UI}}/{{.ID}}">
|
||||
{{svg "octicon-pencil" 16 "tw-mr-1"}}
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.view"}}
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.edit"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@
|
|||
{{ctx.Locale.Tr "settings.authorized_integration.claims.generic"}}
|
||||
</h4>
|
||||
<div class="ui attached bottom segment">
|
||||
<div class="field">
|
||||
<div class="required field {{if .Err_Issuer}}error{{end}}">
|
||||
<label for="issuer">{{ctx.Locale.Tr "settings.authorized_integration.field.issuer"}}</label>
|
||||
<input id="issuer" name="issuer" value="{{.Form.Issuer}}" readonly>
|
||||
<input id="issuer" name="issuer" value="{{.Form.Issuer}}" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="content">{{ctx.Locale.Tr "settings.authorized_integration.field.claim_rules"}}</label>
|
||||
<textarea id="content" name="content" class="tw-hidden">{{.Form.ClaimRules}}</textarea>
|
||||
<div class="field {{if .Err_ClaimRules}}error{{end}}">
|
||||
<label for="claim_rules">{{ctx.Locale.Tr "settings.authorized_integration.field.claim_rules"}}</label>
|
||||
<textarea id="claim_rules" name="claim_rules" class="tw-hidden">{{.Form.ClaimRules}}</textarea>
|
||||
{{template "shared/codemirror_container" .}}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@
|
|||
<label>{{ctx.Locale.Tr "settings.repo_and_org_access"}}</label>
|
||||
<div class="field">
|
||||
<div class="field ui radio checkbox">
|
||||
<input id="resource-all" class="enable-system" type="radio" name="resource" value="all" {{if eq .Form.Resource "all"}}checked{{end}} disabled>
|
||||
<input id="resource-all" class="enable-system" type="radio" name="resource" value="all" {{if eq .Form.Resource "all"}}checked{{end}}>
|
||||
<label for="resource-all">{{ctx.Locale.Tr "settings.permissions_access_all"}}</label>
|
||||
<p class="help">{{ctx.Locale.Tr "settings.access_token.resource_all_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field ui radio checkbox">
|
||||
<input id="resource-public-only" class="enable-system" type="radio" name="resource" value="public-only" {{if eq .Form.Resource "public-only"}}checked{{end}} disabled>
|
||||
<input id="resource-public-only" class="enable-system" type="radio" name="resource" value="public-only" {{if eq .Form.Resource "public-only"}}checked{{end}}>
|
||||
<label for="resource-public-only">{{ctx.Locale.Tr "settings.permissions_public_only"}}</label>
|
||||
<p class="help">
|
||||
{{ctx.Locale.Tr "settings.access_token.resource_public_only_help"}}
|
||||
|
|
@ -25,8 +25,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field ui radio checkbox">
|
||||
<input id="resource-repo-specific" class="enable-system" type="radio" name="resource" value="repo-specific" {{if eq .Form.Resource "repo-specific"}}checked{{end}} disabled>
|
||||
<div class="field ui radio checkbox {{if .Err_SelectedRepo}}error{{end}}">
|
||||
<input id="resource-repo-specific" class="enable-system" type="radio" name="resource" value="repo-specific" {{if eq .Form.Resource "repo-specific"}}checked{{end}}>
|
||||
<label for="resource-repo-specific">{{ctx.Locale.Tr "settings.permissions_access_specific_repositories"}}</label>
|
||||
<p class="help">
|
||||
{{ctx.Locale.Tr "settings.access_token.resource_specific_repo_help"}}
|
||||
|
|
@ -36,9 +36,108 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div id="repo-selector-wrapper" role="group" class="field tw-mt-4">
|
||||
<div id="repo-selector" class="tw-flex tw-flex-wrap tw-gap-8">
|
||||
|
||||
<!-- left-hand side: repo list from a search -->
|
||||
<div class="ui tab active list tw-flex-1">
|
||||
<h5>
|
||||
{{ctx.Locale.Tr "settings.access_token.available_repositories"}}
|
||||
</h5>
|
||||
|
||||
<div class="ui small fluid action left icon input tw-mb-3">
|
||||
<input type="search" name="repo_search" spellcheck="false" {{if eq .Autofocus "search"}}autofocus{{end}} placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.repo_search}}">
|
||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||
<button class="ui small icon button" aria-label="{{ctx.Locale.Tr "search.search"}}" type="submit" name="set_page" value="1" formnovalidate="true" formmethod="get" formaction="#resource-repo-specific">
|
||||
{{svg "octicon-search" 16}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{if eq (len .Repos) 0}}
|
||||
{{ctx.Locale.Tr "settings.access_token.no_repositories_found"}}
|
||||
{{else}}
|
||||
<div class="tw-grid tw-items-center" style="grid-template-columns: min-content 1fr min-content;">
|
||||
{{range .Repos}}
|
||||
{{template "user/settings/repo_icon" .}}
|
||||
<div class="text truncate">
|
||||
{{.FullName}}
|
||||
</div>
|
||||
<button class="ui primary button tw-ml-2 tw-my-1 tiny" type="submit" aria-label="{{ctx.Locale.Tr "repo.editor.add" .FullName}}" formnovalidate="true" name="add_selected_repo" value="{{.FullName}}" formmethod="get" formaction="#resource-repo-specific">
|
||||
{{ctx.Locale.Tr "add"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Can't use base/paginate template include here because all the pagination links in
|
||||
that template are simple <a href=...> links. Here, we need to turn them into form
|
||||
buttons so that we can submit the current form. If a user just changed a value (eg. set
|
||||
the token name, changed a selected permission) and then clicked a pagination button, the
|
||||
new value that they changed needs to be submitted. base/paginate would allow preserving
|
||||
old values from before the change, but not new updates. Implementing here also allows
|
||||
the use of smaller styling. */}}
|
||||
{{with .Page.Paginater}}
|
||||
<input type="hidden" name="page" value="{{.Current}}">
|
||||
<div class="center page buttons">
|
||||
<div class="ui borderless pagination menu mini">
|
||||
<button class="item navigation {{if .IsFirst}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="1" formmethod="get" formaction="#resource-repo-specific">
|
||||
{{svg "gitea-double-chevron-left" 16 "tw-mr-1"}}
|
||||
<span class="navigation_label">{{ctx.Locale.Tr "admin.first_page"}}</span>
|
||||
</button>
|
||||
<button class="item navigation {{if not .HasPrevious}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.Previous}}" formmethod="get" formaction="#resource-repo-specific">
|
||||
{{svg "octicon-chevron-left" 16 "tw-mr-1"}}
|
||||
<span class="navigation_label">{{ctx.Locale.Tr "repo.issues.previous"}}</span>
|
||||
</button>
|
||||
{{range .Pages}}
|
||||
{{if eq .Num -1}}
|
||||
<a class="disabled item">...</a>
|
||||
{{else}}
|
||||
<button class="item navigation {{if .IsCurrent}}active{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.Num}}" formmethod="get" formaction="#resource-repo-specific">
|
||||
{{.Num}}
|
||||
</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<button class="item navigation {{if not .HasNext}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.Next}}" formmethod="get" formaction="#resource-repo-specific">
|
||||
<span class="navigation_label">{{ctx.Locale.Tr "repo.issues.next"}}</span>
|
||||
{{svg "octicon-chevron-right" 16 "tw-ml-1"}}
|
||||
</button>
|
||||
<button class="item navigation {{if .IsLast}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.TotalPages}}" formmethod="get" formaction="#resource-repo-specific">
|
||||
<span class="navigation_label">{{ctx.Locale.Tr "admin.last_page"}}</span>
|
||||
{{svg "gitea-double-chevron-right" 16 "tw-ml-1"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- right-hand side: selected repositories -->
|
||||
<div class="tw-flex-1">
|
||||
<h5>{{ctx.Locale.TrPluralString (len .SelectedRepos) "settings.access_token.selected_repositories" (len .SelectedRepos)}}</h5>
|
||||
{{if eq (len .SelectedRepos) 0}}
|
||||
<div class="tw-my-2">
|
||||
{{ctx.Locale.Tr "settings.access_token.no_repositories_selected"}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="tw-grid tw-items-center tw-auto-rows-min" style="grid-template-columns: min-content 1fr min-content;">
|
||||
{{range .SelectedRepos}}
|
||||
<input type="hidden" name="selected_repo" value="{{.FullName}}">
|
||||
{{template "user/settings/repo_icon" .}}
|
||||
<div class="text tw-text-nowrap"> <!-- nowrap, !truncate, ensures that this can be fully seen and ensures this side of the control has an appropriate min-width -->
|
||||
{{.FullName}}
|
||||
</div>
|
||||
<button class="ui button tw-ml-2 tw-my-1 tiny" type="submit" aria-label="{{ctx.Locale.Tr "settings.access_token.remove" .FullName}}" formnovalidate="true" name="remove_selected_repo" value="{{.FullName}}" formmethod="get" formaction="#resource-repo-specific">
|
||||
{{ctx.Locale.Tr "remove"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="field">
|
||||
<div class="field {{if .Err_Scope}}error{{end}}">
|
||||
<label>
|
||||
{{ctx.Locale.Tr "settings.select_permissions"}}
|
||||
</label>
|
||||
|
|
@ -52,7 +151,7 @@
|
|||
{{.}}
|
||||
</label>
|
||||
<div class="gitea-select">
|
||||
<select class="ui selection" name="scope" id="scope-{{.}}" disabled>
|
||||
<select class="ui selection" name="scope" id="scope-{{.}}">
|
||||
<option value="">
|
||||
{{ctx.Locale.Tr "settings.permission_no_access"}}
|
||||
</option>
|
||||
|
|
@ -70,6 +169,11 @@
|
|||
|
||||
</div>
|
||||
|
||||
<div class="ui segment">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "settings.authorized_integration.save"}}</button>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@
|
|||
<form id="scoped-access-form" class="ui form" action="{{.Link}}" method="post">
|
||||
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.view_page_title" .Form.Name}}
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.edit_page_title" .Form.Name}}
|
||||
</h4>
|
||||
<div class="ui attached bottom segment">
|
||||
<div class="field">
|
||||
<div class="required field {{if .Err_Name}}error{{end}}">
|
||||
<label for="name">{{ctx.Locale.Tr "settings.authorized_integration.field.name"}}</label>
|
||||
<input id="name" name="name" value="{{.Form.Name}}" maxlength="255" readonly>
|
||||
<input id="name" name="name" value="{{.Form.Name}}" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">{{ctx.Locale.Tr "settings.authorized_integration.field.description"}}</label>
|
||||
<textarea name="description" rows="5" placeholder="{{ctx.Locale.Tr "settings.authorized_integration.field.description.placeholder"}}" readonly>{{.Form.Description}}</textarea>
|
||||
<textarea id="description" name="description" rows="5" placeholder="{{ctx.Locale.Tr "settings.authorized_integration.field.description.placeholder"}}">{{.Form.Description}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
|
|
|||
13
tests/e2e/fixtures/authorized_integration.yml
Normal file
13
tests/e2e/fixtures/authorized_integration.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-
|
||||
id: 1
|
||||
user_id: 2
|
||||
ui: generic
|
||||
scope: write:repository,read:issue
|
||||
resource_all_repos: true
|
||||
name: "Example AI"
|
||||
description: "This is an authorized integration.\nThis example is just for viewing and editing."
|
||||
issuer: "urn:forgejo:authorized-integrations:actions" # avoid an external dependency on validating an issuer on edit
|
||||
audience: u:2:7a6a47fb-6252-48b2-b0bb-e39158b11a36
|
||||
claim_rules: "{}"
|
||||
created_unix: 1778959784
|
||||
updated_unix: 1778959784
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
// web_src/css/{form,user}.css
|
||||
// @watch end
|
||||
|
||||
import {expect} from '@playwright/test';
|
||||
import {expect, type Page} from '@playwright/test';
|
||||
import {test, login_user, login} from './utils_e2e.ts';
|
||||
import {screenshot} from './shared/screenshots.ts';
|
||||
import {validate_form} from './shared/forms.ts';
|
||||
|
|
@ -237,3 +237,191 @@ test('User: Add specific repo access token error', async ({browser}, workerInfo)
|
|||
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
|
||||
await expect(page.getByRole('button', {name: 'Remove org17/big_test_private_4'})).toBeVisible();
|
||||
});
|
||||
|
||||
test('User: List authorized integrations', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await expect(page.locator('.flex-item-title')).toContainText('Example AI');
|
||||
await expect(page.locator('.flex-item-body')).toContainText('Added on 2026-05-16');
|
||||
await expect(page.locator('.flex-item-body')).toContainText('No recent activity');
|
||||
});
|
||||
|
||||
async function validateClaimRules(page: Page, expected: string) {
|
||||
await expect(async () => {
|
||||
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
||||
expect(internal).toStrictEqual(expected);
|
||||
}).toPass({timeout: 3000});
|
||||
await expect(page.locator('#claim_rules')).toHaveValue(expected);
|
||||
}
|
||||
|
||||
test('User: View authorized integration', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('Example AI');
|
||||
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('This is an authorized integration.\nThis example is just for viewing and editing.');
|
||||
await expect(page.getByRole('textbox', {name: 'Audience (aud Claim)'})).toHaveValue('u:2:7a6a47fb-6252-48b2-b0bb-e39158b11a36');
|
||||
await expect(page.getByRole('textbox', {name: 'Issuer (iss Claim)'})).toHaveValue('urn:forgejo:authorized-integrations:actions');
|
||||
|
||||
// Claim rules JSON codemirror editor:
|
||||
const editor = page.locator('.cm-content');
|
||||
await expect(editor).toHaveAttribute('data-language', 'json', {timeout: 3000});
|
||||
await validateClaimRules(page, '{\n "rules": null\n}');
|
||||
|
||||
await expect(page.getByRole('radio', {name: 'All (public, private, and limited)'})).toBeChecked();
|
||||
await expect(page.getByRole('radio', {name: 'Public only'})).not.toBeChecked();
|
||||
await expect(page.getByRole('radio', {name: 'Specific repositories'})).not.toBeChecked();
|
||||
|
||||
await expect(page.getByRole('combobox', {name: 'issue'})).toHaveValue('read:issue');
|
||||
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('write:repository');
|
||||
await expect(page.getByRole('combobox', {name: 'user'})).toHaveValue('');
|
||||
await expect(page.getByRole('combobox', {name: 'admin'})).toBeHidden(); // not an admin user
|
||||
});
|
||||
|
||||
test('User: Edit authorized integration basic fields', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
|
||||
await page.getByRole('textbox', {name: 'Name'}).fill('Example AI (Updated!)');
|
||||
await page.getByRole('textbox', {name: 'Description'}).fill('Updated by Edit authorized integration basic field test');
|
||||
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
|
||||
// Returns to the list page; validate the updated name is present, and that it isn't marked
|
||||
// as "used" just because it was edited:
|
||||
await expect(page.locator('.flex-item-title')).toContainText('Example AI (Updated!)');
|
||||
await expect(page.locator('.flex-item-body')).toContainText('Added on 2026-05-16');
|
||||
await expect(page.locator('.flex-item-body')).toContainText('No recent activity');
|
||||
|
||||
// Reopen to check description:
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('Example AI (Updated!)');
|
||||
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('Updated by Edit authorized integration basic field test');
|
||||
|
||||
// Restore values to avoid affecting other tests and other platforms:
|
||||
await page.getByRole('textbox', {name: 'Name'}).fill('Example AI');
|
||||
await page.getByRole('textbox', {name: 'Description'}).fill('This is an authorized integration.\nThis example is just for viewing and editing.');
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
|
||||
});
|
||||
|
||||
test('User: Edit authorized integration basic fields validation error', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
await page.getByRole('textbox', {name: 'Name'}).fill('\t'); // trims to empty
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
|
||||
await expect(page.locator('.flash-error')).toContainText('Authorized integration name is required.');
|
||||
await expect(page.getByRole('textbox', {name: 'Name'}).locator('..')).toHaveClass('required field error');
|
||||
});
|
||||
|
||||
test('User: Edit authorized integration issuer validation error', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('ftp://example.org'); // designed to hit "unsupported URL scheme" error, no external traffic involved
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
|
||||
await expect(page.locator('.flash-error')).toContainText(/Issuer validation failed:/);
|
||||
await expect(page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).locator('..')).toHaveClass('required field error');
|
||||
});
|
||||
|
||||
test('User: Edit authorized integration claim rules', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
|
||||
const editor = page.locator('.cm-content');
|
||||
await editor.click(); // Focus codemirror editor
|
||||
await page.keyboard.press('ControlOrMeta+A'); // select all
|
||||
await page.keyboard.press('Backspace'); // delete
|
||||
await page.keyboard.type('{"rules": [{"claim": "sub", "compare": "eq", "value": "a subject"}]}', {delay: 10});
|
||||
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
|
||||
// Reopen to check claim rules saved:
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
await validateClaimRules(page, '{\n "rules": [\n {\n "claim": "sub",\n "compare": "eq",\n "value": "a subject"\n }\n ]\n}');
|
||||
|
||||
// Restore values to avoid affecting other tests and other platforms:
|
||||
await editor.click(); // Focus codemirror editor
|
||||
await page.keyboard.press('ControlOrMeta+A'); // select all
|
||||
await page.keyboard.press('Backspace'); // delete
|
||||
await page.keyboard.type('{"rules": null}', {delay: 10});
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
|
||||
});
|
||||
|
||||
test('User: Edit authorized integration claim rules validation error', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
|
||||
const editor = page.locator('.cm-content');
|
||||
await editor.click(); // Focus codemirror editor
|
||||
await page.keyboard.type('{{{{{{', {delay: 10}); // type some incomplete garbage at the end
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
|
||||
await expect(page.locator('.flash-error')).toContainText(/Claim Rules validation failed:/);
|
||||
});
|
||||
|
||||
test('User: Edit authorized integration specific repo', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
|
||||
// clicking specific repositories will display currently available repositories:
|
||||
await expect(page.getByText('org17/big_test_private_4')).toBeHidden();
|
||||
await page.getByRole('radio', {name: 'Specific repositories'}).click();
|
||||
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
|
||||
await expect(page.getByText('user2/commits_search_test')).toBeVisible(); // another repo, will be used to verify search worked
|
||||
|
||||
await page.getByPlaceholder('Search repos…').fill('big_test_private_4');
|
||||
await page.getByRole('button', {name: 'Search…'}).click();
|
||||
|
||||
// verify search results visible:
|
||||
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
|
||||
await expect(page.getByText('user2/commits_search_test')).toBeHidden();
|
||||
|
||||
// after performing a search, verify that the name, 'selected repositories', and selected permissions are maintained
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue(/^Example AI/);
|
||||
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
|
||||
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('write:repository');
|
||||
|
||||
// Add the big_test_private_4 repo.
|
||||
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
|
||||
await expect(page.getByText('Selected repository (1)')).toBeVisible();
|
||||
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
|
||||
|
||||
// Remove it to test remove, and then re-add
|
||||
await page.getByRole('button', {name: 'Remove org17/big_test_private_4'}).click();
|
||||
await expect(page.getByText('Selected repositories (0)')).toBeVisible();
|
||||
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
|
||||
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
|
||||
|
||||
// Save authorized integration
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
|
||||
// Reopen to check change to repo-specific was saved:
|
||||
await page.getByRole('link', {name: 'Edit'}).click();
|
||||
await expect(page.getByRole('radio', {name: 'All (public, private, and limited)'})).not.toBeChecked();
|
||||
await expect(page.getByRole('radio', {name: 'Public only'})).not.toBeChecked();
|
||||
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
|
||||
await expect(page.getByRole('button', {name: 'Remove org17/big_test_private_4'})).toBeVisible();
|
||||
|
||||
// Restore values to avoid affecting other tests and other platforms:
|
||||
await page.getByRole('radio', {name: 'All (public, private, and limited)'}).click();
|
||||
await page.getByRole('button', {name: 'Save authorized integration'}).click();
|
||||
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import (
|
|||
"forgejo.org/modules/util"
|
||||
"forgejo.org/modules/web"
|
||||
"forgejo.org/routers"
|
||||
auth_method "forgejo.org/services/auth/method"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
"forgejo.org/services/auth/source/remote"
|
||||
app_context "forgejo.org/services/context"
|
||||
"forgejo.org/services/mailer"
|
||||
|
|
@ -831,7 +831,7 @@ func newAITester(t *testing.T, setupAI ...func(*auth.AuthorizedIntegration)) *Au
|
|||
|
||||
// trust TLS cert of our NewTLSServer by inserting the test client for our test server in as the HTTP client to use
|
||||
ait.resetHTTPClient = test.MockVariableValue(
|
||||
&auth_method.GetAuthorizedIntegrationHTTPClient,
|
||||
&auth_service.GetAuthorizedIntegrationHTTPClient,
|
||||
func() *http.Client {
|
||||
return ait.testServer.Client()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1374,7 +1374,7 @@ func TestAuthorizedIntegrationView(t *testing.T) {
|
|||
assert.Equal(t, "An Authorized Integration created for the test case TestAuthorizedIntegrationView.\nIt's pretty neat.", htmlDoc.doc.Find("textarea[name='description']").Text())
|
||||
htmlDoc.AssertAttrEqual(t, "#audience", "value", ait.authorizedIntegration.Audience)
|
||||
htmlDoc.AssertAttrEqual(t, "#issuer", "value", ait.authorizedIntegration.Issuer)
|
||||
assert.Equal(t, "{\n \"rules\": [\n {\n \"claim\": \"custom-claim\",\n \"compare\": \"eq\",\n \"value\": \"custom-claim-value\"\n }\n ]\n}", htmlDoc.doc.Find("textarea[id='content']").Text()) //nolint:testifylint // this isn't a JSON comparison; the formatting here should be exact as it represents the auto-indentation generated by the server
|
||||
assert.Equal(t, "{\n \"rules\": [\n {\n \"claim\": \"custom-claim\",\n \"compare\": \"eq\",\n \"value\": \"custom-claim-value\"\n }\n ]\n}", htmlDoc.doc.Find("textarea[id='claim_rules']").Text()) //nolint:testifylint // this isn't a JSON comparison; the formatting here should be exact as it represents the auto-indentation generated by the server
|
||||
htmlDoc.AssertElementChecked(t, "#resource-all")
|
||||
htmlDoc.AssertElementSelected(t, "#scope-activitypub option[value='write:activitypub']")
|
||||
assert.Equal(t, 0, htmlDoc.Find("#scope-admin").Length()) // not an admin user
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ import {createCodemirror} from './codemirror.ts';
|
|||
|
||||
export function initAuthorizedIntegrationClaimRuleEditor() {
|
||||
if (!$('.user.authorized-integrations').length) return;
|
||||
const _promise = createCodemirror($('#content')[0], 'claims.json', {language: 'JSON'});
|
||||
const _promise = createCodemirror($('#claim_rules')[0], 'claims.json', {language: 'JSON'});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue