feat: ability to edit authorized integration in web UI (#12601)

Extends the UI introduced in #12558 to have edit capabilities.  (not in scope: "Add" for a new Authorized Integration will be the next update to this UI; `create-authorized-integration` CLI is still the only way to create a new record)

This PR includes a few refactoring steps.  The goal of these steps is to have `services/auth` be a single entrypoint for validating, inserting, or updating an authorized integration.  Some logic is moved out of `services/authz` because it is not authorization related, and some is moved out of `services/auth/method` to allow it to be reused during validation without creating a cyclical module dependency.

This PR also adds comprehensive validation to the more complex fields in the authorized integration, such as the issuer and claim rules.  This validation applies to the `forgejo admin user create-authorized-integration` CLI as well.

The visible UI is the same as #12558, but with a "Save" button, and the ability to display errors:

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

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [x] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.
    - Documentation is on my TODO list and will be completed before release.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12601
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak
2026-05-17 18:33:39 +02:00
committed by Mathieu Fenniak
parent 8a1021e2a0
commit cf087a2f12
33 changed files with 1710 additions and 413 deletions
+344
View 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)
@@ -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
View 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
}
+23 -200
View File
@@ -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"
+2 -2
View File
@@ -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
-16
View File
@@ -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: \"\"")
})
}