feat: authorized integrations DB models and authentication implementation (#12261)
Authorized Integrations is a new feature to 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 that user. This is an authentication mechanism that requires zero preconfiguration of shared secrets, and instead establishes trust through short-lived secrets (JWTs) that are signed by the issuer, signatures are validated by comparison with published public keys, and a public-keys retrieved through well-known HTTP endpoints secured with TLS verification. The primary goal of Authorized Integrations is to support a mechanism for Forgejo Actions to receive elevated, but controlled, additional access to Forgejo. More details as to what the end result will look like are available in the [design proposal](https://codeberg.org/forgejo/forgejo/issues/3571#issuecomment-13268004) on #3571. This PR adds the core database storage and authentication verification for Authorized Integrations, with these capabilities: - An Authorized Integration is resolved by a unique key of an "issuer" and an "audience". The value of "issuer" is defined by the remote integration, and the value of "audience" will incorporate a unique identifier generated by Forgejo. - Example issuer: `https://token.actions.githubusercontent.com/` is the issuer for GitHub JWTs - Example audience: `https://forgejo.example.org/-/mfenniak/authorized-integration/6cc55ba0` is the expected format for a random audience field that Forgejo will generate. - JWTs can contain any number of claims, which are represented as a JSON object; Forgejo can validate these with a flexible policy. - 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. - Authorized Integrations support a `ClaimRules` system which allows claim equal, glob, and nested object inspection. - `{"claim":"sub","comparison":"eq","value":"repo:mfenniak/forgejo-runner-testrepo:pull_request"}` -- would validate that `sub` exactly equals the specific value - `{"claim":"sub","comparison":"glob","value":"repo:mfenniak/forgejo-runner-testrepo:*"}` -- would validate that `sub` matches the given string prefix but allow any event - When a JWT is received on an incoming API call, Forgejo retrieves the Authorized Integration from the DB (if present), validates the token signature against a remote JWKS, validates the claims, and grants API access as the user with a permission scope defined on the Authorized Integration. In addition to the unit testing provided here, this PR has been manually integration tested against three JWT issuing systems: Forgejo Actions, GitHub Actions, and AWS STS GetWebIdentityToken. Careful consideration has been made of these security concerns: - SSRF attacks against Forgejo are prevented by: - having a blocklist on remote HTTP validation requests which prevent access to internal network resources, - ensuring that authorized integrations are created by users with matching issuers, before attempting to validate tokens - Resource utilization attacks against Forgejo are reduced by limiting the possible size of external metadata requests; when fetching `/.well-known/openid-configuration` and `jkws_uri`'s from remote, untrusted servers, a maximum response size of 16 kB is enforced - Only well-known secure assymmetric JWT signing algorithms are supported -- in particular, the sketchy `none` JWT algorithm isn't supported. - JWT validation is covered by extensive unit tests, covering validation of all JWT timestamps, validation of the issuers, validation of the issuer's documented supported signing algorithms. This PR serves as a core, and many enhancements are required for this to be a usable system for users. ## 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... - [ ] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - Documentation updates for new config entries will be authored. - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] 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. - [x] 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. - Marking not visible as there's no mechanism to interact with this backend yet. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12261 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
committed by
Mathieu Fenniak
parent
b17ed16f31
commit
48218c654b
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -226,6 +226,7 @@ func LoadSettings() {
|
||||
loadProjectFrom(CfgProvider)
|
||||
loadMimeTypeMapFrom(CfgProvider)
|
||||
loadF3From(CfgProvider)
|
||||
loadAuthorizedIntegrationFrom(CfgProvider)
|
||||
}
|
||||
|
||||
// LoadSettingsForInstall initializes the settings for install
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user