mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-18 15:37:48 +00:00
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>
344 lines
13 KiB
Go
344 lines
13 KiB
Go
// 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
|
|
}
|