diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 7a85d1e7ae..ae22c02609 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2823,3 +2823,26 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; storage type ;STORAGE_TYPE = local + +;; Authorized integrations are a capability for users to define external systems which can generate JWTs that Forgejo +;; will trust in order to perform API access on behalf of that user. While validating a JWT from an external system, +;; Forgejo makes outgoing HTTP requests to the JWT issuer. +; [authorized_integration] +;; Timeout for HTTP requests to remote servers. Default is 10 seconds. +;REQUEST_TIMEOUT = 10s +; +;; Allowed domains for authorized integrations. Default is blank which means all domains will be allowed (except local +;; networks, see ALLOW_LOCALNETWORKS). +;; Multiple domains can be separated by commas. +;; Wildcards are supported: "github.com, *.github.com" +;ALLOWED_DOMAINS = +; +;; Blocklist for authorized integrations, default is blank. +;; Multiple domains can be separated by commas. +;; Wildcards are supported: "github.com, *.github.com" +;BLOCKED_DOMAINS = +; +;; Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632 and RFC 4291. +;; Default is false. +;; If a domain is allowed by ALLOWED_DOMAINS, this option will be ignored. +;ALLOW_LOCALNETWORKS = false diff --git a/models/auth/authorized_integration.go b/models/auth/authorized_integration.go new file mode 100644 index 0000000000..a8641d44ca --- /dev/null +++ b/models/auth/authorized_integration.go @@ -0,0 +1,146 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package auth + +import ( + "context" + "time" + + "forgejo.org/models/db" + "forgejo.org/modules/timeutil" + "forgejo.org/modules/util" + + "xorm.io/builder" +) + +// An Authorized Integration allow users to define external systems which can generate JSON Web Tokens (JWTs) that +// Forgejo will trust in order to perform API access on behalf of a user defined by the UserID field. +// +// When a JWT is received by Forgejo, the issuer (iss) and audience (aud) claims are used to lookup an authorized +// integration with an exact match. Together these fields serve as a unique key for the authorized issuer. Duplicates +// cannot be permitted because we would not know which user to authenticate the JWT as. +type AuthorizedIntegration struct { + ID int64 `xorm:"pk autoincr"` + + UserID int64 `xorm:"NOT NULL REFERENCES(user, id)"` + Scope AccessTokenScope `xorm:"NOT NULL"` + ResourceAllRepos bool `xorm:"NOT NULL"` // flag for whether AuthorizedIntegrationResourceRepo instances will limit the resources this access token can access (false) or won't limit them (true). + + // Exact-match `iss` claim of the JWT + Issuer string `xorm:"NOT NULL UNIQUE(s)"` + // Exact-match `aud` claim of the JWT + Audience string `xorm:"NOT NULL UNIQUE(s)"` + ClaimRules *ClaimRules `xorm:"NOT NULL JSON"` + + CreatedUnix timeutil.TimeStamp `xorm:"NOT NULL created"` + UpdatedUnix timeutil.TimeStamp `xorm:"NOT NULL updated"` +} + +func init() { + db.RegisterModel(new(AuthorizedIntegration)) +} + +// An [AuthorizedIntegration] can validate the claims in a JWT against a set of rules defined by this structure. +// +// JWTs can contain any number of claims, which are represented as a JSON object. A small number of common claims are +// described in RFC7519 (sec 4.1) which defines JWTs, but most claims are entirely arbitrarily defined by the JWT +// issuer. +// +// For example, eg. a claim may be {"sub": "repo:coolguy/forgejo-runner-testrepo:pull_request"} indicating that an OIDC +// token was received from an Actions execution in a specific repo on a specific event. +// +// Validating the claims from a JWT issuer is a critical part of creating a secure [AuthorizedIssuer]. For example, +// assume that we receive a JWT from a public hosting platform like Codeberg. We will validate that it is a claim +// created by the correct Issuer, Codeberg -- but anyone can do that through Forgejo Actions. We will validate that it +// has the correct audience -- but that's an *input* to Forgejo Actions, so anyone can create a claim on Codeberg with +// an arbitrary audience. The rest of the claims contain the critical information about who ran a Forgejo Action, on +// which repository, and in response to which events, and those must be validated to ensure that an authorized issuer is +// correctly authorized. +// +// Following that an example, a minimum claim rule that would be required for securely using Forgejo Actions would be +// something like: +// +// { +// "rules": [{ +// "claim": "sub", +// "comparison": "eq", +// "value": "repo:forgejo/website:pull_request" +// }] +// } +// +// This defines a single rule which says that the `sub` claim must be exactly equal to +// "repo:forgejo/website:pull_request". Forgejo Actions would generate this subject when an Action is running on the +// repo forgejo/website in response to the pull_request event. +// +// Some JWT claims are JSON objects. The [ClaimNested] comparison operator can be used to define rules that inspect the +// object within a claim. For example, AWS STS generates a claim "https://sts.amazonaws.com/": {...} with values inside +// an object, like "aws_account". A nested claim can inspect those values: +// +// { +// "rules":[{ +// "claim": "https://sts.amazonaws.com/", +// "compare": "nest", +// "nested": {"rules":[ +// {"claim": "aws_account", "compare": "eq", "value": "1234567890"}, +// {"claim": "lambda_source_function_arn", "compare": "eq", "value": "arn:aws:lambda:ca-central-1:1234567890:function:forgejo-oidc-accepting-test"} +// ]} +// } +// +// ]} +// +// This defines a rule that looks into the "https://sts..." claim and verifies the "aws_account" and +// "lambda_source_function_arn" keys match specific known values. +type ClaimRules struct { + Rules []ClaimRule `json:"rules"` +} + +// Defines a single rule that will check the value of one JWT claim. +type ClaimRule struct { + // The target claim, eg. "sub" + Claim string `json:"claim"` + // Comparison rule to use on this claim + Comparison ClaimComparison `json:"compare"` + + // For Comparison of ClaimEqual or ClaimGlob, the specific value or glob to match against + Value string `json:"value,omitempty"` + + // For ClaimNested, the rules to apply to the nested object + Nested *ClaimRules `json:"nested,omitempty"` +} + +type ClaimComparison string + +const ( + ClaimEqual ClaimComparison = "eq" // exactly equal claim + ClaimGlob ClaimComparison = "glob" // glob match complete claim string + ClaimNested ClaimComparison = "nest" // recurse into a claim that is an map[string]any with it's own data fields +) + +func GetAuthorizedIntegration(ctx context.Context, issuer, audience string) (*AuthorizedIntegration, error) { + var ai AuthorizedIntegration + found, err := db.GetEngine(ctx).Where("issuer = ? AND audience = ?", issuer, audience).Get(&ai) + if err != nil { + return nil, err + } else if !found { + return nil, util.ErrNotExist + } + return &ai, nil +} + +// Bump the UpdatedUnix field of this authorized integration to now, tracking when it was last used for authentication. +// To reduce database write workload, this is only tracked by one-minute intervals -- the UPDATE statement conditionally +// avoids writes. +func (ai *AuthorizedIntegration) UpdateLastUsed(ctx context.Context) error { + newTime := timeutil.TimeStampNow() + cnt, err := db.GetEngine(ctx). + Table(&AuthorizedIntegration{}). + Where(builder.Eq{"id": ai.ID}). + Where(builder.Lt{"updated_unix": newTime.AddDuration(-1 * time.Minute)}). + NoAutoTime(). + Update(map[string]any{"updated_unix": newTime}) + if cnt == 1 { + ai.UpdatedUnix = newTime + } + return err +} diff --git a/models/auth/authorized_integration_resource_repo.go b/models/auth/authorized_integration_resource_repo.go new file mode 100644 index 0000000000..98960b5c8a --- /dev/null +++ b/models/auth/authorized_integration_resource_repo.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package auth + +import ( + "forgejo.org/models/db" + "forgejo.org/modules/timeutil" +) + +// Represents a many-to-many join table which indicates specific repositories (RepoID) that can be accessed by an +// authorized integration (IntegID). An authorized integrations's ResourceAllRepos field must be false for records in +// this table to become active. +// +// Model name is shortend (from AuthorizedIntegrationResourceRepo) to accomodate recreate-tables + MySQL, where the +// "tmp_recreate_" + foreign key index name would exceed the max identifier length. +type AuthorizedIntegResourceRepo struct { + ID int64 `xorm:"pk autoincr"` + IntegID int64 `xorm:"NOT NULL REFERENCES(authorized_integration, id)"` // field name shortened (AuthorizationIntegrationID) for max identifier length + RepoID int64 `xorm:"NOT NULL REFERENCES(repository, id)"` + + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` +} + +func init() { + db.RegisterModel(new(AuthorizedIntegResourceRepo)) +} diff --git a/models/auth/authorized_integration_test.go b/models/auth/authorized_integration_test.go new file mode 100644 index 0000000000..54cf97fcb5 --- /dev/null +++ b/models/auth/authorized_integration_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package auth_test + +import ( + "fmt" + "testing" + "time" + + auth_model "forgejo.org/models/auth" + "forgejo.org/models/db" + "forgejo.org/models/unittest" + "forgejo.org/modules/timeutil" + "forgejo.org/modules/util" + + gouuid "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeAuthorizedIntegration(t *testing.T) *auth_model.AuthorizedIntegration { + t.Helper() + + ai := &auth_model.AuthorizedIntegration{ + UserID: 2, + Scope: auth_model.AccessTokenScopeAll, + ResourceAllRepos: true, + Issuer: "https://example.org/", + Audience: fmt.Sprintf("https://forgejo.example.org/api/actions/%s", gouuid.New().String()), + ClaimRules: &auth_model.ClaimRules{}, + } + _, err := db.GetEngine(t.Context()).Insert(ai) + require.NoError(t, err) + + return ai +} + +func TestGetAuthorizedIntegration(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ai := makeAuthorizedIntegration(t) + + get, err := auth_model.GetAuthorizedIntegration(t.Context(), "abc", "123") + require.ErrorIs(t, err, util.ErrNotExist) + assert.Nil(t, get) + + get, err = auth_model.GetAuthorizedIntegration(t.Context(), ai.Issuer, ai.Audience) + require.NoError(t, err) + require.NotNil(t, get) + assert.Equal(t, ai.ID, get.ID) +} + +func TestAuthorizedIntegrationUpdateLastUsed(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + ai := makeAuthorizedIntegration(t) + ai.UpdatedUnix = 0 + cnt, err := db.GetEngine(t.Context()).ID(ai.ID).Cols("updated_unix").NoAutoTime().Update(ai) + require.NoError(t, err) + assert.EqualValues(t, 1, cnt) + + timeutil.MockSet(time.Unix(1777130023, 0)) + defer timeutil.MockUnset() + + assert.EqualValues(t, 0, ai.UpdatedUnix) + require.NoError(t, ai.UpdateLastUsed(t.Context())) + assert.EqualValues(t, 1777130023, ai.UpdatedUnix) // object field updated + assert.EqualValues(t, 1777130023, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) + + // nearly immediate redo should have same timestamp due to the 1 minute deduplication: + timeutil.MockSet(time.Unix(1777130025, 0)) + require.NoError(t, ai.UpdateLastUsed(t.Context())) + assert.EqualValues(t, 1777130023, ai.UpdatedUnix) // object field not updated + assert.EqualValues(t, 1777130023, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) // database field not updated + + // but if it's a little while later.. + timeutil.MockSet(time.Unix(1777131139, 0)) + require.NoError(t, ai.UpdateLastUsed(t.Context())) + assert.EqualValues(t, 1777131139, ai.UpdatedUnix) // object field updated + assert.EqualValues(t, 1777131139, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) // database field updated +} diff --git a/models/forgejo_migrations/v16a_add_authorized_integration.go b/models/forgejo_migrations/v16a_add_authorized_integration.go new file mode 100644 index 0000000000..4cfd5219f4 --- /dev/null +++ b/models/forgejo_migrations/v16a_add_authorized_integration.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "forgejo.org/modules/timeutil" + + "xorm.io/xorm" +) + +func init() { + registerMigration(&Migration{ + Description: "add authorized_integration tables", + Upgrade: addAuthorizedIntegrationTables, + }) +} + +func addAuthorizedIntegrationTables(x *xorm.Engine) error { + type ClaimRules struct{} + type AuthorizedIntegration struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL REFERENCES(user, id)"` + Scope string `xorm:"NOT NULL"` + ResourceAllRepos bool `xorm:"NOT NULL"` + Issuer string `xorm:"NOT NULL UNIQUE(s)"` + Audience string `xorm:"NOT NULL UNIQUE(s)"` + ClaimRules *ClaimRules `xorm:"NOT NULL JSON"` + CreatedUnix timeutil.TimeStamp `xorm:"NOT NULL created"` + UpdatedUnix timeutil.TimeStamp `xorm:"NOT NULL updated"` + } + type AuthorizedIntegResourceRepo struct { + ID int64 `xorm:"pk autoincr"` + IntegID int64 `xorm:"NOT NULL REFERENCES(authorized_integration, id)"` + RepoID int64 `xorm:"NOT NULL REFERENCES(repository, id)"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + } + + _, err := x.SyncWithOptions( + xorm.SyncOptions{IgnoreDropIndices: true}, + new(AuthorizedIntegration), + new(AuthorizedIntegResourceRepo), + ) + return err +} diff --git a/modules/jwtx/signingkey.go b/modules/jwtx/signingkey.go index be1115cb15..db9cf03aa2 100644 --- a/modules/jwtx/signingkey.go +++ b/modules/jwtx/signingkey.go @@ -469,3 +469,97 @@ func InitAsymmetricSigningKey(keyPath, algorithm string) (SigningKey, error) { return signingKey, nil } + +func requiredJWKStr(jwk map[string]any, key string) (string, error) { + vAny, ok := jwk[key] + if !ok { + return "", fmt.Errorf("JWK missing required field %q", key) + } + vStr, ok := vAny.(string) + if !ok { + return "", fmt.Errorf("JWK field %q must be string, but was %T", key, vAny) + } + return vStr, nil +} + +// Reconstructs public key from a JWKS entry (such as those produced by [SigningKey.ToJWK]), parsing the JWK output and +// returning a key object. The key object produced must be usable for [jwt.SigningMethod] interface's [Verify] method, +// for the related signing method -- an [rsa.PublicKey] object, an [ed25519.PublicKey] object, or [ecdsa.PublicKey] +// object, with the currently supported asymmetric algorithms. +func ParseJWKToPublicKey(jwk map[string]any) (any, error) { + kty := jwk["kty"] + + switch kty { + case "RSA": + eStr, err := requiredJWKStr(jwk, "e") + if err != nil { + return nil, err + } + nStr, err := requiredJWKStr(jwk, "n") + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + if err != nil { + return nil, fmt.Errorf("invalid RSA JWK 'e' field: %w", err) + } + nBytes, err := base64.RawURLEncoding.DecodeString(nStr) + if err != nil { + return nil, fmt.Errorf("invalid RSA JWK 'n' field: %w", err) + } + pubKey := &rsa.PublicKey{ + E: int(new(big.Int).SetBytes(eBytes).Int64()), + N: new(big.Int).SetBytes(nBytes), + } + return pubKey, nil + case "OKP": + if jwk["crv"] != "Ed25519" { + return nil, fmt.Errorf("OKP curve %d is not supported; only Ed25519", jwk["crv"]) + } + xStr, err := requiredJWKStr(jwk, "x") + if err != nil { + return nil, err + } + xBytes, err := base64.RawURLEncoding.DecodeString(xStr) + if err != nil { + return nil, fmt.Errorf("invalid EdDSA JWK 'x' field: %w", err) + } + return ed25519.PublicKey(xBytes), nil + case "EC": + xStr, err := requiredJWKStr(jwk, "x") + if err != nil { + return nil, err + } + yStr, err := requiredJWKStr(jwk, "y") + if err != nil { + return nil, err + } + var curve elliptic.Curve + switch jwk["crv"] { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported ECDSA curve in JWK: %s", jwk["crv"]) + } + xBytes, err := base64.RawURLEncoding.DecodeString(xStr) + if err != nil { + return nil, fmt.Errorf("invalid ECDSA JWK 'x' field: %w", err) + } + yBytes, err := base64.RawURLEncoding.DecodeString(yStr) + if err != nil { + return nil, fmt.Errorf("invalid ECDSA JWK 'y' field: %w", err) + } + pubKey := &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + return pubKey, nil + default: + return nil, fmt.Errorf("unsupported key type in JWK: %s", kty) + } +} diff --git a/modules/setting/authorized_integration.go b/modules/setting/authorized_integration.go new file mode 100644 index 0000000000..778232e3ad --- /dev/null +++ b/modules/setting/authorized_integration.go @@ -0,0 +1,21 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +import "time" + +var AuthorizedIntegration = struct { + AllowedDomains string + BlockedDomains string + AllowLocalNetworks bool + RequestTimeout time.Duration +}{} + +func loadAuthorizedIntegrationFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("authorized_integration") + AuthorizedIntegration.AllowedDomains = sec.Key("ALLOWED_DOMAINS").MustString("") + AuthorizedIntegration.BlockedDomains = sec.Key("BLOCKED_DOMAINS").MustString("") + AuthorizedIntegration.AllowLocalNetworks = sec.Key("ALLOW_LOCALNETWORKS").MustBool(false) + AuthorizedIntegration.RequestTimeout = sec.Key("REQUEST_TIMEOUT").MustDuration(10 * time.Second) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 44dad1fab1..bd3dec1f84 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -226,6 +226,7 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadF3From(CfgProvider) + loadAuthorizedIntegrationFrom(CfgProvider) } // LoadSettingsForInstall initializes the settings for install diff --git a/routers/api/shared/middleware.go b/routers/api/shared/middleware.go index 9dd1605783..f6dd30f8b8 100644 --- a/routers/api/shared/middleware.go +++ b/routers/api/shared/middleware.go @@ -53,6 +53,7 @@ func buildAuthGroup() *auth_method.Group { &auth_method.AccessToken{}, &auth_method.ActionRuntimeToken{}, &auth_method.ActionTaskToken{}, + &auth_method.AuthorizedIntegration{}, ) if setting.Service.EnableReverseProxyAuthAPI { group.Add(&auth_method.ReverseProxy{}) diff --git a/services/auth/method/auth_result_authorized_integration.go b/services/auth/method/auth_result_authorized_integration.go new file mode 100644 index 0000000000..6868b4848f --- /dev/null +++ b/services/auth/method/auth_result_authorized_integration.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +import ( + auth_model "forgejo.org/models/auth" + user_model "forgejo.org/models/user" + "forgejo.org/modules/optional" + "forgejo.org/services/auth" +) + +var _ auth.AuthenticationResult = &authorizedIntegrationAuthenticationResult{} + +type authorizedIntegrationAuthenticationResult struct { + *auth.BaseAuthenticationResult + user *user_model.User + scope auth_model.AccessTokenScope +} + +func (r *authorizedIntegrationAuthenticationResult) User() *user_model.User { + return r.user +} + +func (r *authorizedIntegrationAuthenticationResult) Scope() optional.Option[auth_model.AccessTokenScope] { + return optional.Some(r.scope) +} diff --git a/services/auth/method/authorized_integration.go b/services/auth/method/authorized_integration.go new file mode 100644 index 0000000000..2f29d406e3 --- /dev/null +++ b/services/auth/method/authorized_integration.go @@ -0,0 +1,348 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "sync" + "time" + + auth_model "forgejo.org/models/auth" + user_model "forgejo.org/models/user" + "forgejo.org/modules/hostmatcher" + "forgejo.org/modules/json" + "forgejo.org/modules/jwtx" + "forgejo.org/modules/log" + "forgejo.org/modules/proxy" + "forgejo.org/modules/setting" + "forgejo.org/modules/util" + "forgejo.org/services/auth" + + "github.com/gobwas/glob" + "github.com/golang-jwt/jwt/v5" +) + +var ( + _ auth.Method = &AuthorizedIntegration{} + + aiHTTPClient *http.Client + initHTTPClient sync.Once + + errParseInternalServer = errors.New("internal server error") +) + +// 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 +// the database record. +// +// Authorization is received from HTTP requests as a `Authorization: Bearer [...jwt...]` (or `Authorization: Token +// [...jwt...]`). +type AuthorizedIntegration struct { + // Permit the use of `Authorization: Basic ...`, in addition to the typical bearer/token authorization header. If + // true, the basic password will be interpreted as a JWT token if present and valid. The username is ignored. + PermitBasic bool + + // For testing -- interpret JWTs with now as a fixed time. + fixedTime *time.Time +} + +func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput { + hasToken, token := tokenFromAuthorizationBearer(req).Get() + if !hasToken { + if !a.PermitBasic { + return &auth.AuthenticationNotAttempted{} + } + hasBasic, basicToken := tokenFromAuthorizationBasic(req).Get() + if !hasBasic { + return &auth.AuthenticationNotAttempted{} + } + token = basicToken + } + + var authorizedIntegration *auth_model.AuthorizedIntegration + + parsedToken, err := jwt.ParseWithClaims(token, &flexibleClaims{}, + func(t *jwt.Token) (any, error) { + keyID, ok := t.Header["kid"] + if !ok { + return nil, errors.New("failed finding key identifer (kid) in JWT headers") + } + + issuer, err := t.Claims.GetIssuer() + if err != nil { + return nil, fmt.Errorf("failed getting `iss` claim: %w", err) + } else if len(issuer) == 0 { + return nil, fmt.Errorf("invalid `iss` claim: %q", issuer) + } + audienceArray, err := t.Claims.GetAudience() + if err != nil { + return nil, fmt.Errorf("failed getting `aud` claim: %w", err) + } else if len(audienceArray) != 1 { + return nil, fmt.Errorf("required one and only one `aud` claim, but received %d", len(audienceArray)) + } + audience := audienceArray[0] + if len(audience) == 0 { + return nil, fmt.Errorf("invalid `aud` claim: %q", audience) + } + + authorizedIntegration, err = auth_model.GetAuthorizedIntegration(req.Context(), issuer, audience) + if errors.Is(err, util.ErrNotExist) { + return nil, errors.New("matching authorized_integration not found") + } else if err != nil { + return nil, fmt.Errorf("failure reading authorized_integration: %w (%w)", err, errParseInternalServer) + } + + // Do the claim check before accessing the issuer's OIDC metadata and JWKS, to reduce risk of resource + // utilization attack through invalid JWTs causing remote requests. + err = a.checkClaims(t.Claims.(*flexibleClaims), authorizedIntegration.ClaimRules) + if err != nil { + return nil, fmt.Errorf("claim mismatch: %w", err) + } + + issuerURL, err := url.Parse(issuer) + if err != nil { + return nil, fmt.Errorf("failed parsing issuer: %w", err) + } + + issuerOIDCURL := issuerURL.JoinPath(".well-known/openid-configuration") + var oidcConfig openIDConfiguration + // TODO: cache external OIDC configuration, with a fixed timeout (not LRU/MRU) + if err := a.fetchJSON(issuerOIDCURL.String(), &oidcConfig); err != nil { + return nil, fmt.Errorf("error when fetching .well-known/openid-configuration from %s: %w", issuerOIDCURL, err) + } + + if oidcConfig.Issuer != issuer { + return nil, fmt.Errorf("issuer mismatch; expected %q, received %q from %s", issuer, oidcConfig.Issuer, issuerOIDCURL) + } + if !slices.Contains(oidcConfig.IDTokenSigningAlgValuesSupported, t.Method.Alg()) { + return nil, fmt.Errorf("issuer supports signature algorithms %#v, but received token with algorithm %s", oidcConfig.IDTokenSigningAlgValuesSupported, t.Method.Alg()) + } + + jwksURI, err := url.Parse(oidcConfig.JwksURI) + if err != nil { + return nil, fmt.Errorf("failed parsing jwks_uri: %w", err) + } else if jwksURI.Host != issuerURL.Host { + // Prevent SSRF which could occur if a malicious openid-connection response returned a jwks_uri field + // that causes Forgejo to access other hostnames. This could be considered a valid case as well and we + // can rely on the config-based allowed and blocked domains for the [authorized_integration] section, + // 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 + // TODO: cache JWKS, with a fixed timeout (not LRU/MRU) + if err := a.fetchJSON(oidcConfig.JwksURI, &keys); err != nil { + return nil, fmt.Errorf("error when fetching JWKS from %s: %w", oidcConfig.JwksURI, err) + } + + for _, key := range keys.Keys { + if key["kid"] == keyID { + alg, algPresent := key["alg"] // "alg" is an optional field + if algPresent && alg != t.Method.Alg() { + return nil, fmt.Errorf("kid %q doesn't match expected algorithm %s, was %v", keyID, t.Method.Alg(), key["alg"]) + } + + use, usePresent := key["use"] // "use" is also an optional field + if usePresent && use != "sig" { + return nil, fmt.Errorf("kid %q isn't designated for signing usage, was %s", keyID, key["use"]) + } + + pub, err := jwtx.ParseJWKToPublicKey(key) + if err != nil { + return nil, fmt.Errorf("failed to parse JWKS: %w", err) + } + return pub, nil + } + } + + return nil, errors.New("no key identified") + }, + jwt.WithValidMethods(jwtx.ValidAsymmetricAlgorithms), // only asymetric algorithms, as JWKS must have a public key only + jwt.WithIssuedAt(), + jwt.WithTimeFunc(func() time.Time { + if a.fixedTime != nil { + return *a.fixedTime + } + return time.Now() + }), + ) + if err != nil && errors.Is(err, errParseInternalServer) { + // Errors from parsing marked errParseInternalServer are AuthenticationError, not incorrect creds: + return &auth.AuthenticationError{Error: err} + } else if err != nil { + return &auth.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")} + } else if authorizedIntegration == nil { // shouldn't be possible, but overly safe + return &auth.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)} + } + + if err = authorizedIntegration.UpdateLastUsed(req.Context()); err != nil { + log.Error("UpdateLastUsed: %v", err) + } + + return &auth.AuthenticationSuccess{ + Result: &authorizedIntegrationAuthenticationResult{ + user: u, + scope: authorizedIntegration.Scope, + // TODO: add repo-specific access with an authz reducer + }, + } +} + +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 (a *AuthorizedIntegration) fetchJSON(urlString string, v any) 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()) + } + + initHTTPClient.Do(initAuthorizedIntegrationHTTPClient) + + resp, err := aiHTTPClient.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) + } + + body := io.LimitReader(resp.Body, authorizedIntegrationRequestBodyLimit) + decoder := json.NewDecoder(body) + err = decoder.Decode(&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) + } + 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 { + if stored == nil { + return nil + } + + for _, rule := range stored.Rules { + var lhs any + + if lhsClaim, isFlex := incomingClaims.(*flexibleClaims); isFlex { + switch rule.Claim { + case "iss": + lhs = lhsClaim.Issuer + case "sub": + lhs = lhsClaim.Subject + case "jti": + lhs = lhsClaim.ID + case "aud": + audienceArray, err := lhsClaim.GetAudience() + if err != nil { + return fmt.Errorf("failed getting `aud` claim: %w", err) + } else if len(audienceArray) != 1 { + return fmt.Errorf("required one and only one `aud` claim, but received %d", len(audienceArray)) + } + lhs = audienceArray[0] + default: + v, present := lhsClaim.other[rule.Claim] + if !present { + return fmt.Errorf("claim rule on %q couldn't be satisfied: claim not found", rule.Claim) + } + lhs = v + } + } else if lhsMap, isMap := incomingClaims.(map[string]any); isMap { + v, present := lhsMap[rule.Claim] + if !present { + return fmt.Errorf("claim rule on %q couldn't be satisfied: claim not found", rule.Claim) + } + lhs = v + } else { + return fmt.Errorf("unexpected incoming claims type: %T", incomingClaims) + } + + switch rule.Comparison { + case auth_model.ClaimEqual: + lhsStr, ok := lhs.(string) + if !ok { + return fmt.Errorf("claim %q must be a string, but was %T", rule.Claim, lhs) + } else if lhsStr != rule.Value { + return fmt.Errorf("claim %q must be %q, but was %q", rule.Claim, rule.Value, lhsStr) + } + case auth_model.ClaimGlob: + lhsStr, ok := lhs.(string) + if !ok { + return fmt.Errorf("claim %q must be a string, but was %T", rule.Claim, lhs) + } + r, err := glob.Compile(rule.Value) + if err != nil { + return fmt.Errorf("unable to parse glob for claim rule on %q; glob = %q, err = %w", rule.Claim, rule.Value, err) + } + if !r.Match(lhsStr) { + return fmt.Errorf("claim %q must match glob %q, but value %q did not match", rule.Claim, rule.Value, lhsStr) + } + case auth_model.ClaimNested: + lhsMap, ok := lhs.(map[string]any) + if !ok { + return fmt.Errorf("claim %q must be a map, but was %T", rule.Claim, lhs) + } else if err := a.checkClaims(lhsMap, rule.Nested); err != nil { + return fmt.Errorf("in nested claim %q: %w", rule.Claim, err) + } + } + } + + return nil +} diff --git a/services/auth/method/authorized_integration_claims.go b/services/auth/method/authorized_integration_claims.go new file mode 100644 index 0000000000..1518683ca8 --- /dev/null +++ b/services/auth/method/authorized_integration_claims.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +import ( + "fmt" + "maps" + + "forgejo.org/modules/json" + + "github.com/golang-jwt/jwt/v5" +) + +// Structure for inspecting the standard claims of a JWT (jwt.RegisteredClaims), which also stores any provided +// service-defined claims in an unstructured map[string]any. +type flexibleClaims struct { + jwt.RegisteredClaims + other map[string]any +} + +// Populate a [flexibleClaims] from JSON data, implementing [json.Unmarshaler]. +func (a *flexibleClaims) UnmarshalJSON(b []byte) error { + var s map[string]any + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + var rc jwt.RegisteredClaims + other := map[string]any{} + for k, v := range s { + switch k { + case "iss": + str, ok := v.(string) + if !ok { + return fmt.Errorf("expected `iss` to be string, but was %v", v) + } + rc.Issuer = str + case "sub": + str, ok := v.(string) + if !ok { + return fmt.Errorf("expected `sub` to be string, but was %v", v) + } + rc.Subject = str + case "aud": + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("uanble to return `aud` to []byte: %w", err) + } + if err := json.Unmarshal(b, &rc.Audience); err != nil { + return fmt.Errorf("uanble to decode `aud: %w", err) + } + case "exp": + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("uanble to return `exp` to []byte: %w", err) + } + if err := json.Unmarshal(b, &rc.ExpiresAt); err != nil { + return fmt.Errorf("uanble to decode `exp: %w", err) + } + case "nbf": + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("uanble to return `nbf` to []byte: %w", err) + } + if err := json.Unmarshal(b, &rc.NotBefore); err != nil { + return fmt.Errorf("uanble to decode `nbf: %w", err) + } + case "iat": + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("uanble to return `iat` to []byte: %w", err) + } + if err := json.Unmarshal(b, &rc.IssuedAt); err != nil { + return fmt.Errorf("uanble to decode `iat: %w", err) + } + case "jti": + str, ok := v.(string) + if !ok { + return fmt.Errorf("expected `jti` to be string, but was %v", v) + } + rc.ID = str + default: + other[k] = v + } + } + + a.RegisteredClaims = rc + a.other = other + + return nil +} + +// Marshal flexibleClaims to JSON, merging both the registered claims and the additional claims into a map. +func (a flexibleClaims) MarshalJSON() ([]byte, error) { + rcJSON, err := json.Marshal(a.RegisteredClaims) + if err != nil { + return nil, err + } + + var fullMap map[string]any + err = json.Unmarshal(rcJSON, &fullMap) + if err != nil { + return nil, err + } + maps.Copy(fullMap, a.other) + + return json.Marshal(fullMap) +} diff --git a/services/auth/method/authorized_integration_claims_test.go b/services/auth/method/authorized_integration_claims_test.go new file mode 100644 index 0000000000..399bb6138e --- /dev/null +++ b/services/auth/method/authorized_integration_claims_test.go @@ -0,0 +1,149 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +import ( + "testing" + "time" + + "forgejo.org/modules/json" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Real-world Forgejo Actions claims, Forgejo v15 +const forgejoClaims = ` +{ + "actor": "coolguy", + "aud": "https://example.org/-/coolguy/authorized-integration/346e1496", + "base_ref": "main", + "event_name": "pull_request", + "exp": 1776979110, + "head_ref": "forgejo-oidc-test", + "iat": 1776975510, + "iss": "https://example.org/api/actions", + "nbf": 1776975510, + "ref": "refs/pull/113/head", + "ref_protected": "false", + "ref_type": "", + "repository": "coolguy/test", + "repository_owner": "coolguy", + "run_attempt": "4", + "run_id": "3572", + "run_number": "2054", + "sha": "d4083cc0f4e7452dc00f7a5f73ec5486a549adb9", + "sub": "repo:coolguy/test:pull_request", + "workflow": "main.yml", + "workflow_ref": "coolguy/test/.forgejo/workflows/main.yml@refs/pull/113/head" +} +` + +// Real-world GitHub Actions claims +const githubClaims = ` +{ + "actor": "coolguy", + "actor_id": "91093", + "aud": "https://example.org/-/coolguy/authorized-integration/6cc55ba0", + "base_ref": "main", + "check_run_id": "72783197645", + "event_name": "pull_request", + "exp": 1776980496, + "head_ref": "github-oidc-test", + "iat": 1776980196, + "iss": "https://token.actions.githubusercontent.com", + "job_workflow_ref": "coolguy/forgejo-runner-testrepo/.github/workflows/main.yml@refs/pull/3/merge", + "job_workflow_sha": "62a34e2bf42fda53a0209bfd485dcab3013b1160", + "jti": "83545042-379e-4328-8e60-3b6d46594a5f", + "nbf": 1776979896, + "ref": "refs/pull/3/merge", + "ref_protected": "false", + "ref_type": "branch", + "repository": "coolguy/forgejo-runner-testrepo", + "repository_id": "1113890566", + "repository_owner": "coolguy", + "repository_owner_id": "91093", + "repository_visibility": "private", + "run_attempt": "8", + "run_id": "24846522812", + "run_number": "10", + "runner_environment": "github-hosted", + "sha": "62a34e2bf42fda53a0209bfd485dcab3013b1160", + "sub": "repo:coolguy/forgejo-runner-testrepo:pull_request", + "workflow": ".github/workflows/main.yml", + "workflow_ref": "coolguy/forgejo-runner-testrepo/.github/workflows/main.yml@refs/pull/3/merge", + "workflow_sha": "62a34e2bf42fda53a0209bfd485dcab3013b1160" +} +` + +// Real-world AWS Federated Web Identity claims +const awsClaims = ` +{ + "aud": "https://example.org/-/coolguy/authorized-integration/7895835c", + "sub": "arn:aws:iam::1234567890:role/service-role/forgejo-oidc-accepting-test-role-x7t3fgko", + "https://sts.amazonaws.com/": { + "aws_account": "1234567890", + "original_session_exp": "2026-04-24T09:34:34Z", + "source_region": "us-west-2", + "principal_id": "arn:aws:iam::1234567890:role/service-role/forgejo-oidc-accepting-test-role-x7t3fgko", + "lambda_source_function_arn": "arn:aws:lambda:us-west-2:1234567890:function:forgejo-oidc-accepting-test" + }, + "iss": "https://a103a2cc-b461-473d-84fe-6c4f6d45af88.tokens.sts.global.api.aws", + "exp": 1776980375, + "iat": 1776980075, + "jti": "0afcbeb7-512d-479f-b596-703c03ae65a5" +} +` + +func TestFlexibleClaimsUnmarshal(t *testing.T) { + t.Run("Forgejo", func(t *testing.T) { + var retval flexibleClaims + data := []byte(forgejoClaims) + require.NoError(t, json.Unmarshal(data, &retval)) + // assert the claims that are handled specially in flexibleClaims UnmarshalJSON + assert.Equal(t, "https://example.org/api/actions", retval.Issuer) + assert.Equal(t, "repo:coolguy/test:pull_request", retval.Subject) + assert.Equal(t, jwt.ClaimStrings{"https://example.org/-/coolguy/authorized-integration/346e1496"}, retval.Audience) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 21, 18, 30, 0, time.Local)}, retval.ExpiresAt) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 20, 18, 30, 0, time.Local)}, retval.NotBefore) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 20, 18, 30, 0, time.Local)}, retval.IssuedAt) + assert.Empty(t, retval.ID) + // short check that the 'other' claims were stored as well + assert.Equal(t, "d4083cc0f4e7452dc00f7a5f73ec5486a549adb9", retval.other["sha"]) + assert.Len(t, retval.other, 15) + }) + t.Run("GitHub", func(t *testing.T) { + var retval flexibleClaims + data := []byte(githubClaims) + require.NoError(t, json.Unmarshal(data, &retval)) + // assert the claims that are handled specially in flexibleClaims UnmarshalJSON + assert.Equal(t, "https://token.actions.githubusercontent.com", retval.Issuer) + assert.Equal(t, "repo:coolguy/forgejo-runner-testrepo:pull_request", retval.Subject) + assert.Equal(t, jwt.ClaimStrings{"https://example.org/-/coolguy/authorized-integration/6cc55ba0"}, retval.Audience) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 21, 41, 36, 0, time.Local)}, retval.ExpiresAt) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 21, 31, 36, 0, time.Local)}, retval.NotBefore) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 21, 36, 36, 0, time.Local)}, retval.IssuedAt) + assert.Equal(t, "83545042-379e-4328-8e60-3b6d46594a5f", retval.ID) + // short check that the 'other' claims were stored as well + assert.Equal(t, "62a34e2bf42fda53a0209bfd485dcab3013b1160", retval.other["sha"]) + assert.Len(t, retval.other, 24) + }) + t.Run("AWS", func(t *testing.T) { + var retval flexibleClaims + data := []byte(awsClaims) + require.NoError(t, json.Unmarshal(data, &retval)) + // assert the claims that are handled specially in flexibleClaims UnmarshalJSON + assert.Equal(t, "https://a103a2cc-b461-473d-84fe-6c4f6d45af88.tokens.sts.global.api.aws", retval.Issuer) + assert.Equal(t, "arn:aws:iam::1234567890:role/service-role/forgejo-oidc-accepting-test-role-x7t3fgko", retval.Subject) + assert.Equal(t, jwt.ClaimStrings{"https://example.org/-/coolguy/authorized-integration/7895835c"}, retval.Audience) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 21, 39, 35, 0, time.Local)}, retval.ExpiresAt) + assert.Nil(t, retval.NotBefore) + assert.Equal(t, &jwt.NumericDate{Time: time.Date(2026, time.April, 23, 21, 34, 35, 0, time.Local)}, retval.IssuedAt) + assert.Equal(t, "0afcbeb7-512d-479f-b596-703c03ae65a5", retval.ID) + // short check that the 'other' claims were stored as well + assert.Equal(t, "1234567890", retval.other["https://sts.amazonaws.com/"].(map[string]any)["aws_account"]) + assert.Len(t, retval.other, 1) + }) +} diff --git a/services/auth/method/authorized_integration_oidc.go b/services/auth/method/authorized_integration_oidc.go new file mode 100644 index 0000000000..2e35b63ace --- /dev/null +++ b/services/auth/method/authorized_integration_oidc.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +// 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 { + 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 { + // 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. + Keys []map[string]any `json:"keys"` +} diff --git a/services/auth/method/authorized_integration_oidc_test.go b/services/auth/method/authorized_integration_oidc_test.go new file mode 100644 index 0000000000..b394ab0c01 --- /dev/null +++ b/services/auth/method/authorized_integration_oidc_test.go @@ -0,0 +1,273 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +import ( + "testing" + + "forgejo.org/modules/json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Real-world Forgejo Actions .well-known/openid-configuration from Forgejo v15 +const forgejoOIDC = ` +{ + "issuer": "https://example.org/api/actions", + "jwks_uri": "https://example.org/api/actions/.well-known/keys", + "subject_types_supported": [ + "public" + ], + "response_types_supported": [ + "id_token" + ], + "claims_supported": [ + "sub", + "aud", + "exp", + "iat", + "iss", + "nbf", + "actor", + "base_ref", + "event_name", + "head_ref", + "ref", + "ref_protected", + "ref_type", + "repository", + "repository_owner", + "run_attempt", + "run_id", + "run_number", + "sha", + "workflow", + "workflow_ref" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid" + ] +} +` + +// Real-world Forgejo Actions JWKS from Forgejo v15 +const forgejoJWKS = ` +{ + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "kid": "SNNttXGzw6l53JC158lXddjSjQ5bJ9bdTTqTi12gaLY", + "kty": "RSA", + "n": "7RL963BVzemasfImhlR3KUX97YdA7g3SBnq_ZLzcdxLXPGDhsnSoxMX7gY30b1qpQlML8yiAyz_gxUydiVlqpEEPypR9lfKtZXv4JTM-X2rccegcreUyfFJnFuzVUoY7SVEzAulLUwqP8MH8kxDI7JZRQ8_JIjm9IxEuWCSc3XnVxNCTS2XEdHsug_Kt6SQdcH8xL9U2w0EHAUna9KkLAl6_PzBg1JxIQDHQtfp_CN7YNyoyilH88XAGEeQm0fLz6GH7hhyw6y1b9NprYIxNrdD4Pb1b66j4K--bCJy530UmEAlfLbCiDCh4k78TPUnU_YwwT4ujC0t28zoHNB3Y0w", + "use": "sig" + } + ] +} +` + +// Real-world GitHub Actions /.well-known/openid-configuration +const githubOIDC = ` +{ + "issuer": "https://token.actions.githubusercontent.com", + "jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks", + "subject_types_supported": [ + "public", + "pairwise" + ], + "response_types_supported": [ + "id_token" + ], + "claims_supported": [ + "sub", + "aud", + "exp", + "iat", + "iss", + "jti", + "nbf", + "ref", + "sha", + "repository", + "repository_id", + "repository_owner", + "repository_owner_id", + "enterprise", + "enterprise_id", + "run_id", + "run_number", + "run_attempt", + "actor", + "actor_id", + "workflow", + "workflow_ref", + "workflow_sha", + "head_ref", + "base_ref", + "event_name", + "ref_type", + "ref_protected", + "environment", + "environment_node_id", + "job_workflow_ref", + "job_workflow_sha", + "repository_visibility", + "runner_environment", + "issuer_scope", + "check_run_id" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid" + ] +} +` + +// Real-world GitHub Actions JWKS +const githubJWKS = ` +{ + "keys": [ + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "cc413527-173f-5a05-976e-9c52b1d7b431", + "n": "w4M936N3ZxNaEblcUoBm-xu0-V9JxNx5S7TmF0M3SBK-2bmDyAeDdeIOTcIVZHG-ZX9N9W0u1yWafgWewHrsz66BkxXq3bscvQUTAw7W3s6TEeYY7o9shPkFfOiU3x_KYgOo06SpiFdymwJflRs9cnbaU88i5fZJmUepUHVllP2tpPWTi-7UA3AdP3cdcCs5bnFfTRKzH2W0xqKsY_jIG95aQJRBDpbiesefjuyxcQnOv88j9tCKWzHpJzRKYjAUM6OPgN4HYnaSWrPJj1v41eEkFM1kORuj-GSH2qMVD02VklcqaerhQHIqM-RjeHsN7G05YtwYzomE5G-fZuwgvQ", + "e": "AQAB" + }, + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "38826b17-6a30-5f9b-b169-8beb8202f723", + "n": "5Manmy-zwsk3wEftXNdKFZec4rSWENW4jTGevlvAcU9z3bgLBogQVvqYLtu9baVm2B3rfe5onadobq8po5UakJ0YsTiiEfXWdST7YI2Sdkvv-hOYMcZKYZ4dFvuSO1vQ2DgEkw_OZNiYI1S518MWEcNxnPU5u67zkawAGsLlmXNbOylgVfBRJrG8gj6scr-sBs4LaCa3kg5IuaCHe1pB-nSYHovGV_z0egE83C098FfwO1dNZBWeo4Obhb5Z-ZYFLJcZfngMY0zJnCVNmpHQWOgxfGikh3cwi4MYrFrbB4NTlxbrQ3bL-rGKR5X318veyDlo8Dyz2KWMobT4wB9U1Q", + "e": "AQAB", + "x5c": [ + "MIIDKzCCAhOgAwIBAgIUDnwm6eRIqGFA3o/P1oBrChvx/nowDQYJKoZIhvcNAQELBQAwJTEjMCEGA1UEAwwaYWN0aW9ucy5zZWxmLXNpZ25lZC5naXRodWIwHhcNMjQwMTIzMTUyNTM2WhcNMzQwMTIwMTUyNTM2WjAlMSMwIQYDVQQDDBphY3Rpb25zLnNlbGYtc2lnbmVkLmdpdGh1YjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOTGp5svs8LJN8BH7VzXShWXnOK0lhDVuI0xnr5bwHFPc924CwaIEFb6mC7bvW2lZtgd633uaJ2naG6vKaOVGpCdGLE4ohH11nUk+2CNknZL7/oTmDHGSmGeHRb7kjtb0Ng4BJMPzmTYmCNUudfDFhHDcZz1Obuu85GsABrC5ZlzWzspYFXwUSaxvII+rHK/rAbOC2gmt5IOSLmgh3taQfp0mB6Lxlf89HoBPNwtPfBX8DtXTWQVnqODm4W+WfmWBSyXGX54DGNMyZwlTZqR0FjoMXxopId3MIuDGKxa2weDU5cW60N2y/qxikeV99fL3sg5aPA8s9iljKG0+MAfVNUCAwEAAaNTMFEwHQYDVR0OBBYEFIPALo5VanJ6E1B9eLQgGO+uGV65MB8GA1UdIwQYMBaAFIPALo5VanJ6E1B9eLQgGO+uGV65MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGS0hZE+DqKIRi49Z2KDOMOaSZnAYgqq6ws9HJHT09MXWlMHB8E/apvy2ZuFrcSu14ZLweJid+PrrooXEXEO6azEakzCjeUb9G1QwlzP4CkTcMGCw1Snh3jWZIuKaw21f7mp2rQ+YNltgHVDKY2s8AD273E8musEsWxJl80/MNvMie8Hfh4n4/Xl2r6t1YPmUJMoXAXdTBb0hkPy1fUu3r2T+1oi7Rw6kuVDfAZjaHupNHzJeDOg2KxUoK/GF2/M2qpVrd19Pv/JXNkQXRE4DFbErMmA7tXpp1tkXJRPhFui/Pv5H9cPgObEf9x6W4KnCXzT3ReeeRDKF8SqGTPELsc=" + ], + "x5t": "ykNaY4qM_ta4k2TgZOCEYLkcYlA" + }, + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "38E9B30B3A023A1B72309921A69A42FCC496C42C", + "n": "tEq2Fp9HcdT5MwMsB_UTm8j_woJJLi3sA-y0RX2tioTm581seyfvOH6lJ5JmHVtS-_fb8B2tRT1pznHQSNq14PsJdu9bp5egbWmIz-5RvhqoM-oKem_MJENCNFuqXijRLT47FRdfH3inqde1vJlA_JJHCqYMKIpHH7kqNFYcCpwr0vk80Hc2rTyL0uBXI7NqBZbtUgNoyucWO5O7QQrPNOmlr-GI8aFckFRfobCaCOiH9qW02FtkV74fwBGVCNhNf3a1CK81-O8xEGimvVydI_pQA5B8QqVuQjY_ntOu555HdirA0hKkY6fsE9eZCMFmWDHZ2kSWLjhabxWxIzSzXQ", + "e": "AQAB", + "x5c": [ + "MIIDrDCCApSgAwIBAgIQbuIOJTcGQ4GOQs29F1/uLzANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTI1MDkxMTE5MzcxOFoXDTI3MDkxMTE5NDcxOFowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALRKthafR3HU+TMDLAf1E5vI/8KCSS4t7APstEV9rYqE5ufNbHsn7zh+pSeSZh1bUvv32/AdrUU9ac5x0EjateD7CXbvW6eXoG1piM/uUb4aqDPqCnpvzCRDQjRbql4o0S0+OxUXXx94p6nXtbyZQPySRwqmDCiKRx+5KjRWHAqcK9L5PNB3Nq08i9LgVyOzagWW7VIDaMrnFjuTu0EKzzTppa/hiPGhXJBUX6Gwmgjoh/altNhbZFe+H8ARlQjYTX92tQivNfjvMRBopr1cnSP6UAOQfEKlbkI2P57TrueeR3YqwNISpGOn7BPXmQjBZlgx2dpEli44Wm8VsSM0s10CAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBQLxdObdnfWzaBcxau87tdSUtEuQjAdBgNVHQ4EFgQUC8XTm3Z31s2gXMWrvO7XUlLRLkIwDQYJKoZIhvcNAQELBQADggEBAD2Eo703wXQgB2vJn/RwTTcHGkeMkYXm0mWCxOSh4iCKVvqypBJrmLzRkMMJN0/10qIGciYWUl6EkL7yj48tpXXH01Ep0ONDdo9UYmKGp81Z4j3u3FBJTVQSdj2tnPOPZlYWaBkerIkcIeyWBRKvne1UBaobbk84epfBmUfAMFmyJEk+x+q7cqmsbjDtdrmhiWaInqCijpS2dW2MitJ5F7tBBS26SMTqLQteA2IOwIW1BMlYIPuSO3dKn/rYVS8RjL+x+MxP98vla5sichoEZwVWnXiXgFZ4n/asGqc+Da9q6ILLtInvgI5bi7kjJJ2ARTRC5/a+J/v3EL+t8SdnOO8=" + ], + "x5t": "OOmzCzoCOhtyMJkhpppC_MSWxCw" + }, + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "4F3E9AD8C9A6F5EB3173006F4FA630E28F43DCE9", + "n": "tGevqhkBGn8NB0dKxs8Ddxhn-xZPm55svcSlkJZEOwDOXDLl_0-iVOVKNJfcHHLHvMqa6zh2DDcpAWZi2FpeBAJupsrymqwzllxOODWKWoVIoaIjOO7h1JLiF9Knwuq-o6BPtKdwOT-bOrXRzChMtQsc5C1Auex-D0Z6loObBuK1Lkm0RK9ISQsLqBEwq8g0OOupI_shU1r2rT2G0nkZ0CvxVlQeUGShFi8Mdys2s5LPqBwjC4LKwjk8moWQV32KEccbTPKxnG_539DxRglHJgHPHisSVGsfZIUXi2chtXdQHZPdVve8ZRmknCykZtkJ6K87llSUXi7oyzhCIZdiUQ", + "e": "AQAB", + "x5c": [ + "MIIDrDCCApSgAwIBAgIQPQS35v3ITW6fNLO8GX5QBjANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTI1MDgwNjE0MTEzMloXDTI3MDgwNjE0MjEzMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALRnr6oZARp/DQdHSsbPA3cYZ/sWT5uebL3EpZCWRDsAzlwy5f9PolTlSjSX3Bxyx7zKmus4dgw3KQFmYthaXgQCbqbK8pqsM5ZcTjg1ilqFSKGiIzju4dSS4hfSp8LqvqOgT7SncDk/mzq10cwoTLULHOQtQLnsfg9GepaDmwbitS5JtESvSEkLC6gRMKvINDjrqSP7IVNa9q09htJ5GdAr8VZUHlBkoRYvDHcrNrOSz6gcIwuCysI5PJqFkFd9ihHHG0zysZxv+d/Q8UYJRyYBzx4rElRrH2SFF4tnIbV3UB2T3Vb3vGUZpJwspGbZCeivO5ZUlF4u6Ms4QiGXYlECAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBRKoYOga736JYE15vT7b4gWjC1hwTAdBgNVHQ4EFgQUSqGDoGu9+iWBNeb0+2+IFowtYcEwDQYJKoZIhvcNAQELBQADggEBAJVZIPtoZUlvqgu+Pl0nj8WopA8iuy1m7JRg5fg+bOIGFhXFR8+mH8prpeodjUQ40q2Hq6IwnVir+G56zVwAPf2HHksqdp8be9qjkTjD0mJorPCt/lumrKoNGOVmYffYuIyr73hwsl8fN6sGjAyXLFBkozE4s5ssbeodFxiYE1A61SXnzldC00M7qWleMWjTUBixiZ+R/eroddkLNBGDv9ewDrTQv1ipNec89+Wi7Wb6SAXNxBADiC5kVlFylBgHo3oZNg3KFzZS01REyc4zdH7v1wfZzilLluI6ygTyYRYpJCsKrX5D9JW196f2PCzcs+VXMfneRDnvyfjep7Y1Pi8=" + ], + "x5t": "Tz6a2Mmm9esxcwBvT6Yw4o9D3Ok" + } + ] +} +` + +// Real-world .well-known/openid-configuration extracted from a AWS Federated Web Identity endpoint. +const awsOIDC = ` +{ + "claims_supported": [ + "sub", + "iss", + "aud", + "exp", + "iat", + "jti", + "https://sts.amazonaws.com/" + ], + "id_token_signing_alg_values_supported": [ + "RS256", + "ES384" + ], + "issuer": "https://a103a2cc-b461-473d-84fe-6c4f6d45af88.tokens.sts.global.api.aws", + "jwks_uri": "https://a103a2cc-b461-473d-84fe-6c4f6d45af88.tokens.sts.global.api.aws/.well-known/jwks.json", + "subject_types_supported": [ + "public" + ] +} +` + +// Real-world JWKS extracted from a AWS Federated Web Identity endpoint. +const awsJWKS = ` +{ + "keys": [ + { + "e": "AQAB", + "kid": "RSA_0", + "kty": "RSA", + "n": "3AvB0UECoYssZEgSMTa4SYvfqstJxkhbBBSKAFRUW6f_McJ9CAXkTi6YkG0NGm77ZIRW12_gOLKZJUHWp9CMAbmk0O4sMIx8K6Ap7-6qjkt7FYvl4mkQVJd-pU-yE3SJn0S5xEbCYXulgrrGN8POysTblqN0BfrdDAYTVhWQ47rbm--3QrRcVN9XCjlMBVXYauaN6KlszKL6NTe7GWilauYBsVHw7d4ekliuEGGA6zJNGz595KD7yofRc1euFs86KgiFj0mpudCqG39jIlBJ4vZSJPw1Rsvhg8THqlxhmurVYr9TuckLJa5fpEL78xGs3Ar4GIM6w0sxLDbdY-KdCQ", + "use": "sig" + }, + { + "alg": "ES384", + "crv": "P-384", + "kid": "EC384_0", + "kty": "EC", + "use": "sig", + "x": "ad_olFw0n3XBA114sefjlirPf2gX6bKqT-kD2lQzfQzkWW1TetKIUWah3md-UgV9", + "y": "w6GzW2Oen4G7Ei1bFaDkBpPSulvkSznb6YtG79NWK9UjgDqfN6am9lUs-bF8VN7v" + } + ] +} +` + +func TestParseOpenIDConfiguration(t *testing.T) { + t.Run("Forgejo", func(t *testing.T) { + var retval openIDConfiguration + 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 + 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 + 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) + }) +} + +func TestParseJSONWebKeySet(t *testing.T) { + t.Run("Forgejo", func(t *testing.T) { + var retval openIDKeys + 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 + 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 + data := []byte(awsJWKS) + require.NoError(t, json.Unmarshal(data, &retval)) + assert.Len(t, retval.Keys, 2) + }) +} diff --git a/services/auth/method/authorized_integration_test.go b/services/auth/method/authorized_integration_test.go new file mode 100644 index 0000000000..46048573ad --- /dev/null +++ b/services/auth/method/authorized_integration_test.go @@ -0,0 +1,723 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package method + +import ( + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + auth_model "forgejo.org/models/auth" + "forgejo.org/models/db" + "forgejo.org/modules/json" + "forgejo.org/modules/jwtx" + "forgejo.org/modules/test" + "forgejo.org/services/auth" + + "github.com/golang-jwt/jwt/v5" + gouuid "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckClaims(t *testing.T) { + ai := &AuthorizedIntegration{} + rules := func(rule ...auth_model.ClaimRule) *auth_model.ClaimRules { + return &auth_model.ClaimRules{Rules: rule} + } + eq := func(claim, value string) auth_model.ClaimRule { + return auth_model.ClaimRule{ + Claim: claim, + Comparison: auth_model.ClaimEqual, + Value: value, + } + } + glob := func(claim, value string) auth_model.ClaimRule { + return auth_model.ClaimRule{ + Claim: claim, + Comparison: auth_model.ClaimGlob, + Value: value, + } + } + nest := func(claim string, inner ...auth_model.ClaimRule) auth_model.ClaimRule { + return auth_model.ClaimRule{ + Claim: claim, + Comparison: auth_model.ClaimNested, + Nested: rules(inner...), + } + } + + t.Run("nil claims", func(t *testing.T) { + require.NoError(t, ai.checkClaims(map[string]any{}, nil)) + }) + + t.Run("flexibleClaims's fixed and other fields", func(t *testing.T) { + t.Run("iss", func(t *testing.T) { + c := &flexibleClaims{} + rules := rules(eq("iss", "https://example.org")) + + c.Issuer = "https://example.org" + require.NoError(t, ai.checkClaims(c, rules)) + + c.Issuer = "https://other.example.org" + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"iss\" must be \"https://example.org\", but was \"https://other.example.org\"") + }) + + t.Run("sub", func(t *testing.T) { + c := &flexibleClaims{} + rules := rules(eq("sub", "my-stuff")) + + c.Subject = "my-stuff" + require.NoError(t, ai.checkClaims(c, rules)) + + c.Subject = "my-other-stuff" + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"sub\" must be \"my-stuff\", but was \"my-other-stuff\"") + }) + + t.Run("jti", func(t *testing.T) { + c := &flexibleClaims{} + rules := rules(eq("jti", "7d9a2e85-6b8d-4b59-bca0-09d702476338")) + + c.ID = "7d9a2e85-6b8d-4b59-bca0-09d702476338" + require.NoError(t, ai.checkClaims(c, rules)) + + c.ID = "8855d16c-cd5f-4ace-b626-5c5875e1a993" + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"jti\" must be \"7d9a2e85-6b8d-4b59-bca0-09d702476338\", but was \"8855d16c-cd5f-4ace-b626-5c5875e1a993\"") + }) + + t.Run("aud", func(t *testing.T) { + c := &flexibleClaims{} + rules := rules(eq("aud", "the-best-audience")) + + c.Audience = jwt.ClaimStrings{"the-best-audience"} + require.NoError(t, ai.checkClaims(c, rules)) + + c.Audience = jwt.ClaimStrings{"something-else"} + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"aud\" must be \"the-best-audience\", but was \"something-else\"") + + c.Audience = jwt.ClaimStrings{"aud1", "aud2"} + require.ErrorContains(t, ai.checkClaims(c, rules), "required one and only one `aud` claim, but received 2") + }) + + t.Run("arbitrary field", func(t *testing.T) { + c := &flexibleClaims{other: map[string]any{}} + rules := rules(eq("arbitrary", "abc")) + + c.other["arbitrary"] = "abc" + require.NoError(t, ai.checkClaims(c, rules)) + + c.other["arbitrary"] = "123" + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be \"abc\", but was \"123\"") + + delete(c.other, "arbitrary") + require.ErrorContains(t, ai.checkClaims(c, rules), "claim rule on \"arbitrary\" couldn't be satisfied: claim not found") + }) + }) + + t.Run("map[string]any input", func(t *testing.T) { + t.Run("arbitrary field", func(t *testing.T) { + c := map[string]any{} + rules := rules(eq("arbitrary", "abc")) + + c["arbitrary"] = "abc" + require.NoError(t, ai.checkClaims(c, rules)) + + c["arbitrary"] = "123" + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be \"abc\", but was \"123\"") + + delete(c, "arbitrary") + require.ErrorContains(t, ai.checkClaims(c, rules), "claim rule on \"arbitrary\" couldn't be satisfied: claim not found") + }) + }) + + t.Run("unexpected input", func(t *testing.T) { + c := map[string]int{} + rules := rules(eq("arbitrary", "abc")) + c["arbitrary"] = 123 + require.ErrorContains(t, ai.checkClaims(c, rules), "unexpected incoming claims type: map[string]int") + }) + + t.Run("comparison ClaimEqual", func(t *testing.T) { + c := map[string]any{} + rules := rules(eq("arbitrary", "abc")) + + c["arbitrary"] = "abc" + require.NoError(t, ai.checkClaims(c, rules)) + + c["arbitrary"] = "123" + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be \"abc\", but was \"123\"") + + c["arbitrary"] = 123 + require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be a string, but was int") + }) + + t.Run("comparison ClaimGlob", func(t *testing.T) { + c := map[string]any{} + r := rules(glob("arbitrary", "*c")) + + c["arbitrary"] = "abc" + require.NoError(t, ai.checkClaims(c, r)) + + c["arbitrary"] = "123" + require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must match glob \"*c\", but value \"123\" did not match") + + c["arbitrary"] = "this string contains a c or two but doesn't end with one" // ensure glob isn't OK w/ a partial match + require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must match glob \"*c\", but value \"this string contains a c or two but doesn't end with one\" did not match") + + c["arbitrary"] = 123 + require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must be a string, but was int") + + r = rules(glob("arbitrary", "[abc")) + c["arbitrary"] = "abc" + require.ErrorContains(t, ai.checkClaims(c, r), "unable to parse glob for claim rule on \"arbitrary\"; glob = \"[abc\", err = unexpected end of input") + }) + + t.Run("comparison ClaimNested", func(t *testing.T) { + c := map[string]any{} + r := rules(nest("nest", eq("arbitrary", "abc"))) + + c["nest"] = map[string]any{"arbitrary": "abc"} + require.NoError(t, ai.checkClaims(c, r)) + + c["nest"] = map[string]any{"blah": "abc"} + require.ErrorContains(t, ai.checkClaims(c, r), "in nested claim \"nest\": claim rule on \"arbitrary\" couldn't be satisfied: claim not found") + + c["nest"] = map[string]int{"blah": 123} + require.ErrorContains(t, ai.checkClaims(c, r), "claim \"nest\" must be a map, but was map[string]int") + }) + + t.Run("multiple rules", func(t *testing.T) { + c := map[string]any{ + "arb1": "abc", + "arb2": "123", + "arb3": "def", + } + rules := rules( + eq("arb1", "abc"), + eq("arb2", "123"), + ) + + require.NoError(t, ai.checkClaims(c, rules)) + + delete(c, "arb1") + require.ErrorContains(t, ai.checkClaims(c, rules), "\"arb1\"") + + c["arb1"] = "abc" + delete(c, "arb2") + require.ErrorContains(t, ai.checkClaims(c, rules), "\"arb2\"") + }) +} + +func requireOutput[K auth.MethodOutput](t *testing.T, o auth.MethodOutput) K { + t.Helper() + k, isType := o.(K) + require.True(t, isType, "expected Verify output to be type %T, but was %T: %v", *new(K), o, o) + return k +} + +func TestAuthorizedIntegration(t *testing.T) { + t.Run("no token", func(t *testing.T) { + ai := &AuthorizedIntegration{} + aiBasic := &AuthorizedIntegration{PermitBasic: true} + req := httptest.NewRequest("GET", "https://example.org", nil) + output := ai.Verify(req, nil, nil) + requireOutput[*auth.AuthenticationNotAttempted](t, output) + output = aiBasic.Verify(req, nil, nil) + requireOutput[*auth.AuthenticationNotAttempted](t, output) + }) + + t.Run("not a JWT", func(t *testing.T) { + ai := &AuthorizedIntegration{} + req := httptest.NewRequest("GET", "https://example.org", nil) + req.Header.Set("Authorization", "Bearer abc") + output := ai.Verify(req, nil, nil) + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "parse JWT error") + }) + + t.Run("valid Bearer JWT", func(t *testing.T) { + ait := newAITester(t) + defer ait.close() + output := ait.bearerRequest() + success := requireOutput[*auth.AuthenticationSuccess](t, output) + res := success.Result + assert.EqualValues(t, 2, res.User().ID) + hasScope, scope := res.Scope().Get() + assert.True(t, hasScope) + assert.Equal(t, auth_model.AccessTokenScopeAll, scope) + assert.Nil(t, res.Reducer()) + }) + + t.Run("valid Basic JWT", func(t *testing.T) { + t.Run("PermitBasic", func(t *testing.T) { + ait := newAITester(t, + aiTweak(func(ai *AuthorizedIntegration) { + ai.PermitBasic = true + })) + defer ait.close() + output := ait.basicRequest() + requireOutput[*auth.AuthenticationSuccess](t, output) + }) + + t.Run("!PermitBasic", func(t *testing.T) { + ait := newAITester(t, + aiTweak(func(ai *AuthorizedIntegration) { + ai.PermitBasic = false + })) + defer ait.close() + output := ait.basicRequest() + requireOutput[*auth.AuthenticationNotAttempted](t, output) + }) + }) + + t.Run("JWT expiry", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.ExpiresAt = jwt.NewNumericDate(time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local)) + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "token is expired") + }) + + t.Run("JWT issued at", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.IssuedAt = jwt.NewNumericDate(time.Date(2027, time.January, 1, 12, 0, 0, 0, time.Local)) + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "token used before issued") + }) + + t.Run("JWT not before", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.NotBefore = jwt.NewNumericDate(time.Date(2027, time.January, 1, 12, 0, 0, 0, time.Local)) + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "token is not valid yet") + }) + + t.Run("issuer", func(t *testing.T) { + t.Run("missing in claim", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.Issuer = "" + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "invalid `iss` claim") + }) + + t.Run("mismatch DB", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.Issuer = "https://whoops.example.org" + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "matching authorized_integration not found") + }) + + t.Run("mismatch openid metadata", func(t *testing.T) { + ait := newAITester(t, + openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) { + oidc.Issuer = "https://whoops.example.org" + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "issuer mismatch") + }) + + t.Run("non-HTTPS issuer", func(t *testing.T) { + ait := newAITester(t, + aiDBTweak(func(aiDB *auth_model.AuthorizedIntegration) { + aiDB.Issuer = "http://whoops.example.org" + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "unsupported URL scheme: \"http://") + }) + + t.Run("signing alg values supported doesn't include in-use alg", func(t *testing.T) { + ait := newAITester(t, + openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) { + oidc.IDTokenSigningAlgValuesSupported = []string{"WEIRD"} + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, " issuer supports signature algorithms []string{\"WEIRD\"}, but received token with algorithm RS256") + }) + }) + + t.Run("audience", func(t *testing.T) { + t.Run("missing in claim", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.Audience = jwt.ClaimStrings{} + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "required one and only one `aud` claim, but received 0") + }) + + t.Run("multiple in claim", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.Audience = jwt.ClaimStrings{"abc", "def"} + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "required one and only one `aud` claim, but received 2") + }) + + t.Run("mismatch DB", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.Audience = jwt.ClaimStrings{"abc"} + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "matching authorized_integration not found") + }) + }) + + t.Run("checks claim rules", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.other["custom-claim"] = "oops wrong claim" + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "claim \"custom-claim\" must be \"custom-claim-value\"") + }) + + t.Run("key algorithms", func(t *testing.T) { + for _, alg := range jwtx.ValidAsymmetricAlgorithms { + t.Run(alg, func(t *testing.T) { + ait := newAITester(t, + jwtxKeyTweak(func() jwtx.SigningKey { + keyPath := filepath.Join(t.TempDir(), fmt.Sprintf("jwt-%s.priv", alg)) + jwtSigningKey, err := jwtx.InitAsymmetricSigningKey(keyPath, alg) + require.NoError(t, err) + return jwtSigningKey + }), + openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) { + oidc.IDTokenSigningAlgValuesSupported = []string{alg} + }), + ) + defer ait.close() + output := ait.bearerRequest() + requireOutput[*auth.AuthenticationSuccess](t, output) + }) + } + }) + + 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) { + oidc.JwksURI = "https://whoops.example.org/.keys" + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "jwks_uri host mismatch: must be the same as issuer host") + }) + + t.Run("non-HTTPS JWKS address", func(t *testing.T) { + ait := newAITester(t, + openIDTweak(func(oidc *openIDConfiguration, ait *AuthorizedIntegrationTester) { + oidc.JwksURI = strings.ReplaceAll(ait.testServer.URL, "https://", "http://") + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "unsupported URL scheme: \"http://") + }) + + t.Run("missing key", func(t *testing.T) { + ait := newAITester(t, + jwksTweak(func(keys *openIDKeys) { + keys.Keys = []map[string]any{} + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "no key identified") + }) + + t.Run("alg missing", func(t *testing.T) { + ait := newAITester(t, + jwksTweak(func(keys *openIDKeys) { + for k := range keys.Keys { + delete(keys.Keys[k], "alg") + } + })) + defer ait.close() + output := ait.bearerRequest() + // per RFC7517 "alg" is optional + requireOutput[*auth.AuthenticationSuccess](t, output) + }) + + t.Run("alg mismatch", func(t *testing.T) { + ait := newAITester(t, + jwksTweak(func(keys *openIDKeys) { + for k := range keys.Keys { + keys.Keys[k]["alg"] = "WEIRD" + } + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "doesn't match expected algorithm RS256, was WEIRD") + }) + + t.Run("use missing", func(t *testing.T) { + ait := newAITester(t, + jwksTweak(func(keys *openIDKeys) { + for k := range keys.Keys { + delete(keys.Keys[k], "use") + } + })) + defer ait.close() + output := ait.bearerRequest() + // per RFC7517 "use" is optional + requireOutput[*auth.AuthenticationSuccess](t, output) + }) + + t.Run("use isn't 'sig'", func(t *testing.T) { + ait := newAITester(t, + jwksTweak(func(keys *openIDKeys) { + for k := range keys.Keys { + keys.Keys[k]["use"] = "enc" + } + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "isn't designated for signing usage, was enc") + }) + + t.Run("large JWKS document", func(t *testing.T) { + ait := newAITester(t, + jwksTweak(func(keys *openIDKeys) { + var keyContents map[string]any + for _, v := range keys.Keys { + keyContents = v + } + for range 128 { + keys.Keys = append(keys.Keys, keyContents) + } + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "failed to decode (response body restricted to 16384 bytes)") + }) + }) + + t.Run("specific scopes", func(t *testing.T) { + ait := newAITester(t, + aiDBTweak(func(aiDB *auth_model.AuthorizedIntegration) { + aiDB.Scope = "read:repository,read:user" + })) + defer ait.close() + output := ait.bearerRequest() + success := requireOutput[*auth.AuthenticationSuccess](t, output) + res := success.Result + hasScope, scope := res.Scope().Get() + assert.True(t, hasScope) + readRepository, err := scope.HasScope(auth_model.AccessTokenScopeReadRepository) + require.NoError(t, err) + assert.True(t, readRepository, "read:repository") + readUser, err := scope.HasScope(auth_model.AccessTokenScopeReadUser) + require.NoError(t, err) + assert.True(t, readUser, "read:user") + writeAdmin, err := scope.HasScope(auth_model.AccessTokenScopeWriteAdmin) + require.NoError(t, err) + assert.False(t, writeAdmin, "write:admin") + }) +} + +type AuthorizedIntegrationTester struct { + t *testing.T + ai *AuthorizedIntegration + dbAI *auth_model.AuthorizedIntegration + jwtSigningKey jwtx.SigningKey + testServer *httptest.Server + resetHTTPClient func() + tweaks []tweak +} + +func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester { + fixedTime := time.Date(2026, time.January, 1, 16, 0, 0, 0, time.Local) + ait := &AuthorizedIntegrationTester{ + t: t, + ai: &AuthorizedIntegration{ + fixedTime: &fixedTime, + }, + tweaks: tweaks, + } + for _, tweak := range ait.tweaks { + if aiTweak, is := tweak.(aiTweak); is { + aiTweak(ait.ai) + } + } + + var jwtSigningKey jwtx.SigningKey + for _, tweak := range ait.tweaks { + if jwtxKeyTweak, is := tweak.(jwtxKeyTweak); is { + jwtSigningKey = jwtxKeyTweak() + } + } + if jwtSigningKey == nil { + var err error + keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv") + jwtSigningKey, err = jwtx.InitAsymmetricSigningKey(keyPath, "RS256") + require.NoError(t, err) + } + ait.jwtSigningKey = jwtSigningKey + + 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{ + Issuer: ait.dbAI.Issuer, + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + JwksURI: fmt.Sprintf("%s/.keys", ait.dbAI.Issuer), + } + for _, tweak := range ait.tweaks { + if tweak, is := tweak.(openIDTweak); is { + tweak(retval, ait) + } + } + err := json.NewEncoder(w).Encode(retval) + require.NoError(t, err) + return + } + if r.URL.Path == "/api/actions/.keys" { + jwk, err := ait.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 := &openIDKeys{ + Keys: []map[string]any{jwkMapAny}, + } + for _, tweak := range ait.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) + })) + + // 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.dbAI = &auth_model.AuthorizedIntegration{ + UserID: 2, + Scope: auth_model.AccessTokenScopeAll, + Issuer: fmt.Sprintf("%s/api/actions", ait.testServer.URL), + Audience: fmt.Sprintf("https://forgejo.example.org/-/coolguy/authorized-integration/%s", gouuid.New().String()), + ClaimRules: &auth_model.ClaimRules{ + Rules: []auth_model.ClaimRule{ + { + Claim: "custom-claim", + Comparison: auth_model.ClaimEqual, + Value: "custom-claim-value", + }, + }, + }, + } + for _, tweak := range ait.tweaks { + if tweak, is := tweak.(aiDBTweak); is { + tweak(ait.dbAI) + } + } + _, err := db.GetEngine(t.Context()).Insert(ait.dbAI) + require.NoError(t, err) + + return ait +} + +func (ait *AuthorizedIntegrationTester) signedJWT() string { + claims := flexibleClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: ait.dbAI.Issuer, + Audience: jwt.ClaimStrings{ait.dbAI.Audience}, + }, + other: map[string]any{ + "custom-claim": "custom-claim-value", + }, + } + for _, tweak := range ait.tweaks { + if tweak, is := tweak.(claimTweak); is { + tweak(&claims) + } + } + signedToken, err := ait.jwtSigningKey.JWT(claims) + require.NoError(ait.t, err) + return signedToken +} + +func (ait *AuthorizedIntegrationTester) bearerRequest() auth.MethodOutput { + signedToken := ait.signedJWT() + req := httptest.NewRequest("GET", "https://forgejo.example.org", nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signedToken)) + return ait.ai.Verify(req, nil, nil) +} + +func (ait *AuthorizedIntegrationTester) basicRequest() auth.MethodOutput { + signedToken := ait.signedJWT() + req := httptest.NewRequest("GET", "https://forgejo.example.org", nil) + req.SetBasicAuth("", signedToken) + return ait.ai.Verify(req, nil, nil) +} + +func (ait *AuthorizedIntegrationTester) close() { + ait.resetHTTPClient() + ait.testServer.Close() +} + +type tweak any + +type claimTweak func(*flexibleClaims) + +type aiTweak func(*AuthorizedIntegration) + +type openIDTweak func(*openIDConfiguration, *AuthorizedIntegrationTester) + +type jwksTweak func(*openIDKeys) + +type aiDBTweak func(*auth_model.AuthorizedIntegration) + +type jwtxKeyTweak func() jwtx.SigningKey